Случалось ли вам, выполняя какую-то задачу, понять, что самый простой путь — нажать С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

Комментарии (6)


  1. Luchnik22
    26.04.2022 11:25
    +1

    Спасибо за статью, отличным примером проблемы служит компонент календаря в ui-kit'ах - иногда он включает в себя более 30 разных свойств, которые так или иначе меняют его поведение. Добавление ещё одного свойства может вызывать боль, потому что легко поломать поведение других свойств.

    Одно из классных решений, которые я видел - использовать композицию. В том же самом mui, она как раз применяется или более явный пример можно увидеть при использовании фреймворка для редактирования lexical. Если вдруг для вашего проекта нужно специфичное поведение - вы всегда можете его реализовать из базовых блоков. Но у такого подхода тоже есть минус - при обновлении дизайн системы, может понадобится обновить компонент, который в проекте.

    Про Cohesion очень хорошо рассказывают в ролике на JS Conf 2018 года


    1. roaming-light Автор
      27.04.2022 02:24

      Привет, спасибо за классное дополнение к статье!

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

      Ещё я вдохновлялся докладом про трейдоффы при написании фреймворка от создателя Vue Evan You on Vue.js: Seeking the Balance in Framework Design | JSConf.Asia 2019. Написание компонент тоже часто связано с нахождением подобного баланса


  1. XaBoK
    27.04.2022 01:58

    Поздравляю, вы открыли СОЛИД.


    1. roaming-light Автор
      27.04.2022 02:28

      Скорее DRY :)


      1. XaBoK
        27.04.2022 02:46
        +1

        Драй слишком примитивен. Вы же даёте обоснование и принципы рефакторинга отдаленно цитируя солид.

        • Что компонент делает, какая у него функциональность? - Interface segregation

        • Не слишком ли много он знает? - SIngle responsibility

        • Какие есть способы добавлять компоненту поведение, когда он уже существует в продакшене . - open-closed principle

        Ну и так далее


        1. Alexandroppolus
          27.04.2022 18:17
          +1

          Что компонент делает, какая у него функциональность? - Interface segregation

          это тоже Single responsibility. ISP - немного другое.