SSF-U - Единый стандарт для фуллскрина
Стандарт SSF-U определяет требования к семантике, доступности и логике работы фуллскрина
33
2 мин.
102
7 мин.
У большинства команд есть представление о том, как должен выглядеть «правильный» компонент, но обычно оно живёт в головах и код-ревью, а не в тексте. Здесь я попробую зафиксировать одну конкретную структуру компонента целиком — от папки, в которой он лежит, до строки экспорта.
Компонент занимает отдельную папку с двумя файлами:
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в один файл имена конфликтуют, выручает переименование черезas—import 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 — нет.
Когда у компонента есть варианты внешнего вида, соблазн развесить условия по разметке, но вместо этого лучше описывать варианты таблицей — она не наращивает 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 определяет требования к семантике, доступности и логике работы фуллскрина
33
2 мин.
Стандарт SSA определяет требования к семантике, доступности и логике работы аккордеона
48
2 мин.
Разбор критических ошибок веб-дизайна. Почему слайдеры, автоплей и тяжёлые страницы снижают конверсию и позиции в Google и Yandex
45
1 мин.
Стандарт SSP определяет требования к семантике, доступности и логике работы пагинации
55
1 мин.
Стандарт SSPS определяет требования к структуре и наименованию файлов и папок в проекте
165
2 мин.
Стандарт SSG определяет требования к семантике, доступности и логике работы Git
48
2 мин.