Пишем хорошие компоненты, которые захочется переиспользовать, а плохие — не пишем
Привет! Меня зовут Антон Крылов, я фронтенд-разработчик в Авито — занимаюсь профилями. До Авито я работал тимлидом и видел много хорошего и плохого кода. В этой статье — мои наблюдения и размышления на тему, как сделать компонент, который приятно использовать и который не придётся переписывать через время.
Прежде чем говорить о том, как написать хороший компонент, давайте поговорим о том, как делать точно не нужно.
Плохой компонент — сложный
Компонент можно назвать сложным, если:
У него нет тестов. Такой компонент без будет тяжело использовать извне, а расширять — ещё сложнее.
Он делает слишком много. Я встречал компоненты на 1000–2000 строк, которые умеют всё. Но чтобы что-то поправить в таком компоненте, может понадобиться целый спринт.
Он Overengineered. Разработчики часто грешат добавлением искусственных сложностей в код. Например:
useEffect вместо useCallback. Я периодически встречаю такой кейс, когда в одном месте обрабатываются действия после изменения state, которое происходит где-то глубоко в компонентах фильтра. Почитайте, почему это не лучшее решение.
RxJS (подставьте сюда любую технологию с которой сложно разобраться) везде. Это классная штука, но иногда она не нужна. Например, для сингл-кейса лучше написать императивный код.
Лишние слои абстракции. Иногда их плодят, чтобы весь код переиспользовался как можно больше, а в итоге потребности в переиспользовании нет.
Чтобы понять, как работает такой искусственно усложнённый компонент, придётся прочитать весь код. Это долго и неэффективно.
Плохой компонент — неконсистентный
Это компонент, чьё состояние или пропсы не соответствуют ожидаемому поведению.
Неконсистентность возникает, если компонент изменяет свое состояние в ответ на действия пользователя, но не обновляет соответствующие данные в других компонентах, которые зависят от него. Это может привести к непредсказуемому поведению или сбоям.
Проблему неконсистетности можно решить на этапе дизайна API для компонента. Допустим, мы написали компонент TextField, а потом захотели расширить его валидацией. Можно сделать так:
export interface TextFieldProps extends
Omit,
'onChange'> {
...
errorText?: string;
validation?: (value: string) => boolean;
...
}
Но такое решение — неконсистентное. Если по ошибке передать в TextField только один из пропов, ничего работать не будет.
Ещё вариант — добавить валидацию пропов, но и это не лучшее решение. В таком варианте только один вариант для ошибки — добавить новые не получится:
export interface TextFieldProps extends
Omit,
'onChange'> {
...
validationProps?: {
errorText: string;
validation: (value: string) => boolean;
},
...
}
Лучшее решение для консистентности — пользоваться правилом «single prop — single logic» — на каждый проп должна быть своя логика:
export interface TextFieldProps extends
Omit,
'onChange'> {
...
validation: (value: string) => string;
...
}
Другие признаки плохого компонента
Есть ещё два признака компонентов, которые сложно использовать и тем более расширять: использование отображения на флагах и чересчур большое количество пропсов — Props Hell.
Отображение на флагах. Проблема в том, что рано или поздно количество флагов разрастётся. И тогда будет тяжело разобраться, что происходит в коде.
return isCreateForm ? :
Если без флагов никак не обойтись — делайте маппинг.
const forms = {
createForm: CreateForm,
editForm: EditForm
};
interface Props {
formName: keyof typeof forms;
}
const Form = ({ formName }: Props) => {
const Component = forms[formName];
return
}
Props Hell — это ситуация, в которой компонент принимает большое количество пропсов, которые должны передаваться от родительского компонента.
Props Hell обычно приводит к низкой поддерживаемости и сложности в изменении и тестировании компонента, потому что разбираться в таком тяжело и долго:
function 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)
Чтобы избежать Props Hell, можно разбить компонент на более мелкие или использовать контекст.
Вообще, прежде чем писать компонент, нужно понять, зачем мы его делаем. Разработчики сами должны решать, что хорошо, а что плохо. Это напрямую зависит от того, насколько решение попадает в текущие требования и изменяется под будущие. Для этого стоит разобраться, какие требования к компонентам существуют, и на что нужно ориентироваться при написании кода.
Функциональные требования хорошего компонента
Функциональные требования к компонентам говорят о том, что они должны уметь делать и как должны выглядеть.
Функциональные требования обычно формулирует бизнес и дизайнеры. Первым нужно, чтобы изменения приносили больше денег, а вторым — чтобы это было красиво и удобно.
Есть ещё требования от разработки — чтобы компоненты были простые и понятные. Но на самих себя, к сожалению, разработчики часто забивают и концентрируются на бизнес-требованиях. Так делать не стоит.
Требования от бизнеса, дизайнеров и разработчиков. Последние тоже важны, не нужно на них забивать
Нефункциональные требования хорошего компонента
Нефункциональные требования касаются как раз разработчиков. Они о том, как сделать компонент простым, понятным и лёгким для переиспользования.
Вот какие нефункциональные характеристики есть у компонентов:
Гибкость — насколько компонент способен адаптироваться к различным сценариям использования, не нарушая своей функциональности. Гибкий компонент можно переиспользовать в разных контекстах и с разными данными без большого количества изменений в коде. Например, компонент TextField из примера выше — негибкий: слишком большое количество пропсов усложняют модификацию.
Коробочность — насколько просто пользоваться компонентом извне. Допустим, у нас есть компонент Table, который принимает только URL. Передавать данные таблицы через URL неудобно — это плохое решение для внешнего API. Но это может быть и хорошим решением — например, для CRUD-таблицы, где все данные ± однотипны.
interface Props {
entryUrl: string;
}
const Table = ({ entryUrl }: Props) => {
const { rows, headers } = useDataFromUrl(entryUrl);
…
}
Зависимость — насколько компонент зависим от других компонентов. Если у компонента много зависимостей, он менее гибкий и более хрупкий.
Пример компонента с множеством зависимостей. Так — плохо
Явность — насколько глубоко нужно знать бизнес-логику или быть погружённым в проект, чтобы работать с компонентом. Допустим, у нас есть компонент PrimaryButton. Для того, чтобы понять, что происходит, нужны знания о дизайн-системе: например, что из себя представляет variant='primary'
const PrimaryButton = ({ children }: Props) => {
return (
)
}
Если хочется сделать код более явным, лучше избегать таких ситуаций. Например, вместо variant='primary'
можно использовать более очевидный вариант — прокинуть код цвета:
const PrimaryButton = ({ children }: Props) => {
return (
)
}
Функциональность — сколько возможностей предоставляет компонент. Например, у нас есть компонент BasicForm, который и отрисовывает форму, и умеет отправлять данные по нажатию Submit.
function BasicForm() {
const form = useForm({
onSubmit: (values) => {
alert(JSON.stringify(values, null, 2));
console.log('values', values);
},
children: [
{
label: 'First Name',
name: 'firstName',
component: 'Input',
value: '',
},
{
component: 'Submit',
text: 'submit',
},
],
});
return ;
}
Хороший компонент — соответствует запросам
В каждой ситуации важность нефункциональных требований разная, и зависит от конкретных бизнес-требований. Поэтому хороший компонент — это компонент с набором характеристик, который соответствуют конкретным запросам и сценариям использования.
Допустим, у нас есть UI-кит. Рассмотрим, как характеристики будут меняться в зависимости от ситуации.
Ситуация 1: UI-кит используется во многих проектах. Так обычно и бывает: чаще всего наборы компонентов используются в большом количестве проектов.
Тогда возникают такие нефункциональные требования:
Переопределение стилей должно быть возможным только на уровне темы — для единообразия и согласованности дизайна.
В наборе должно быть как можно меньше зависимостей — чтобы UI-кит меньше весил.
API должно быть максимально простым и явным — чтобы им легко мог пользоваться даже стажёр, который пока мало знает про бизнес-требования.
Важность нефункциональных требования для UI-кита, который будет использоваться во многих проектах на лепестковой диаграмме
Ситуация 2: UI-кит используется в ограниченном количестве проектов. Допустим, мы точно знаем, что набор не будет использоваться в множестве проектов.
Тогда нефункциональные характеристики поменяются:
Можно позволить более высокую гибкость, поскольку в небольшом количестве проектов проще отслеживать согласованность интерфейсов.
Функциональности должно быть больше, чтобы на уровне конкретного проекта приходилось делать как можно меньше.
Явность API не так важна — важнее функциональность.
Важность нефункциональных требований для UI-кита, который будет использоваться в небольшом количестве проектов на лепестковой диаграмме
Как написать хороший компонент
У хорошего компонента характеристики соответствуют сценарию его использования. Давайте разберёмся, как влиять на нефункциональные требования, чтобы «подгонять» их под запросы для каждого конкретного компонента.
Писать тесты в любом случае. Они влияют на все характеристики, хотя и не напрямую. Например, в процессе написания тестов придётся учитывать зависимости — то есть лишний раз задуматься о них.
Использовать Dependency Injection (DI), если нужны гибкость и независимости. Dependency Injection позволяет вынести создание объектов и управление их зависимостями за пределы класса — это делает код более гибким и расширяемым.
Кроме того, DI позволяет создавать более тестируемый код, так как мы можем легко заменить реальные зависимости на заглушки в тестах.
Вернёмся к примеру с компонентом TextField. В нём много потенциальных мест для использования Dependency Injection. Например, вместо пропса handleEnter можно прокинуть функцию, а вместо useSuperFocus — хук.
function 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)
Dependency Injection в React осуществляет функция inject. Подробнее об этом можно почитать в GitHub-репозитории react-ioc.
Также могут пригодиться:
Применять DSL-like подход, если нужны коробочность и явность. Он поможет уменьшить количество кода, необходимого для создания пользовательского интерфейса, и сделать разработку более доступной и понятной для новичков.
Например, можно оставить общее поведение формы BasicForm (из примера выше) в хуках:
import { useForm } from "react-hook-form";
export default function App() {
const { register, handleSubmit } = useForm({ shouldUseNativeValidation: true });
const onSubmit = async (data) => {
console.log(data);
};
return (
);
}
В такой ситуации можно легко сделать хоть 1000 таких форм — каждый раз переписывать их не придётся.
TL; DR
1. Плохой компонент — сложный и неконсистентный.
2. У компонентов есть характеристики — нефункциональные требования:
3. У хорошего компонента характеристики соответствуют запросам и требованиям.
4. На характеристики можно и нужно влиять, чтобы «подгонять» их под запросы и требования. Для этого можно использовать:
тесты;
Dependency Injection;
DSL-like подход.
Полезные ресурсы
Моя статья на русском: Гайд по написанию и рефакторингу компонентов, которые хочется переиспользовать →
Статьи на английском:
GitHub-репозитории:
Предыдущая статья: Разработка — всё? Действительно ли нас всех заменят роботы?