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

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

Аналогия


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

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

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

tiycpjyof9n0im9rpj_7p8hkagi.pngo1wdw-gs2gl1w-kdnae4ek7dmnw.png
Одна схема против другой

История


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

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

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

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

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

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

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

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


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


Сам гайд


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

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


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

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

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

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

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

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

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

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

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

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

    export interface Props extends Omit, 'onChange'> {
       withoutImplicitFocus?: boolean;
       hasAutoSelect?: boolean;
       hasLowerCase?: boolean;
       hasAutoSelectAfterSubmit?: boolean;
       selector?: string | null;
       priority?: number;
       onKeyDown?: (event: KeyboardEvent) => 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, 'onChange'> {
       selector: string | null;
       priority: number;
       onKeyDown?: (event: KeyboardEvent) => 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, 'onChange'> {
       withoutImplicitFocus?: boolean;
       hasAutoSelect?: boolean;
       hasLowerCase?: boolean;
       hasAutoSelectAfterSubmit?: boolean;
       selector?: string | null;
       priority?: number;
       onKeyDown?: (event: KeyboardEvent) => 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, 'onChange'> {
       withoutImplicitFocus?: boolean;
       hasAutoSelect?: boolean;
       hasLowerCase?: boolean;
       hasAutoSelectAfterSubmit?: boolean;
       selector?: string | null;
       priority?: number;
       onKeyDown?: (event: KeyboardEvent) => 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();
      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

© Habrahabr.ru