Анатомия React компонента

Анатомия React компонента

102

7 мин.

Анатомия React-компонента

У большинства команд есть представление о том, как должен выглядеть «правильный» компонент, но обычно оно живёт в головах и код-ревью, а не в тексте. Здесь я попробую зафиксировать одну конкретную структуру компонента целиком — от папки, в которой он лежит, до строки экспорта.

Папка и файл пропсов

Компонент занимает отдельную папку с двумя файлами:

product-card/
  product-card.tsx
  product-card.props.ts

Типы пропсов лежат отдельно от реализации, и причин на это две.

Первая — объём, составной Props из Pick, пересечений и типов запроса легко разрастается до десятков строк и в одном файле начинает заслонять сам компонент. Вторая — импорты, контракт компонента нужен не только ему самому, его тянут тесты, обёртки и пропсы родительских компонентов, и всем им достаточно лёгкого файла с типом, без файла с реализацией и его зависимостей.

Сам тип называется просто Props, имя файла уже содержит имя компонента, поэтому ProductCardProps внутри product-card.props.ts ничего бы не добавил.

import type { Props } from './product-card.props';

Минус у такого именования тоже есть, если открыть несколько файлов пропсов рядом, по одному имени типа их не различить — приходится смотреть на вкладку, а при импорте нескольких Props в один файл имена конфликтуют, выручает переименование через asimport type { Props as CardProps } from ....

Пропсы собираются, а не пишутся

Тип пропсов почти никогда не состоит из полей, придуманных с нуля, обычно он склеен из нескольких источников — нативные HTML-атрибуты и типы, сгенерированные из схемы API.

type Props = Pick<ComponentProps<'a'>, 'className'> &
  Pick<ComponentProps<'img'>, 'fetchPriority'> & {
    product: ProductQuery['products'][number];
    onSelect?: ((id: string) => void) | undefined;
  };

Pick<ComponentProps<'a'>, 'className'> вместо className?: string выглядит громоздко, но избавляет от ручного повторения типов, которые уже описаны в React, а поля, взятые из сгенерированного типа запроса, меняются вместе со схемой — если бэкенд переименует поле, компилятор покажет все места, которые это заденет.

Отдельная деталь — запись ((id: string) => void) | undefined вместо простого ?, она нужна при включённом exactOptionalPropertyTypes, флаг различает «поля нет» и «поле есть, но равно undefined», а второй случай постоянно возникает, когда пропсы прокидываются через spread. Без явного | undefined такие вызовы не компилируются, и проще один раз написать длиннее, чем расставлять условные spread'ы по всем вызовам.

И главное правило, которое важнее синтаксиса, у похожих компонентов должны быть свои узкие типы: если карточка товара, карточка статьи и карточка отзыва различаются набором данных, это три компонента с тремя Props, а не один компонент с типом, где половина полей опциональна. Тип, в котором rating?, author? и videoUrl? соседствуют без всякой связи, уже не описывает контракт — он описывает объединение всех случаев. Это, по сути, принцип разделения интерфейсов из SOLID, применённый к пропсам — компонент не должен объявлять поля, которые нужны лишь одному из его сценариев.

Порядок внутри тела

Импорты сверху и объявление компонента следом — здесь ничего специфического, интересное начинается внутри тела. У него тоже есть фиксированный порядок блоков, благодаря которому конкретный вид логики ищется в известном месте, а не чтением файла целиком.

const Dropdown = (props: Props) => {
  // 1. деструктуризация пропсов
  const { onChange, options, value } = props;

  // 2. хуки react
  const [isOpen, setIsOpen] = useState(false);

  const listRef = useRef<HTMLUListElement>(null);

  // 3. переменные
  const selected = options.find((option) => option.value === value);
  const displayValue = selected?.label ?? 'Выбрать';

  // 4. функции
  const handleSelect = (nextValue: string) => {
    onChange(nextValue);
    setIsOpen(false);
  };

  // 5. useEffect и useLayoutEffect — последними, перед return
  useEffect(() => {
    if (!isOpen) return;

    const handleClickOutside = () => setIsOpen(false);

    document.addEventListener('click', handleClickOutside);

    return () => document.removeEventListener('click', handleClickOutside);
  }, [isOpen]);

  return (/* ... */);
};

export { Dropdown };

Деструктуризация → хуки → переменные → функции → эффекты. Порядок не произвольный, каждый следующий блок может опираться на предыдущие, но не наоборот: переменная использует хук, функция использует переменную и хук, эффект использует вообще всё, что выше.

Обратный порядок в JS и не заработает (const нельзя использовать до объявления), а вот вперемешку — вполне, и именно вперемешку выглядит большинство нечитаемых компонентов, где, чтобы понять зависимости эффекта в середине файла, приходится скакать глазами вверх и вниз.

useEffect и useLayoutEffect стоят отдельным, последним блоком, а не рядом с остальными хуками в начале, так как эффекты обычно завязаны на то, что объявлено ниже по телу — на обработчики и вычисленные переменные, и читать эффект раньше того, на что он ссылается, неудобно.

Переменные из третьего блока — это всё, что вычисляется из пропсов или состояния и получает имя до JSX.

const isHighPriority = fetchPriority === 'high';
const isOutOfStock = product.stock === 0;

Цель в том, чтобы разметка ниже читалась как перечисление уже принятых решений, а не как место, где они принимаются, условие product.stock === 0 внутри JSX заставляет читателя разбирать логику на месте, isOutOfStock — нет.

Варианты — через объект, а не через if

Когда у компонента есть варианты внешнего вида, соблазн развесить условия по разметке, но вместо этого лучше описывать варианты таблицей — она не наращивает Cognitive Complexity, сколько бы вариантов ни добавилось.

const VARIANT_CONFIG = {
  primary: 'bg-accent text-white rounded-md',
  outline: 'border border-accent text-accent',
  ghost: 'text-black',
} satisfies Record<NonNullable<Props['variant']>, string>;

Таблица объявляется на уровне модуля, между импортами и компонентом, а не внутри его тела, внутри же компонента объект пересоздавался бы на каждом рендере, на уровне модуля он создаётся один раз при загрузке файла.

Туда же выносится всё, что не зависит от пропсов и состояния.

Дальше в JSX остаётся VARIANT_CONFIG[variant] — единственная точка, где вариант вообще упоминается.

satisfies в конце делает две вещи. Во-первых, проверяет полноту — если в тип variant добавить новое значение и забыть строку в объекте, код не соберётся. Во-вторых, в отличие от аннотации const CONFIG: Record<...> = {...}, не затирает литеральные типы значений, они остаются конкретными строками, а не абстрактным string.

Такая таблица — реализация принципа открытости/закрытости, новый вариант добавляется строкой в объект, существующая разметка не меняется. Обратный пример — компонент, где по телу разбросаны флаги вида isCompact, isFeatured, isInline, и каждый участок JSX проверяет их в разных сочетаниях, такой компонент нельзя менять локально, правка одного варианта проходит через разметку всех остальных.

Общий код выносится слотами

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

const CardCover = (props: Props) => {
  const { badge, image, overlay } = props;

  return (
    <div className='relative aspect-square'>
      {overlay}
      {image}
      {badge}
    </div>
  );
};

Один потребитель кладёт в badge пометку «нет в наличии», другой — счётчик просмотров, третий не передаёт ничего. CardCover не знает ни про склад, ни про просмотры, не растёт при появлении новых потребителей, и у него остаётся одна причина меняться — вёрстка самой обложки.

Критерий для выноса при этом жёсткий, выносится то, что совпадает буквально, а не то, что «похоже». Два куска разметки, похожие по форме, но развивающиеся по разным причинам, после объединения начинают мешать друг другу и через полгода в общем компоненте живёт клубок условий, ради избавления от которого всё и затевалось.

Сюда же относится граница сервер/клиент в Next.js, 'use client' ставится на конкретный файл, которому нужны события или состояние, в паре из презентационной обёртки и интерактивной ссылки директива стоит только на второй, первая продолжает рендериться на сервере.

Стили

Классы собираются утилитой cn (обёртка над clsx и tailwind-merge), каждое условие передаётся отдельным аргументом, а не склеивается в одну строку через шаблонные литералы.

className={cn(
  'flex flex-col rounded-md bg-white',
  isOutOfStock && 'opacity-60',
  className,
)}

Внешний className идёт последним аргументом, в таком случае tailwind-merge даёт вызывающей стороне переопределять стили компонента, а не только дописывать к ним.

Экспорт в конце

Компонент объявляется через const, экспорт — отдельной строкой в конце файла, всегда именованный.

const ProductCard = (props: Props) => {
  // ...
};

export { ProductCard };

Именованный экспорт вместо default нужен, чтобы имя компонента было одинаковым во всех местах импорта и переживало автоматические рефакторинги, а экспорт в конце, а не в объявлении, — чтобы публичная поверхность файла была видна в одном месте.

Оба соглашения мелкие, и польза каждого по отдельности невелика, смысл появляется от того, что они соблюдаются везде.

Зачем всё это

Ни один из пунктов выше не нов, заметно другое — вместе они покрывают половину SOLID: узкие пропсы вместо одного толстого типа — разделение интерфейсов, таблица вариантов — открытость/закрытость, слоты без знания о потребителях — единственная ответственность.

Только достигается это не иерархиями классов, а дисциплиной формы.

Когда каждый файл устроен одинаково — пропсы отдельно, деструктуризация в начале, эффекты перед return, экспорт в конце — в проекте на полсотни компонентов перестаёшь читать код и начинаешь его узнавать, открыл файл, глазами нашёл нужный слой, поправил.

Форма компонента становится такой же предсказуемой, как его поведение, и именно это экономит время на длинной дистанции.

Похожие статьи

  • SSF-U - Единый стандарт для фуллскрина

    Стандарт SSF-U определяет требования к семантике, доступности и логике работы фуллскрина

    33

    2 мин.

  • SSA - Единый стандарт для аккордеона

    Стандарт SSA определяет требования к семантике, доступности и логике работы аккордеона

    48

    2 мин.

  • Bad Practices для сайтов

    Разбор критических ошибок веб-дизайна. Почему слайдеры, автоплей и тяжёлые страницы снижают конверсию и позиции в Google и Yandex

    45

    1 мин.

  • SSP - Единый стандарт для пагинации

    Стандарт SSP определяет требования к семантике, доступности и логике работы пагинации

    55

    1 мин.

  • SSPS - Единый стандарт для структуры проекта

    Стандарт SSPS определяет требования к структуре и наименованию файлов и папок в проекте

    165

    2 мин.

  • SSG - Единый стандарт для Git

    Стандарт SSG определяет требования к семантике, доступности и логике работы Git

    48

    2 мин.

  • Все статьи

Свяжитесь со мной