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