Случалось ли вам, выполняя какую-то задачу, понять, что самый простой путь — нажать Сtrl+C, Сtrl+V: перетащить из соседней папочки пару файлов, поменять пару строчек, и будет ок? Повторялось ли это ощущение? Я хочу рассказать о том, как боролся с этой проблемой и к чему пришёл вместе с командой. Назовём это решение «универсальные компоненты» (если у кого-то будет более удачное название для концепции, жду в коментариях). Примеры буду приводить в основном на React, но концепции будут общие.
Немного обо мне и команде. У нас не совсем обычная ситуация для Яндекса — мы существуем немного в изоляции с точки зрения пересечения с другими интерфейсами. С одной стороны, у нас есть возможность пользоваться всеми благами дизайн-систем и наработок Яндекса, с другой — мы не сталкиваемся с высокой стоимостью изменений (проще говоря, можем существовать как автономная команда). Поэтому мой опыт может быть полезен не только для ребят, которые сидят в больших корпорациях, но и для тех, кто работает в маленьких компаниях или стартапах (многие из решений, о которых расскажу ниже, были приняты в соответствии с принципами lean development).
Представьте, что каждый компонент — это человечек, с которым вам придётся пообщаться. У каждой стандартной страницы (например, такой) — примерно 20 человечков, с которыми она общается. Если идти в каждый из этих компонентов, то они общаются ещё с 5–15 человечками (скорее всего, большая часть общения пересекается, но не суть).
В целом, не страшно, когда такая история есть у одной страницы. Но когда страниц становится больше 100–200, каждая из них является отдельным компонентом. Теперь представьте, что вам нужно будет пообщаться с каждым из этих человечков, чтобы добавить какого-то из них в общую группу.
Аналогия, конечно, так себе, но суть вы поняли — уменьшая количество соединений в графе зависимостей или сводя их в одну точку (единая точка входа), вы сильно упрощаете себе жизнь/ Вы всегда знаете, что можете пообщаться с главным руководителем всех страничек. И самый внимательный читатель увидит здесь принцип high cohesion — подробнее можно почитать здесь.
Одна схема против другой
В 2020 году я пришёл в Маркет, в отдел разработки складов. На тот момент у меня было около года опыта в промышленной разработке, и меня нанимали как единственного разработчика интерфейсов. Ну вы поняли — мне дали карт-бланш.
У проекта была интересная специфика. До того как я пришёл, у работы было две фазы. Сначала несколько фронтендеров показали бэкендерам за несколько недель, как писать фронтенд. А потом бэкендеры потихоньку это дописывали. Но так как ребята заложили сложную архитектуру, бэкендерам было очень трудно ей следовать.
Короче, я бросился упрощать архитектуру, пока не стало слишком поздно. Весь флоу пользователя в основном состоял из однотипных экранов с одним полем ввода, и чтобы не дублировать функциональность, я выделил для себя один компонент, который очень помог мне в дальнейшем — InputPage. Да, это просто страница с полем ввода и двумя кнопками. Но потом там появилась обработка loading-состояния, возможность добавить шапку, чтобы всё скроллилось только под ней, добавить что-то до и после инпута, досыпать кнопок. Но основная функциональность осталась той же — поле ввода и две кнопки.
Это сразу решило проблему двойных сканов (ввод и кнопка далее блокировались во время pending-состояния). Так же мы решили проблемы с неконсистентностью отступов, расположением инпута на экране (были экраны с инпутом сверху и посередине) и многие другие мелкие неконсистентности.
Впоследствии это помогло сразу для всех экранов использовать специальный тип инпута с жёстким фокусом (когда экран гас, слетал фокус) с переводом в верхний регистр и выделением текста при фокусе или ошибках (чтобы было проще сканировать).
На данный момент примерно 80% проекта для кладовщиков сделано с помощью этого компонента.
И да, теперь, когда мне нужно добавить какую-то фичу для определённого типа экранов — мне не придётся перелопачивать сотни файлов, достаточно поменять пару мест. И пользователи довольны, и программисты целы.
Я считаю подход «Просто рефакторили и сделали хорошо» наиболее универсальным (остальное зависит от контекста разработки). И вот почему:
Я сконцентрируюсь на третьем пункте, потому что считаю его наиболее важным и реальным способом собирать универсальные компоненты. Примеры будут немного синтетические, прошу принять и простить.
Итак, на какие вопросы важно ответить:
Вам может пригодиться эта концепция, если у вас есть много повторяющихся элементов (например, 100 таблиц, 1000 форм, 500 одинаковых страниц и так далее). Если у вас каждая страница уникальна и неповторима, то универсальность в принципе не про вас.
Плюсы:
Минусы:
Хабрастатьи:
Другие ресурсы:
Немного обо мне и команде. У нас не совсем обычная ситуация для Яндекса — мы существуем немного в изоляции с точки зрения пересечения с другими интерфейсами. С одной стороны, у нас есть возможность пользоваться всеми благами дизайн-систем и наработок Яндекса, с другой — мы не сталкиваемся с высокой стоимостью изменений (проще говоря, можем существовать как автономная команда). Поэтому мой опыт может быть полезен не только для ребят, которые сидят в больших корпорациях, но и для тех, кто работает в маленьких компаниях или стартапах (многие из решений, о которых расскажу ниже, были приняты в соответствии с принципами lean development).
Аналогия
Представьте, что каждый компонент — это человечек, с которым вам придётся пообщаться. У каждой стандартной страницы (например, такой) — примерно 20 человечков, с которыми она общается. Если идти в каждый из этих компонентов, то они общаются ещё с 5–15 человечками (скорее всего, большая часть общения пересекается, но не суть).
В целом, не страшно, когда такая история есть у одной страницы. Но когда страниц становится больше 100–200, каждая из них является отдельным компонентом. Теперь представьте, что вам нужно будет пообщаться с каждым из этих человечков, чтобы добавить какого-то из них в общую группу.
Аналогия, конечно, так себе, но суть вы поняли — уменьшая количество соединений в графе зависимостей или сводя их в одну точку (единая точка входа), вы сильно упрощаете себе жизнь/ Вы всегда знаете, что можете пообщаться с главным руководителем всех страничек. И самый внимательный читатель увидит здесь принцип high cohesion — подробнее можно почитать здесь.
Одна схема против другой
История
В 2020 году я пришёл в Маркет, в отдел разработки складов. На тот момент у меня было около года опыта в промышленной разработке, и меня нанимали как единственного разработчика интерфейсов. Ну вы поняли — мне дали карт-бланш.
У проекта была интересная специфика. До того как я пришёл, у работы было две фазы. Сначала несколько фронтендеров показали бэкендерам за несколько недель, как писать фронтенд. А потом бэкендеры потихоньку это дописывали. Но так как ребята заложили сложную архитектуру, бэкендерам было очень трудно ей следовать.
Короче, я бросился упрощать архитектуру, пока не стало слишком поздно. Весь флоу пользователя в основном состоял из однотипных экранов с одним полем ввода, и чтобы не дублировать функциональность, я выделил для себя один компонент, который очень помог мне в дальнейшем — InputPage. Да, это просто страница с полем ввода и двумя кнопками. Но потом там появилась обработка loading-состояния, возможность добавить шапку, чтобы всё скроллилось только под ней, добавить что-то до и после инпута, досыпать кнопок. Но основная функциональность осталась той же — поле ввода и две кнопки.
Это сразу решило проблему двойных сканов (ввод и кнопка далее блокировались во время pending-состояния). Так же мы решили проблемы с неконсистентностью отступов, расположением инпута на экране (были экраны с инпутом сверху и посередине) и многие другие мелкие неконсистентности.
Впоследствии это помогло сразу для всех экранов использовать специальный тип инпута с жёстким фокусом (когда экран гас, слетал фокус) с переводом в верхний регистр и выделением текста при фокусе или ошибках (чтобы было проще сканировать).
На данный момент примерно 80% проекта для кладовщиков сделано с помощью этого компонента.
И да, теперь, когда мне нужно добавить какую-то фичу для определённого типа экранов — мне не придётся перелопачивать сотни файлов, достаточно поменять пару мест. И пользователи довольны, и программисты целы.
Выводы из истории
- Всегда думай, прежде чем делать.
- Всегда думай при нажатии Сtrl+С, не пришло ли время создать новый компонент или найти существующий.
- Если делаешь новый компонент, подумай, точно ли нет уже существующих компонентов, в которые можно это добавить.
- 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) {
- Можно ли переопределять поведение компонента?
Над этим вопросом стоит внимательно подумать. Допустим, есть проекты, в которых тему и её цвета никак нельзя менять (и это считается правильным и зашивается в 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);
- Что выбрать: контекст или пропсы?
Если у вас есть только один вариант использования компонента на данный момент — смело делайте с помощью пропсов. Если же у вас потребности формата «Вот в этой части приложения должно быть так, а в этой — вот так», то контекст — ваш выбор. - Как сделать другой дефолтный дефолт?
В случае пропсов это будет компонент-обёртка, в случае контекста — другое дефолтное значение в контексте. - Какие есть способы добавлять компоненту поведение, когда он уже существует в продакшене?
- Композиция (приём древний, всем известный: наворачиваете HOC, приправляете compose-функцией, получаете франкенштейна).
Пример приводить не буду, потому что считаю, что HOC можно полностью заменять на хуки. - Хуки (лучше, чем в этом докладе, не расскажу, посоветую только применять их на уровень ниже, чем универсальный компонент).
- Флаги — тоже старый метод, проверенный временем (лучше избегать, но иногда без них никак; главное, чтобы в компоненты не просачивалась странная инфа о контексте по типу
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(); }
- DI — тут можно извращаться по-разному.
- Любая комбинация вышеперечисленного.
- Композиция (приём древний, всем известный: наворачиваете HOC, приправляете compose-функцией, получаете франкенштейна).
Выводы
Вам может пригодиться эта концепция, если у вас есть много повторяющихся элементов (например, 100 таблиц, 1000 форм, 500 одинаковых страниц и так далее). Если у вас каждая страница уникальна и неповторима, то универсальность в принципе не про вас.
Плюсы:
- Если основополагающих компонентов немного — сильно уменьшаются затраты на поиск подходящих (похоже на пункт 3, но больше про когнитивную сложность).
Если у вас 100–200 мелких компонент, скорее всего, каждый разработчик будет вынужден периодически синхронизировать собственное понимание того, как они работают. Когда у вас есть 2–5 универсальных компонент — подобную синхронизацию проводить проще. Если прикрутить сверхукодген (а он правда удобен, когда вы хотите сохранять удобную и поддерживаемую структуру проекта), то разрабатывать становится ещё проще и быстрее. А ориентироваться в таких проектах — одно удовольствие. - Вместо того чтобы покрыть тысячу компонентов тестами поверхностно, можно покрыть один, зато очень хорошо.
Тут всё зависит от контекста. Лучше, конечно, всё покрыть тестами, но, с точки зрения Lean, необходимым и достаточным будет хорошо покрыть один компонент, которым вы пользуетесь чаще всего. - Уменьшается количество точек входа в приложении (см. аналогию с человечками выше).
- Пользователям становится проще пользоваться вашим интерфейсом (потому что паттерны везде одинаковые, и привыкнуть к ним надо только один раз).
UX и правда улучшается, если сохраняется высокая консистентность, а поддержку высокой консистентности с помощью одного компонента я считаю самым простым способом.
Минусы:
- Может страдать производительность.
Так как универсальные компоненты чаще всего объединяют в себе достаточно много функций, они так или иначе будут проигрывать в перфе куче маленьких компонент, сделанных под определённую маленькую задачу. Тут уже вам решать: для нас разница в 5–10 мс на медленных устройства была не столь существенна. - Проект можно привести к нерасширяемому виду, если неправильно готовить.
Если начинается история с %%if (project/feature) === «что-то там» — пиши пропало.Такого в универсальных компонентах точно быть не должно. Если правильно пользоваться принципами DI, описанными выше, то много проблем возникать не будет.
Дополнительно
- Можно поставить себе eslint-плагин, который немного упростит отлов расползания графа зависимостей.
- Используйте TS, с ним проще пользоваться API компонент, которые писали не вы (вдруг кто-то ещё этим не занялся).
- Ограничивайте размер файлов, чтобы универсальные компоненты были скорее точкой входа или агрегацией других компонент — правило линтера.
- Кому интересно, можете поиграться с примерами в репозитории.
- Не забывайте про тесты, с ними проще жить.
Ссылки
Хабрастатьи:
- Атомарный веб-дизайн
- React: лучшие практики
- Качество года
- Улучшаем дизайн React приложения с помощью Compound components
- Cohesion и Coupling: отличия
Другие ресурсы:
- Создание универсальной UI-библиотеки
- Пост от Артура про слоты
- Thai Pangsakulyanont: Smells In React Apps — JSConf.Asia 2018
- Ant Design
- MUI
- github.com/import-js/eslint-plugin-import/blob/main/docs/rules/max-dependencies.md
- github.com/wemake-services/wemake-frontend-styleguide/tree/master/packages/eslint-config-typescript