Пишем хорошие компоненты, которые захочется переиспользовать, а плохие — не пишем

—

от автора

Привет! Меня зовут Антон Крылов, я фронтенд-разработчик в Авито — занимаюсь профилями. До Авито я работал тимлидом и видел много хорошего и плохого кода. В этой статье — мои наблюдения и размышления на тему, как сделать компонент, который приятно использовать и который не придётся переписывать через время.

Прежде чем говорить о том, как написать хороший компонент, давайте поговорим о том, как делать точно не нужно.

Плохой компонент — сложный

Компонент можно назвать сложным, если:

  1. У него нет тестов. Такой компонент без будет тяжело использовать извне, а расширять — ещё сложнее. 

  2. Он делает слишком много. Я встречал компоненты на 1000–2000 строк, которые умеют всё. Но чтобы что-то поправить в таком компоненте, может понадобиться целый спринт.

  3. Он Overengineered. Разработчики часто грешат добавлением искусственных сложностей в код. Например: 

  • useEffect вместо useCallback. Я периодически встречаю такой кейс, когда в одном месте обрабатываются действия после изменения state, которое происходит где-то глубоко в компонентах фильтра. Почитайте, почему это не лучшее решение.

  • RxJS(подставьте сюда любую технологию с которой сложно разобраться) везде. Это классная штука, но иногда она не нужна. Например, для сингл-кейса лучше написать императивный код.

  • Лишние слои абстракции. Иногда их плодят, чтобы весь код переиспользовался как можно больше, а в итоге потребности в переиспользовании нет. 

Чтобы понять, как работает такой искусственно усложнённый компонент, придётся прочитать весь код. Это долго и неэффективно.

Плохой компонент — неконсистентный

Это компонент, чьё состояние или пропсы не соответствуют ожидаемому поведению. 

Неконсистентность возникает, если компонент изменяет свое состояние в ответ на действия пользователя, но не обновляет соответствующие данные в других компонентах, которые зависят от него. Это может привести к непредсказуемому поведению или сбоям.

Проблему неконсистетности можно решить на этапе дизайна API для компонента. Допустим, мы написали компонент TextField, а потом захотели расширить его валидацией. Можно сделать так:

export interface TextFieldProps extends Omit<React.HTMLProps<HTMLInputElement>, 'onChange'> { ... errorText?: string; validation?: (value: string) => boolean; ... }

Но такое решение — неконсистентное. Если по ошибке передать в TextField только один из пропов, ничего работать не будет.

Ещё вариант — добавить валидацию пропов, но и это не лучшее решение. В таком варианте только один вариант для ошибки — добавить новые не получится:

export interface TextFieldProps extends Omit<React.HTMLProps<HTMLInputElement>, 'onChange'> { ... validationProps?: { errorText: string; validation: (value: string) => boolean; }, ... }

Лучшее решение для консистентности — пользоваться правилом «single prop — single logic» — на каждый проп должна быть своя логика:

export interface TextFieldProps extends Omit<React.HTMLProps<HTMLInputElement>, 'onChange'> { ... validation: (value: string) => string; ... }

Другие признаки плохого компонента

Есть ещё два признака компонентов, которые сложно использовать и тем более расширять: использование отображения на флагах и чересчур большое количество пропсов — Props Hell.

Отображение на флагах. Проблема в том, что рано или поздно количество флагов разрастётся. И тогда будет тяжело разобраться, что происходит в коде.

return isCreateForm ? <CreateForm /> : <EditForm />

Если без флагов никак не обойтись — делайте маппинг.

const forms = { createForm: CreateForm, editForm: EditForm };  interface Props { formName: keyof typeof forms; }  const Form = ({ formName }: Props) => { const Component = forms[formName];  return <Component /> }

Props Hell — это ситуация, в которой компонент принимает большое количество пропсов, которые должны передаваться от родительского компонента.

Props Hell обычно приводит к низкой поддерживаемости и сложности в изменении и тестировании компонента, потому что разбираться в таком тяжело и долго:

function TextField({ withoutImplicitFocus, disabled, onFocus, hasLowerCase, hasAutoSelectAfterSubmit, onChange: onChangeProp, hasAutoSelect = true, selector = DEFAULT_SELECTOR, inputSize = "l", priority = 0, dataE2e = selector || DEFAULT_SELECTOR, dataTestId = selector || DEFAULT_SELECTOR, handleEnter = selectOnEnter, transformValueOnChange = transformToUppercase, onKeyDown = noop, useSuperFocus = useSuperFocusDefault, useFocusAfterError = useFocusAfterErrorDefault, useSuperFocusOnKeydown = useSuperFocusOnKeydownDefault, useSuperFocusAfterDisabled = useSuperFocusAfterDisabledDefault, someJSX, ...textFieldProps }: Props)

Чтобы избежать Props Hell, можно разбить компонент на более мелкие или использовать контекст.

Вообще, прежде чем писать компонент, нужно понять, зачем мы его делаем. Разработчики сами должны решать, что хорошо, а что плохо. Это напрямую зависит от того, насколько решение попадает в текущие требования и изменяется под будущие. Для этого стоит разобраться, какие требования к компонентам существуют, и на что нужно ориентироваться при написании кода.

Функциональные требования хорошего компонента

Функциональные требования к компонентам говорят о том, что они должны уметь делать и как должны выглядеть. 

Функциональные требования обычно формулирует бизнес и дизайнеры. Первым нужно, чтобы изменения приносили больше денег, а вторым — чтобы это было красиво и удобно. 

Есть ещё требования от разработки — чтобы компоненты были простые и понятные. Но на самих себя, к сожалению, разработчики часто забивают и концентрируются на бизнес-требованиях. Так делать не стоит.

Требования от бизнеса, дизайнеров и разработчиков. Последние тоже важны, не нужно на них забивать

Требования от бизнеса, дизайнеров и разработчиков. Последние тоже важны, не нужно на них забивать

Нефункциональные требования хорошего компонента

Нефункциональные требования касаются как раз разработчиков. Они о том, как сделать компонент простым, понятным и лёгким для переиспользования. 

Вот какие нефункциональные характеристики есть у компонентов:

Гибкость — насколько компонент способен адаптироваться к различным сценариям использования, не нарушая своей функциональности. Гибкий компонент можно переиспользовать в разных контекстах и с разными данными без большого количества изменений в коде. Например, компонент TextField из примера выше — негибкий: слишком большое количество пропсов усложняют модификацию.

Коробочность — насколько просто пользоваться компонентом извне. Допустим, у нас есть компонент Table, который принимает только URL. Передавать данные таблицы через URL неудобно — это плохое решение для внешнего API. Но это может быть и хорошим решением — например, для CRUD-таблицы, где все данные ± однотипны.

interface Props { entryUrl: string; }  const Table = ({ entryUrl }: Props) => { const { rows, headers } = useDataFromUrl(entryUrl); … }

Зависимость — насколько компонент зависим от других компонентов. Если у компонента много зависимостей, он менее гибкий и более хрупкий.

Пример компонента с множеством зависимостей. Так — плохо

Пример компонента с множеством зависимостей. Так — плохо

Явность — насколько глубоко нужно знать бизнес-логику или быть погружённым в проект, чтобы работать с компонентом. Допустим, у нас есть компонент PrimaryButton. Для того, чтобы понять, что происходит, нужны знания о дизайн-системе: например, что из себя представляет variant='primary'

const PrimaryButton = ({ children }: Props) => { return ( <Button variant='primary'>{children}</Button> ) }

Если хочется сделать код более явным, лучше избегать таких ситуаций. Например, вместо variant='primary' можно использовать более очевидный вариант — прокинуть код цвета:

const PrimaryButton = ({ children }: Props) => { return ( <Button color='#f00'>{children}</Button> ) }

Функциональность — сколько возможностей предоставляет компонент. Например, у нас есть компонент BasicForm, который и отрисовывает форму, и умеет отправлять данные по нажатию Submit.

function BasicForm() {   const form = useForm({     onSubmit: (values) => {       alert(JSON.stringify(values, null, 2));       console.log('values', values);     },     children: [       {         label: 'First Name',         name: 'firstName',         component: 'Input',         value: '',       },       {         component: 'Submit',         text: 'submit',       },     ],   });   return <Form form={form} />; }

Хороший компонент — соответствует запросам

В каждой ситуации важность нефункциональных требований разная, и зависит от конкретных бизнес-требований. Поэтому хороший компонент — это компонент с набором характеристик, который соответствуют конкретным запросам и сценариям использования. 

Допустим, у нас есть UI-кит. Рассмотрим, как характеристики будут меняться в зависимости от ситуации.

Ситуация 1: UI-кит используется во многих проектах. Так обычно и бывает: чаще всего наборы компонентов используются в большом количестве проектов. 

Тогда возникают такие нефункциональные требования:

  • Переопределение стилей должно быть возможным только на уровне темы — для единообразия и согласованности дизайна.

  • В наборе должно быть как можно меньше зависимостей — чтобы UI-кит меньше весил.

  • API должно быть максимально простым и явным — чтобы им легко мог пользоваться даже стажёр, который пока мало знает про бизнес-требования.

    Важность нефункциональных требования для UI-кита, который будет использоваться во многих проектах на лепестковой диаграмме

    Важность нефункциональных требования для UI-кита, который будет использоваться во многих проектах на лепестковой диаграмме

Ситуация 2: UI-кит используется в ограниченном количестве проектов. Допустим, мы точно знаем, что набор не будет использоваться в множестве проектов.

Тогда нефункциональные характеристики поменяются:

  • Можно позволить более высокую гибкость, поскольку в небольшом количестве проектов проще отслеживать согласованность интерфейсов. 

  • Функциональности должно быть больше, чтобы на уровне конкретного проекта приходилось делать как можно меньше.

  • Явность API не так важна — важнее функциональность. 

Важность нефункциональных требований для UI-кита, который будет использоваться в небольшом количестве проектов на лепестковой диаграмме

Важность нефункциональных требований для UI-кита, который будет использоваться в небольшом количестве проектов на лепестковой диаграмме

Как написать хороший компонент

У хорошего компонента характеристики соответствуют сценарию его использования. Давайте разберёмся, как влиять на нефункциональные требования, чтобы «подгонять» их под запросы для каждого конкретного компонента. 

Писать тесты в любом случае. Они влияют на все характеристики, хотя и не напрямую. Например, в процессе написания тестов придётся учитывать зависимости — то есть лишний раз задуматься о них.

Использовать Dependency Injection (DI), если нужны гибкость и независимости. Dependency Injection позволяет вынести создание объектов и управление их зависимостями за пределы класса — это делает код более гибким и расширяемым.

Кроме того, DI позволяет создавать более тестируемый код, так как мы можем легко заменить реальные зависимости на заглушки в тестах.

Вернёмся к примеру с компонентом TextField. В нём много потенциальных мест для использования Dependency Injection. Например, вместо пропса handleEnter можно прокинуть функцию, а вместо useSuperFocus — хук.

function TextField({ withoutImplicitFocus, disabled, onFocus, hasLowerCase, hasAutoSelectAfterSubmit, onChange: onChangeProp, hasAutoSelect = true, selector = DEFAULT_SELECTOR, inputSize = "l", priority = 0, dataE2e = selector || DEFAULT_SELECTOR, dataTestId = selector || DEFAULT_SELECTOR, // вместо пропса ниже можно прокинуть функцию handleEnter = selectOnEnter, transformValueOnChange = transformToUppercase, onKeyDown = noop, // вместо пропса ниже можно использовать хук useSuperFocus = useSuperFocusDefault, useFocusAfterError = useFocusAfterErrorDefault, useSuperFocusOnKeydown = useSuperFocusOnKeydownDefault, useSuperFocusAfterDisabled = useSuperFocusAfterDisabledDefault, someJSX, ...textFieldProps }: Props)

Dependency Injection в React осуществляет функция inject. Подробнее об этом можно почитать в GitHub-репозитории react-ioc.

Также могут пригодиться:

Применять DSL-like подход, если нужны коробочность и явность. Он поможет уменьшить количество кода, необходимого для создания пользовательского интерфейса, и сделать разработку более доступной и понятной для новичков.

Например, можно оставить общее поведение формы BasicForm (из примера выше) в хуках:

import { useForm } from "react-hook-form";  export default function App() {   const { register, handleSubmit } = useForm({ shouldUseNativeValidation: true });    const onSubmit = async (data) => {     console.log(data);   };    return (     <form onSubmit={handleSubmit(onSubmit)}>       <input {...register("firstName", { required: "Please enter your first name." })} />       <input type="submit" />     </form>   ); }

В такой ситуации можно легко сделать хоть 1000 таких форм — каждый раз переписывать их не придётся.

TL;DR

1. Плохой компонент — сложный и неконсистентный.

2. У компонентов есть характеристики — нефункциональные требования:

  • гибкость;

  • коробочность;

  • зависимость;

  • явность;

  • функциональность.

3. У хорошего компонента характеристики соответствуют запросам и требованиям.

4. На характеристики можно и нужно влиять, чтобы «подгонять» их под запросы и требования. Для этого можно использовать:

  • тесты;

  • Dependency Injection;

  • DSL-like подход.

Полезные ресурсы

Моя статья на русском: Гайд по написанию и рефакторингу компонентов, которые хочется переиспользовать →

Статьи на английском:

GitHub-репозитории:

Предыдущая статья: Разработка — всё? Действительно ли нас всех заменят роботы?


ссылка на оригинал статьи https://habr.com/ru/companies/avito/articles/739330/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *