Привет! Меня зовут Антон Крылов, я фронтенд-разработчик в Авито — занимаюсь профилями. До Авито я работал тимлидом и видел много хорошего и плохого кода. В этой статье — мои наблюдения и размышления на тему, как сделать компонент, который приятно использовать и который не придётся переписывать через время.

Прежде чем говорить о том, как написать хороший компонент, давайте поговорим о том, как делать точно не нужно.

Плохой компонент — сложный

Компонент можно назвать сложным, если:

  1. У него нет тестов. Такой компонент без будет тяжело использовать извне, а расширять — ещё сложнее. 

  2. Он делает слишком много. Я встречал компоненты на 1000–2000 строк, которые умеют всё. Но чтобы что-то поправить в таком компоненте, может понадобиться целый спринт.

  3. Он Overengineered. Разработчики часто грешат добавлением искусственных сложностей в код. Например: 

  • useEffect вместо useCallback. Я периодически встречаю такой кейс, когда в одном месте обрабатываются действия после изменения state, которое происходит где-то глубоко в компонентах фильтра. Почитайте, почему это не лучшее решение.

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

  • Лишние слои абстракции. Иногда их плодят, чтобы весь код переиспользовался как можно больше, а в итоге потребности в переиспользовании нет. 

Чтобы понять, как работает такой искусственно усложнённый компонент, придётся прочитать весь код. Это долго и неэффективно.

Плохой компонент — неконсистентный

Это компонент, чьё состояние или пропсы не соответствуют ожидаемому поведению. 

Неконсистентность возникает, если компонент изменяет свое состояние в ответ на действия пользователя, но не обновляет соответствующие данные в других компонентах, которые зависят от него. Это может привести к непредсказуемому поведению или сбоям.

Проблему неконсистетности можно решить на этапе дизайна API для компонента. Допустим, мы написали компонент TextField, а потом захотели расширить его валидацией. Можно сделать так:

export interface TextFieldProps extends
Omit<React.HTMLProps<HTMLInputElement>,
'onChange'> {
	...
	errorText?: string;
	validation?: (value: string) => boolean;
	...
}

Но такое решение — неконсистентное. Если по ошибке передать в TextField только один из пропов, ничего работать не будет.

Ещё вариант — добавить валидацию пропов, но и это не лучшее решение. В таком варианте только один вариант для ошибки — добавить новые не получится:

export interface TextFieldProps extends
Omit<React.HTMLProps<HTMLInputElement>,
'onChange'> {
	...
	validationProps?: {
	errorText: string;
	validation: (value: string) => boolean;
	},
	...
}

Лучшее решение для консистентности — пользоваться правилом «single prop — single logic» — на каждый проп должна быть своя логика:

export interface TextFieldProps extends
Omit<React.HTMLProps<HTMLInputElement>,
'onChange'> {
	...
	validation: (value: string) => string;
	...
}

Другие признаки плохого компонента

Есть ещё два признака компонентов, которые сложно использовать и тем более расширять: использование отображения на флагах и чересчур большое количество пропсов — Props Hell.

Отображение на флагах. Проблема в том, что рано или поздно количество флагов разрастётся. И тогда будет тяжело разобраться, что происходит в коде.

return isCreateForm ? <CreateForm /> : <EditForm />

Если без флагов никак не обойтись — делайте маппинг.

const forms = {
	createForm: CreateForm,
	editForm: EditForm
};

interface Props {
	formName: keyof typeof forms;
}

const Form = ({ formName }: Props) => {
	const Component = forms[formName];

	return <Component />
}

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 (
		<Button variant='primary'>{children}</Button>
	)
}

Если хочется сделать код более явным, лучше избегать таких ситуаций. Например, вместо variant='primary' можно использовать более очевидный вариант — прокинуть код цвета:

const PrimaryButton = ({ children }: Props) => {
	return (
		<Button color='#f00'>{children}</Button>
	)
}

Функциональность — сколько возможностей предоставляет компонент. Например, у нас есть компонент 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 <Form form={form} />;
}

Хороший компонент — соответствует запросам

В каждой ситуации важность нефункциональных требований разная, и зависит от конкретных бизнес-требований. Поэтому хороший компонент — это компонент с набором характеристик, который соответствуют конкретным запросам и сценариям использования. 

Допустим, у нас есть UI-кит. Рассмотрим, как характеристики будут меняться в зависимости от ситуации.

Ситуация 1: UI-кит используется во многих проектах. Так обычно и бывает: чаще всего наборы компонентов используются в большом количестве проектов. 

Тогда возникают такие нефункциональные требования:

  • Переопределение стилей должно быть возможным только на уровне темы — для единообразия и согласованности дизайна.

  • В наборе должно быть как можно меньше зависимостей — чтобы UI-кит меньше весил.

  • API должно быть максимально простым и явным — чтобы им легко мог пользоваться даже стажёр, который пока мало знает про бизнес-требования.

    Важность нефункциональных требования для UI-кита, который будет использоваться во многих проектах на лепестковой диаграмме
    Важность нефункциональных требования для UI-кита, который будет использоваться во многих проектах на лепестковой диаграмме

Ситуация 2: UI-кит используется в ограниченном количестве проектов. Допустим, мы точно знаем, что набор не будет использоваться в множестве проектов.

Тогда нефункциональные характеристики поменяются:

  • Можно позволить более высокую гибкость, поскольку в небольшом количестве проектов проще отслеживать согласованность интерфейсов. 

  • Функциональности должно быть больше, чтобы на уровне конкретного проекта приходилось делать как можно меньше.

  • Явность API не так важна — важнее функциональность. 

Важность нефункциональных требований для UI-кита, который будет использоваться в небольшом количестве проектов на лепестковой диаграмме
Важность нефункциональных требований для 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 (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("firstName", { required: "Please enter your first name." })} />
      <input type="submit" />
    </form>
  );
}

В такой ситуации можно легко сделать хоть 1000 таких форм — каждый раз переписывать их не придётся.

TL;DR

1. Плохой компонент — сложный и неконсистентный.

2. У компонентов есть характеристики — нефункциональные требования:

  • гибкость;

  • коробочность;

  • зависимость;

  • явность;

  • функциональность.

3. У хорошего компонента характеристики соответствуют запросам и требованиям.

4. На характеристики можно и нужно влиять, чтобы «подгонять» их под запросы и требования. Для этого можно использовать:

  • тесты;

  • Dependency Injection;

  • DSL-like подход.

Полезные ресурсы

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

Статьи на английском:

GitHub-репозитории:

Предыдущая статья: Разработка — всё? Действительно ли нас всех заменят роботы?

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


  1. demiansly
    07.07.2023 05:20
    +3

    Дружище -- твой рисунок с подписью " Пример компонента с множеством зависимостей. Так — плохо " это отражение сути ООП и его базовой идеи на которой пляшет React, может ты перечитаешь ещё раз то? ради чего пришёл в отрасль, и перестанешь изгонять главную идею от которой всё идёт -- а подчинишь её себе и начнёшь использовать? Либо уж откажись, выкини ООП и сделай "классно" по-другому ( это возможно, только надо идеи понимать, а не убирать потому что "слооожнААА" )
    Давно ли классные идеи, отличные идеи, идеи, просто, для аплодисментов появлялись корнем из бизнеса? Чёт ни одного примера не помню за 30 лет.

    Очень надеялся увидеть исходя из заголовка: "Пишем хорошие компоненты, которые захочется переиспользовать, а плохие — не пишем"
    Увидеть главное -- ПРИМЕРЫ, как плохо и главное ПОЧЕМУ плохо с ПРАКТИЧЕСКИМ обоснованием, и как ХОРОШО -- с той же базой обоснования. По факту зря потратил время на чтение.


  1. Harmary
    07.07.2023 05:20

    Очень полезно! Пошла менять флаги на маппинг и использовать Dependency Injection :)


  1. Dominux
    07.07.2023 05:20

    Компонент сложный если у него нет тестов

    Что, простите?! Как эти понятия вообще связаны?


    1. SteamEngine
      07.07.2023 05:20

      Скорее, связь обратная. Чем сложнее компонент, тем сложнее тестировать. Чем сложнее тестировать, тем выше соблазн забить на тесты.