Фронтенд-инженерия интерфейсов
Обзор
Строй пользовательские интерфейсы продакшн-качества: доступные, производительные и визуально доведённые. Цель — интерфейс, который выглядит так, будто его собрал инженер с дизайнерским чутьём в сильной компании, а не сгенерировал ИИ. Это значит: реальное следование дизайн-системе, настоящая доступность, продуманные паттерны взаимодействия и никакой обобщённой «ИИ-эстетики».
Когда применять
- Строишь новые UI-компоненты или страницы
- Меняешь существующие пользовательские интерфейсы
- Верстаешь адаптивные макеты
- Добавляешь интерактивность или управление состоянием
- Чинишь визуальные или UX-проблемы
Архитектура компонентов
Структура файлов
Держи всё, относящееся к компоненту, рядом:
src/components/
TaskList/
TaskList.tsx # Реализация компонента
TaskList.test.tsx # Тесты
TaskList.stories.tsx # Истории Storybook (если используется)
use-task-list.ts # Пользовательский хук (если состояние сложное)
types.ts # Типы, специфичные для компонента (если нужны)
Паттерны компонентов
Композиция важнее конфигурации:
// Хорошо: композируемо
<Card>
<CardHeader>
<CardTitle>Tasks</CardTitle>
</CardHeader>
<CardBody>
<TaskList tasks={tasks} />
</CardBody>
</Card>
// Избегай: переконфигурировано
<Card
title="Tasks"
headerVariant="large"
bodyPadding="md"
content={<TaskList tasks={tasks} />}
/>
Держи компоненты сфокусированными:
// Хорошо: делает одну вещь
export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
return (
<li className="flex items-center gap-3 p-3">
<Checkbox checked={task.done} => onToggle(task.id)} />
<span className={task.done ? 'line-through text-muted' : ''}>{task.title}</span>
<Button variant="ghost" size="sm" => onDelete(task.id)}>
<TrashIcon />
</Button>
</li>
);
}
Отделяй загрузку данных от отображения:
// Контейнер: работает с данными
export function TaskListContainer() {
const { tasks, isLoading, error } = useTasks();
if (isLoading) return <TaskListSkeleton />;
if (error) return <ErrorState message="Failed to load tasks" retry={refetch} />;
if (tasks.length === 0) return <EmptyState message="No tasks yet" />;
return <TaskList tasks={tasks} />;
}
// Представление: занимается отрисовкой
export function TaskList({ tasks }: { tasks: Task[] }) {
return (
<ul role="list" className="divide-y">
{tasks.map(task => <TaskItem key={task.id} task={task} />)}
</ul>
);
}
Управление состоянием
Выбирай простейший подход, который работает:
Локальное состояние (useState) → UI-состояние конкретного компонента
Поднятое состояние → Общее для 2–3 соседних компонентов
Контекст → Тема, аутентификация, локаль (много чтения, редкая запись)
Состояние в URL (searchParams) → Фильтры, пагинация, состояние UI, которым можно поделиться
Серверное состояние (React Query, SWR) → Удалённые данные с кешированием
Глобальное хранилище (Zustand, Redux) → Сложное клиентское состояние на всё приложение
Не прокидывай пропсы глубже трёх уровней. Если ты передаёшь пропсы через компоненты, которые их не используют, вводи контекст или перестраивай дерево компонентов.
Следование дизайн-системе
Избегай ИИ-эстетики
У интерфейсов, сгенерированных ИИ, есть узнаваемые признаки. Избегай их всех:
| Умолчание ИИ | В чём проблема | Продакшн-качество |
|---|---|---|
| Фиолетовый/индиго повсюду | Модели скатываются к визуально «безопасным» палитрам, и все приложения выглядят одинаково | Используй реальную палитру проекта |
| Избыток градиентов | Градиенты добавляют визуальный шум и конфликтуют с большинством дизайн-систем | Плоская заливка или мягкие градиенты в духе дизайн-системы |
| Скругление всего подряд (rounded-2xl) | Максимальное скругление сигналит «дружелюбно», но игнорирует иерархию радиусов в настоящих макетах | Согласованный border-radius из дизайн-системы |
| Обобщённые hero-блоки | Шаблонная вёрстка без связи с реальным содержимым и потребностью пользователя | Вёрстка от содержимого |
| Текст в духе lorem ipsum | Текст-заглушка прячет проблемы вёрстки, которые вскрывает реальный контент (длина, переносы, переполнение) | Реалистичный текст-заполнитель |
| Огромные отступы повсюду | Одинаково щедрые отступы уничтожают визуальную иерархию и расходуют место на экране | Согласованная шкала отступов |
| Стоковые сетки карточек | Однородные сетки — способ не думать о вёрстке, игнорирующий приоритет информации и то, как её просматривают | Вёрстка под задачу |
| Обилие теней | Слоистые тени добавляют глубину, конкурирующую с содержимым, и замедляют отрисовку на слабых устройствах | Мягкие тени или их отсутствие, если дизайн-система не требует иного |
Отступы и вёрстка
Используй согласованную шкалу отступов. Не выдумывай значения:
/* Пользуйся шкалой: шаг 0,25rem (или тем, что принято в проекте) */
/* Хорошо */ padding: 1rem; /* 16px */
/* Хорошо */ gap: 0.75rem; /* 12px */
/* Плохо */ padding: 13px; /* Не из шкалы */
/* Плохо */ margin-top: 2.3rem; /* Не из шкалы */
Типографика
Уважай иерархию заголовков:
h1 → Заголовок страницы (один на странице)
h2 → Заголовок раздела
h3 → Заголовок подраздела
body → Основной текст
small → Вторичный/вспомогательный текст
Не пропускай уровни заголовков. Не используй стили заголовков для не-заголовков.
Цвет
- Используй семантические токены цвета:
text-primary,bg-surface,border-default, а не голые hex-значения - Обеспечь достаточный контраст (4,5:1 для обычного текста, 3:1 для крупного)
- Не полагайся только на цвет для передачи информации (добавляй иконки, текст или паттерны)
Доступность (WCAG 2.1 AA)
Каждый компонент должен соответствовать этим требованиям:
Навигация с клавиатуры
// Каждый интерактивный элемент должен быть доступен с клавиатуры
<button me</button> // ✓ Фокусируется по умолчанию
<div me</div> // ✗ Не фокусируется
<div role="button" tabIndex={0} // ✓ Но лучше <button>
=> {
if (e.key === 'Enter') handleClick();
if (e.key === ' ') e.preventDefault();
}}
=> {
if (e.key === ' ') handleClick();
}}>
Click me
</div>
ARIA-подписи
// Подписывай интерактивные элементы без видимого текста
<button aria-label="Close dialog"><XIcon /></button>
// Подписывай поля форм
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// Или используй aria-label, когда видимой подписи нет
<input aria-label="Search tasks" type="search" />
Управление фокусом
// Переводи фокус при смене содержимого
function Dialog({ isOpen, onClose }: DialogProps) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) closeRef.current?.focus();
}, [isOpen]);
// Удерживай фокус внутри диалога, пока он открыт
return (
<dialog open={isOpen}>
<button ref={closeRef}
{/* содержимое диалога */}
</dialog>
);
}
Осмысленные пустые состояния и состояния ошибок
// Не показывай пустые экраны
function TaskList({ tasks }: { tasks: Task[] }) {
if (tasks.length === 0) {
return (
<div role="status" className="text-center py-12">
<TasksEmptyIcon className="mx-auto h-12 w-12 text-muted" />
<h3 className="mt-2 text-sm font-medium">No tasks</h3>
<p className="mt-1 text-sm text-muted">Get started by creating a new task.</p>
<Button className="mt-4" Task</Button>
</div>
);
}
return <ul role="list">...</ul>;
}
Адаптивная вёрстка
Проектируй сначала под мобильные, потом расширяй:
// Tailwind: адаптивность от мобильных
<div className="
grid grid-cols-1 /* Мобильные: одна колонка */
sm:grid-cols-2 /* Малые: 2 колонки */
lg:grid-cols-3 /* Большие: 3 колонки */
gap-4
">
Проверяй на этих контрольных точках: 320px, 768px, 1024px, 1440px.
Загрузка и переходы
// Скелетон загрузки (а не спиннеры для контента)
function TaskListSkeleton() {
return (
<div className="space-y-3" aria-busy="true" aria-label="Loading tasks">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
);
}
// Оптимистичные обновления ради ощущения скорости
function useToggleTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: toggleTask,
onMutate: async (taskId) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] });
const previous = queryClient.getQueryData(['tasks']);
queryClient.setQueryData(['tasks'], (old: Task[]) =>
old.map(t => t.id === taskId ? { ...t, done: !t.done } : t)
);
return { previous };
},
onError: (_err, _taskId, context) => {
queryClient.setQueryData(['tasks'], context?.previous);
},
});
}
См. также
Подробные требования к доступности и инструменты тестирования — см. ../../references/accessibility-checklist.md.
Типовые самооправдания
| Самооправдание | Как на самом деле |
|---|---|
| «Доступность — это приятное дополнение» | Во многих юрисдикциях это требование закона, а в инженерии — стандарт качества. |
| «Сделаем адаптив потом» | Прикрутить адаптивность задним числом втрое сложнее, чем заложить её сразу. |
| «Дизайн ещё не финальный, стилизацию пропущу» | Используй умолчания дизайн-системы. Нестилизованный интерфейс создаёт впечатление сломанного у тех, кто его смотрит. |
| «Это же просто прототип» | Прототипы становятся продакшном. Заложи фундамент правильно. |
| «ИИ-эстетика пока сойдёт» | Она сигналит о низком качестве. Используй реальную дизайн-систему проекта с самого начала. |
Тревожные признаки
- Компоненты длиннее 200 строк (разбивай)
- Инлайновые стили или произвольные пиксельные значения
- Отсутствуют состояния ошибки, загрузки или пустоты
- Навигация с клавиатуры не проверялась
- Цвет как единственный индикатор состояния (красный/зелёный без текста и иконок)
- Обобщённый «вид от ИИ» (фиолетовые градиенты, огромные карточки, стоковая вёрстка)
Проверка
После сборки интерфейса:
- Компонент отрисовывается без ошибок в консоли
- Все интерактивные элементы доступны с клавиатуры (пройди страницу по Tab)
- Скринридер способен передать содержание и структуру страницы
- Адаптивность: работает на 320px, 768px, 1024px, 1440px
- Состояния загрузки, ошибки и пустоты обработаны
- Соблюдена дизайн-система проекта (отступы, цвета, типографика)
- Нет предупреждений о доступности в devtools или axe-core