Пишем хорошие компоненты, которые захочется переиспользовать, а плохие — не пишем

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

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

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

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

  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.

Также могут пригодиться:

  • Расширение vscode-dependency-cruiser для VS Code — для генерации графа зависимостей из любого JS-файла

  • Библиотека inversify-react — для внедрения зависимостей в компоненты в React-приложениях

Применять 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 подход.

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

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

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

  • You Might Not Need An Effect →

  • Don't useEffect as callback →

  • 5 Common mistakes to avoid when using react Hooks → 

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

  • Использование функции inject в React →

  • Расширение vscode-dependency-cruiser для VS Code — для генерации графа зависимостей из любого JS-файла →

  • Библиотека inversify-react — для внедрения зависимостей в компоненты в React-приложениях →

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

Источник: https://habr.com/ru/companies/avito/articles/739330/


Интересные статьи

Интересные статьи

Развитие цифровых технологий в той или иной степени касается всех отраслей бизнеса. Строительная не исключение. Пережив кризис, с конца 2000-го года сектор начал активно развиваться п...
Привет! На связи Алексей Парфентьев, руководитель отдела аналитики «СёрчИнформ». В середине прошлого года здесь на Habr мы делились тем, что такое наша система файлового ...
Способ создания переиспользуемых Linq фильтров (построителе предикатов для условия Where), которые можно применять для разных типов объектов. Поля объектов для фильтрации указываются с по...
Майкл Сибель — сооснователь (в 25 лет) стартапов Justin.tv/Twitch (капитализация $15 млрд) и Socialcam, член правления Reddit. Интервьюер: В самом начале, многие стартапы принимают...
В этой статье рассматривается создание достаточного простого автотеста. Статья будет полезна начинающим автоматизаторам. Материал изложен максимально доступно, однако, будет значител...