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

от автора

Случалось ли вам, выполняя какую-то задачу, понять, что самый простой путь — нажать Сtrl+C, Сtrl+V: перетащить из соседней папочки пару файлов, поменять пару строчек, и будет ок? Повторялось ли это ощущение? Я хочу рассказать о том, как боролся с этой проблемой и к чему пришёл вместе с командой. Назовём это решение «универсальные компоненты» (если у кого-то будет более удачное название для концепции, жду в коментариях). Примеры буду приводить в основном на React, но концепции будут общие.

Немного обо мне и команде. У нас не совсем обычная ситуация для Яндекса — мы существуем немного в изоляции с точки зрения пересечения с другими интерфейсами. С одной стороны, у нас есть возможность пользоваться всеми благами дизайн-систем и наработок Яндекса, с другой — мы не сталкиваемся с высокой стоимостью изменений (проще говоря, можем существовать как автономная команда). Поэтому мой опыт может быть полезен не только для ребят, которые сидят в больших корпорациях, но и для тех, кто работает в маленьких компаниях или стартапах (многие из решений, о которых расскажу ниже, были приняты в соответствии с принципами lean development).

Аналогия

Представьте, что каждый компонент — это человечек, с которым вам придётся пообщаться. У каждой стандартной страницы (например, такой) — примерно 20 человечков, с которыми она общается. Если идти в каждый из этих компонентов, то они общаются ещё с 5–15 человечками (скорее всего, большая часть общения пересекается, но не суть).

В целом, не страшно, когда такая история есть у одной страницы. Но когда страниц становится больше 100–200, каждая из них является отдельным компонентом. Теперь представьте, что вам нужно будет пообщаться с каждым из этих человечков, чтобы добавить какого-то из них в общую группу.

Аналогия, конечно, так себе, но суть вы поняли — уменьшая количество соединений в графе зависимостей или сводя их в одну точку (единая точка входа), вы сильно упрощаете себе жизнь/ Вы всегда знаете, что можете пообщаться с главным руководителем всех страничек. И самый внимательный читатель увидит здесь принцип high cohesion — подробнее можно почитать здесь.


Одна схема против другой

История

В 2020 году я пришёл в Маркет, в отдел разработки складов. На тот момент у меня было около года опыта в промышленной разработке, и меня нанимали как единственного разработчика интерфейсов. Ну вы поняли — мне дали карт-бланш.

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

Короче, я бросился упрощать архитектуру, пока не стало слишком поздно. Весь флоу пользователя в основном состоял из однотипных экранов с одним полем ввода, и чтобы не дублировать функциональность, я выделил для себя один компонент, который очень помог мне в дальнейшем — InputPage. Да, это просто страница с полем ввода и двумя кнопками. Но потом там появилась обработка loading-состояния, возможность добавить шапку, чтобы всё скроллилось только под ней, добавить что-то до и после инпута, досыпать кнопок. Но основная функциональность осталась той же — поле ввода и две кнопки.

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

Впоследствии это помогло сразу для всех экранов использовать специальный тип инпута с жёстким фокусом (когда экран гас, слетал фокус) с переводом в верхний регистр и выделением текста при фокусе или ошибках (чтобы было проще сканировать).

На данный момент примерно 80% проекта для кладовщиков сделано с помощью этого компонента.

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

Выводы из истории

  1. Всегда думай, прежде чем делать.
  2. Всегда думай при нажатии Сtrl+С, не пришло ли время создать новый компонент или найти существующий.
  3. Если делаешь новый компонент, подумай, точно ли нет уже существующих компонентов, в которые можно это добавить.
  4. 2–3 компонента, которые разруливают большую часть приложения, сильно упрощают жизнь: полная унификация дизайнов, подходов к разработке, обработке различных состояний и так далее.

Сам гайд

Я считаю подход «Просто рефакторили и сделали хорошо» наиболее универсальным (остальное зависит от контекста разработки). И вот почему:

  • Чаще всего детальное проектирование приводит к тому, что компонентом либо сложно пользоваться, либо для этого нужны какие-то секретные знания.
  • Любой код рано или поздно нужно рефакторить, и это факт. Поэтому лучше сразу делать код готовым к рефакторингу, а не ко всем случаям жизни и использования.
  • Вам не нужно изобретать велосипед, когда можно придумать только колесо (с точки зрения экономии мыслетоплива — действительно классный подход).

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

Итак, на какие вопросы важно ответить:

  1. Что этот компонент делает, какая у него функциональность?

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

    Если компонент уже существует, хорошо будет задать себе следующий вопрос.

  2. А не слишком ли много он знает?

    Часто случается, что компонентам дают достаточно много знаний о внешнем мире (например, кнопке говорят, что она находится в навигационном меню и теперь ей надо вести себя как ссылка). Чаще всего это знак, что пора разделить компоненты и дать каждому своё отдельное тайное знание.

  3. Есть ли у компонента дефолтное поведение?

    Чаще всего, когда вы пишете что-то большое и универсальное, у вас будет много дефолтного поведения (те самые Ctrl+C, Ctrl+V, о которых говорилось в начале и которые мы объединили в один компонент). Важно задуматься о том, как вы будете переопределять дефолтное поведение заранее (если его, конечно, можно переопределять).

    Пример дефолтного поведения с возможностью переопределения:

    export interface Props extends Omit<React.HTMLProps<HTMLInputElement>, 'onChange'> {    withoutImplicitFocus?: boolean;    hasAutoSelect?: boolean;    hasLowerCase?: boolean;    hasAutoSelectAfterSubmit?: boolean;    selector?: string | null;    priority?: number;    onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;    onChange?: (value: string) => void;    inputSize: 'm' | 'l';    dataE2e?: string;    dataTestId?: string; }  function TextField({   withoutImplicitFocus,   hasLowerCase = false,   hasAutoSelect = true,   hasAutoSelectAfterSubmit = false,   selector = DEFAULT_SELECTOR,   priority = 0,   disabled,   onKeyDown = noop,   inputSize = "l",   onFocus,   onChange: onChangeProp,   dataE2e = selector || DEFAULT_SELECTOR,   dataTestId = selector || DEFAULT_SELECTOR,   ...textFieldProps }: Props) {

    Пример поведения без возможности переопределения:

    export interface Props extends Omit<React.HTMLProps<HTMLInputElement>, 'onChange'> {    selector: string | null;    priority: number;    onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;    onChange?: (value: string) => void;    dataE2e?: string;    inputSize: 'm' | 'l'; }  function TextField({   disabled,   onFocus,   onChange: onChangeProp,   onKeyDown,   selector,   dataE2e,   inputSize,   priority,   ...textFieldProps }: Props) {
  4. Можно ли переопределять поведение компонента?

    Над этим вопросом стоит внимательно подумать. Допустим, есть проекты, в которых тему и её цвета никак нельзя менять (и это считается правильным и зашивается в CSS-in-JS внутри системы компонент).

    Если можно, то есть разные варианты реализации переопределения (во взрослых ЯП это называется DI, но, как мне кажется, в мире фронтенда это не самое распространённое явление):

    1. Пропсы
    2. Контекст (менее явный, но чуть более гибкий)
    3. Стор (как вариация использования контекста)

    Через пропсы можно прокидывать многое, например:

    1. Флаги
    2. Хуки (отличный, кстати, способ переопределения)
    3. JSX (a.k.a. слоты, не очень хорошая штука с точки зрения перфа, так как вызывает много ререндеров — кстати, вот пост от Артура, создателя Reatom, по поводу возможных оптимизаций слотов)
    4. Любые переменные, которые вам взбредут в голову (функции — тоже переменные)

    Пример прокидывания через пропсы с дефолтными вариантами:

    export interface Props extends Omit<React.HTMLProps<HTMLInputElement>, 'onChange'> {    withoutImplicitFocus?: boolean;    hasAutoSelect?: boolean;    hasLowerCase?: boolean;    hasAutoSelectAfterSubmit?: boolean;    selector?: string | null;    priority?: number;    onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;    onChange?: (value: string) => void;    inputSize: 'm' | 'l';    dataE2e?: string;    dataTestId?: string;   transformValueOnChange?: (value: string) => string;   useFocusAfterError: typeof useFocusAfterErrorDefault,   useSuperFocusAfterDisabled: typeof useSuperFocusAfterDisabledDefault,   useSuperFocus: typeof useSuperFocusDefault,   useSuperFocusOnKeydown: typeof useSuperFocusOnKeydownDefault,   handleEnter: typeof selectOnEnter,   someJSX: ReactNode, }  const 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) => {

    Через контекст можно прокидывать то же самое. Пример прокидывания через контекст:

    export interface Props extends Omit<React.HTMLProps<HTMLInputElement>, 'onChange'> {    withoutImplicitFocus?: boolean;    hasAutoSelect?: boolean;    hasLowerCase?: boolean;    hasAutoSelectAfterSubmit?: boolean;    selector?: string | null;    priority?: number;    onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;    onChange?: (value: string) => void;    dataE2e?: string;    dataTestId?: string; }  function TextField({   withoutImplicitFocus,   disabled,   onFocus,   hasLowerCase,   hasAutoSelectAfterSubmit,   onChange: onChangeProp,   hasAutoSelect = true,   selector = DEFAULT_SELECTOR,   priority = 0,   dataE2e = selector || DEFAULT_SELECTOR,   dataTestId = selector || DEFAULT_SELECTOR,   onKeyDown = noop,   ...textFieldProps }: Props) {   const ref = useRef<HTMLInputElement | InputMaskClass>();   const superFocuEnable = useAtom(superFocusEnableAtom);   const superFocusCondition = useAtom(      superFocusPriorityAtom,      (atomValue) =>         superFocuEnable &&         atomValue?.selector === selector &&         selector !== null,      [selector, superFocuEnable]   );    const { useSuperFocusAfterDisabled, useFocusAfterError, useSuperFocus, useSuperFocusOnKeydown, transformValueOnChange, handleEnter, inputSize } = useContext(TextFieldDefaultContext);  useSuperFocus(selector, priority); useSuperFocusOnKeydown(ref, superFocusCondition); useSuperFocusAfterDisabled(ref, disabled, superFocusCondition); useFocusAfterError(ref, withoutImplicitFocus);
  5. Что выбрать: контекст или пропсы?

    Если у вас есть только один вариант использования компонента на данный момент — смело делайте с помощью пропсов. Если же у вас потребности формата «Вот в этой части приложения должно быть так, а в этой — вот так», то контекст — ваш выбор.

  6. Как сделать другой дефолтный дефолт?

    В случае пропсов это будет компонент-обёртка, в случае контекста — другое дефолтное значение в контексте.

  7. Какие есть способы добавлять компоненту поведение, когда он уже существует в продакшене?
    1. Композиция (приём древний, всем известный: наворачиваете HOC, приправляете compose-функцией, получаете франкенштейна).

      Пример приводить не буду, потому что считаю, что HOC можно полностью заменять на хуки.

    2. Хуки (лучше, чем в этом докладе, не расскажу, посоветую только применять их на уровень ниже, чем универсальный компонент).
    3. Флаги — тоже старый метод, проверенный временем (лучше избегать, но иногда без них никак; главное, чтобы в компоненты не просачивалась странная инфа о контексте по типу isMenu, isDesktop, isForDyadyaVasya).

      Пример:

      function TextField({   withoutImplicitFocus,   disabled,   onFocus,   hasLowerCase,   hasAutoSelectAfterSubmit,   onChange: onChangeProp,   hasAutoSelect = true,   selector = DEFAULT_SELECTOR,   priority = 0,   dataE2e = selector || DEFAULT_SELECTOR,   dataTestId = selector || DEFAULT_SELECTOR,   superFeatureEnabled,   onKeyDown = noop,   ...textFieldProps }: Props) {  if (superFeatureEnabled) {   doMyBest(); }
    4. DI — тут можно извращаться по-разному.
    5. Любая комбинация вышеперечисленного.

Выводы

Вам может пригодиться эта концепция, если у вас есть много повторяющихся элементов (например, 100 таблиц, 1000 форм, 500 одинаковых страниц и так далее). Если у вас каждая страница уникальна и неповторима, то универсальность в принципе не про вас.

Плюсы:

  1. Если основополагающих компонентов немного — сильно уменьшаются затраты на поиск подходящих (похоже на пункт 3, но больше про когнитивную сложность).

    Если у вас 100–200 мелких компонент, скорее всего, каждый разработчик будет вынужден периодически синхронизировать собственное понимание того, как они работают. Когда у вас есть 2–5 универсальных компонент — подобную синхронизацию проводить проще. Если прикрутить сверхукодген (а он правда удобен, когда вы хотите сохранять удобную и поддерживаемую структуру проекта), то разрабатывать становится ещё проще и быстрее. А ориентироваться в таких проектах — одно удовольствие.

  2. Вместо того чтобы покрыть тысячу компонентов тестами поверхностно, можно покрыть один, зато очень хорошо.

    Тут всё зависит от контекста. Лучше, конечно, всё покрыть тестами, но, с точки зрения Lean, необходимым и достаточным будет хорошо покрыть один компонент, которым вы пользуетесь чаще всего.

  3. Уменьшается количество точек входа в приложении (см. аналогию с человечками выше).
  4. Пользователям становится проще пользоваться вашим интерфейсом (потому что паттерны везде одинаковые, и привыкнуть к ним надо только один раз).

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

Минусы:

  1. Может страдать производительность.

    Так как универсальные компоненты чаще всего объединяют в себе достаточно много функций, они так или иначе будут проигрывать в перфе куче маленьких компонент, сделанных под определённую маленькую задачу. Тут уже вам решать: для нас разница в 5–10 мс на медленных устройства была не столь существенна.

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

    Если начинается история с %%if (project/feature) === «что-то там» — пиши пропало.Такого в универсальных компонентах точно быть не должно. Если правильно пользоваться принципами DI, описанными выше, то много проблем возникать не будет.

Дополнительно

  • Можно поставить себе eslint-плагин, который немного упростит отлов расползания графа зависимостей.
  • Используйте TS, с ним проще пользоваться API компонент, которые писали не вы (вдруг кто-то ещё этим не занялся).
  • Ограничивайте размер файлов, чтобы универсальные компоненты были скорее точкой входа или агрегацией других компонент — правило линтера.
  • Кому интересно, можете поиграться с примерами в репозитории.
  • Не забывайте про тесты, с ними проще жить.

Ссылки

Хабрастатьи:

  1. Атомарный веб-дизайн
  2. React: лучшие практики
  3. Качество года
  4. Улучшаем дизайн React приложения с помощью Compound components
  5. Cohesion и Coupling: отличия

Другие ресурсы:

  1. Создание универсальной UI-библиотеки
  2. Пост от Артура про слоты
  3. Thai Pangsakulyanont: Smells In React Apps — JSConf.Asia 2018
  4. Ant Design
  5. MUI
  6. github.com/import-js/eslint-plugin-import/blob/main/docs/rules/max-dependencies.md
  7. github.com/wemake-services/wemake-frontend-styleguide/tree/master/packages/eslint-config-typescript


ссылка на оригинал статьи https://habr.com/ru/company/yandex/blog/662826/