React Frontend Developer Interview for 2–5 Years | Real Questions + Answers | Mock Interview 2025🔥
Сегодня мы разберём техническое собеседование на позицию фронтенд-разработчика, в ходе которого кандидат демонстрирует уверенное владение ключевыми концепциями React, JavaScript, HTML и CSS — от семантики и доступности до оптимизации производительности и работы с хуками. Интервью охватывает как теоретические вопросы (например, различия между useEffect и useLayoutEffect, серверные и клиентские компоненты в React 18+), так и практические задачи: реализация кастомного хука useLocalStorage, написание функции debounce, логика retry с экспоненциальной задержкой. Ккандидат показывает глубокое понимание внутренних механизмов React, включая мемоизацию, управление состоянием, предотвращение утечек памяти и эффективную работу с DOM, что делает этот разбор особенно полезным для подготовки к реальным техническим интервью.
Вопрос 1. Как обеспечить доступность HTML-элементов для скринридеров при использовании кастомных компонентов?
Таймкод: 00:00:18
Ответ собеседника: Правильный. Использовать ARIA-атрибуты (role, aria-label), применять семантические теги (section, article), обеспечить правильное поведение фокуса через tab-index для интерактивных элементов, а также проверить достаточный визуальный контраст между текстом и фоном.
Правильный ответ:
Кандидат дал правильный и достаточно полный ответ. Дополним его деталями и примерами для более глубокого понимания темы.
1. ARIA-атрибуты (Accessible Rich Internet Applications)
ARIA — набор атрибутов, расширяющих семантику HTML-элементов для вспомогательных технологий.
- role — определяет роль элемента (кнопка, диалог, навигация, список и т.д.)
- aria-label — текстовое описание элемента, если видимой метки нет
- aria-labelledby — ссылка на другой элемент, содержащий метку
- aria-describedby — ссылка на элемент с дополнительным описанием
- aria-expanded — указывает, раскрыт ли элемент (аккордеон, выпадающее меню)
- aria-hidden — скрывает элемент от скринридеров (например, декоративные иконки)
- aria-live — уведомляет скринридер об изменениях содержимого без перевода фокуса
Пример кастомной кнопки на div:
<!-- Плохо: div без семантики -->
<div class="btn" onclick="submit()">Отправить</div>
<!-- Правильно: добавлены ARIA-атрибуты -->
<div role="button" tabindex="0" aria-label="Отправить форму"
class="btn" onclick="submit()"
onkeydown="if(event.key==='Enter'||event.key===' ') submit()">
Отправить
</div>
2. Семантические HTML-теги
Всегда предпочитайте нативные семантические теги кастомным элементам:
<header>,<footer>,<main>,<nav>,<section>,<article>,<aside>— структура страницы<button>,<a>,<input>,<select>,<textarea>— интерактивные элементы<ul>,<ol>,<li>— списки<table>,<thead>,<tbody>,<th>,<td>— таблицы
Нативные элементы уже имеют встроенную доступность (фокус, клавиатурная навигация, роли). Кастомные элементы требуют ручной реализации всего этого.
3. Управление фокусом (Focus Management)
- tabindex="0" — элемент участвует в порядке табуляции
- tabindex="-1" — элемент можно сфокусировать программно, но не через Tab
- Для кастомных интерактивных компонентов необходимо обрабатывать клавиатурные события (Enter, Space, Escape, стрелки)
- При открытии модального диалога — захватывать фокус внутри него и возвращать при закрытии
Пример управления фокусом в модальном окне:
function openModal() {
const modal = document.getElementById('modal');
modal.setAttribute('aria-hidden', 'false');
modal.focus();
// Захват фокуса внутри модалки
trapFocus(modal);
}
4. Визуальный контраст
- Минимальное соотношение контраста 4.5:1 для обычного текста (WCAG AA)
- Для крупного текста (18pt+) — минимум 3:1
- Интерактивные элементы должны иметь видимое состояние фокуса (outline, box-shadow)
- Не полагайтесь только на цвет для передачи информации
5. Тестирование доступности
- Ручное тестирование с скринридерами (NVDA, VoiceOver, JAWS)
- Автоматизированные инструменты: axe-core, Lighthouse, WAVE
- Навигация только с клавиатуры (без мыши)
- Проверка порядка чтения — должен совпадать с визуальным порядком
6. Кастомные элементы и Shadow DOM
При использовании Web Components с Shadow DOM доступность усложняется:
- Используйте
<slot>для проекции контента - Применяйте
delegatesFocus: trueпри создании Shadow Root - Добавляйте ARIA-атрибуты к хост-элементу компонента
class CustomButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
shadow.innerHTML = `<button><slot></slot></button>`;
}
}
Ключевой принцип: если можно использовать нативный HTML-элемент — используйте его. Кастомные компоненты — только когда нативные не покрывают требуемое поведение, и тогда необходимо вручную реализовать всю доступность.
Вопрос 2. Как решить проблему тормозов страницы со списком из 10 000 элементов без изменения бэкенда?
Таймкод: 00:01:19
Ответ собеседника: Правильный. Применить виртуализацию (lazy rendering) с использованием библиотек react-window или react-virtualized. Рендерить только видимые элементы в viewport, не добавляя в DOM элементы за пределами видимой области.
Правильный ответ:
Кандидат верно определил основной подход — виртуализацию. Раскроем тему глубже, рассмотрим разные техники и их нюансы.
1. Виртуализация скролла (Virtual Scrolling)
Суть подхода: в DOM присутствуют только элементы, видимые в viewport, плюс небольшой буфер сверху и снизу. При скролле элементы переиспользуются — меняются их данные и позиции.
1.1. Библиотеки виртуализации
- react-window — легковесная (~6 kB), от того же автора, что и react-virtualized
- react-virtualized — более функциональная, но тяжелее (~27 kB)
- @tanstack/react-virtual (бывший react-virtual) — framework-agnostic, поддержка React, Vue, Solid, Svelte
- vue-virtual-scroller — для Vue.js
Пример с react-window:
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style} className={index % 2 ? 'ListItemOdd' : 'ListItemEven'}>
Row {items[index].name}
</div>
);
const VirtualizedList = () => (
<List
height={600}
itemCount={10000}
itemSize={35}
width="100%"
>
{Row}
</List>
);
Для элементов переменной высоты используйте VariableSizeList или react-virtuoso, который автоматически измеряет высоту элементов.
1.2. Ручная реализация виртуализации
Понимание принципов важно для интервью:
function VirtualList({ items, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const endIndex = Math.min(startIndex + visibleCount + 1, items.length);
const offsetY = startIndex * itemHeight;
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={(e) => setScrollTop(e.target.scrollTop)}
>
<div style={{ height: items.length * itemHeight, position: 'relative' }}>
<div style={{ position: 'absolute', top: offsetY, width: '100%' }}>
{items.slice(startIndex, endIndex).map((item, i) => (
<div key={startIndex + i} style={{ height: itemHeight }}>
{item.name}
</div>
))}
</div>
</div>
</div>
);
}
Контейнер с полной высотой создаёт скроллбар правильного размера, а абсолютно позиционированный блок отображает только видимые элементы.
2. Пагинация на клиенте
Если виртуализация не подходит по UX-требованиям, можно разбить данные на страницы:
const [visibleCount, setVisibleCount] = useState(50);
// Показываем только первые N элементов
const visibleItems = items.slice(0, visibleCount);
// Кнопка "Показать ещё"
<button onClick={() => setVisibleCount(prev => prev + 50)}>
Показать ещё ({items.length - visibleCount} осталось)
</button>
Это проще в реализации, но требует явного действия пользователя.
3. Бесконечный скролл (Infinite Scroll)
Компромисс между виртуализацией и пагинацией — подгрузка данных по мере скролла:
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
function InfiniteList() {
const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 0 }) => fetchItems(pageParam, 50),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
useEffect(() => {
if (inView && hasNextPage) fetchNextPage();
}, [inView]);
return (
<>
{data?.pages.map((page) =>
page.items.map((item) => <div key={item.id}>{item.name}</div>)
)}
<div ref={ref}>{hasNextPage ? 'Загрузка...' : ''}</div>
</>
);
}
4. Оптимизация рендеринга
Дополнительные техники, которые помогают при работе с большими списками:
- React.memo для предотвращения лишних ре-рендеров элементов списка
- Стабильные ключи — используйте уникальные ID, не индексы массива (иначе при виртуализации React будет путать элементы)
- Debounce/throttle обработчиков скролла, если реализуете вручную
- CSS contain: content — подсказка браузеру для оптимизации рендеринга
5. Сравнение подходов
| Подход | Сложность | UX | Память DOM | Поиск по списку |
|---|---|---|---|---|
| Виртуализация | Средняя | Отличный скролл | O(видимые) | Только видимые |
| Пагинация | Простая | Кнопки навигации | O(страница) | Текущая страница |
| Бесконечный скролл | Средняя | Плавный скролл | O(загруженные) | Загруженные |
6. Когда нужна серверная виртуализация
Если данные не помещаются в память клиента или нужен полнотекстовый поиск — единственный вариант — серверная пагинация или курсорная пагинация на бэкенде. Но в условии задачи бэкенд трогать нельзя, поэтому все 10 000 элементов должны быть доступны на клиенте.
Рекомендация: для большинства случаев — react-window или @tanstack/react-virtual. Для максимально плавного скролла с элементами разной высоты — react-virtuoso. Для простоты — пагинация или "Показать ещё".
Вопрос 3. Почему sticky-заголовок мерцает или схлопывается при скроллинге?
Таймкод: 00:02:21
Ответ собеседника: Правильный. Проблема связаны с контекстом наложения (stacking context). Новый stacking context может создаваться из-за transform или position у родителей. Нужно проверить z-index, overflow: hidden у родительских элементов и правильно настроить position: sticky.
Правильный ответ:
Кандидат верно указал на stacking context как ключевую причину. Разберём все типичные причины мерцания и схлопывания sticky-элементов подробно.
1. Stacking Context — главный враг sticky
position: sticky работает только внутри своего ближайшего предка, формирующего scrolling container. Если родитель создаёт новый stacking context, sticky-элемент прилипает к границам этого родителя, а не к viewport.
Свойства, создающие новый stacking context:
transform(дажеtransform: translateZ(0)для GPU-ускорения)will-change: transformfilter(дажеfilter: noneв некоторых браузерах)backdrop-filtercontain: paintилиcontain: strictisolation: isolatemix-blend-modeopacityменьше 1 в сочетании сpositionposition: fixed
/* Проблема: родитель создаёт stacking context */
.parent {
transform: translateZ(0); /* частый хак для GPU-ускорения */
}
.sticky-header {
position: sticky;
top: 0;
/* Прилипает к .parent, а не к viewport! */
}
Решение: уберите создающие stacking context свойства у родителей или вынесите sticky-элемент за пределы этого контейнера.
2. overflow у родительских элементов
Если любой предок sticky-элемента имеет overflow: hidden, overflow: auto или overflow: scroll — sticky перестаёт работать корректно или работает относительно этого предка.
/* Проблема */
.wrapper {
overflow: hidden; /* sticky схлопнется */
}
/* Решение: используйте overflow: clip вместо hidden */
.wrapper {
overflow: clip; /* не создаёт clipping context для sticky */
overflow-clip-margin: 10px;
}
3. Недостаточный z-index
Sticky-элемент может просвечиваться под контентом при скролле:
.sticky-header {
position: sticky;
top: 0;
z-index: 100; /* явно задайте z-index */
background: white; /* непрозрачный фон обязателен */
}
Без непрозрачного фона контент будет просвечивать через заголовок.
4. Мерцание из-за пересчёта лейаута
Когда sticky-элемент меняет размер при переходе в fixed-подобное состояние (теряет ширину родителя), происходит layout shift:
.sticky-header {
position: sticky;
top: 0;
width: 100%; /* может сломаться при залипании */
}
/* Решение: фиксируйте ширину или используйте контейнер */
.sticky-wrapper {
height: 60px; /* фиксированная высота предотвращает схлопывание */
}
.sticky-header {
position: sticky;
top: 0;
height: 60px;
}
5. Проблемы с производительностью
Мерцание может быть вызвано тяжёлыми операциями во время скролла:
- Слушатели скролла без throttling/debounce
- Сложные CSS-анимации на sticky-элементе
box-shadowс большим радиусом размытия- Repaint-тяжёлые свойства
/* Проблема: тяжёлый box-shadow */
.sticky-header {
box-shadow: 0 2px 20px rgba(0,0,0,0.15);
}
/* Решение: используйте псевдоэлемент с оптимизацией */
.sticky-header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(to bottom, rgba(0,0,0,0.1), transparent);
pointer-events: none;
}
6. Баги мобильных браузеров
На iOS Safari и мобильном Chrome sticky работает нестабильно:
/* Фикс для iOS Safari */
.sticky-header {
position: -webkit-sticky; /* префикс для старых версий */
position: sticky;
top: 0;
}
/* Родитель скролл-контейнера должен быть достаточно высоким */
.scroll-container {
min-height: 101vh; /* гарантирует наличие скролла */
}
7. Диагностика проблемы
Алгоритм поиска причины:
- Откройте DevTools → Elements → найдите sticky-элемент
- Проверьте всех родителей на наличие
transform,filter,will-change,overflow - В консоли:
getComputedStyle(element).position— убедитесь, что sticky применяется - В Chrome DevTools → Rendering → включите "Paint flashing" — увидите области перерисовки
- Проверьте, что у sticky-элемента есть явно заданный
top(илиbottom,left,right)
Чеклист для исправления:
- Убрать
transform,filter,will-changeу родителей или вынести sticky за их пределы - Заменить
overflow: hiddenнаoverflow: clipу родителей - Задать явный
z-indexи непрозрачный фон - Зафиксировать высоту sticky-элемента и его обёртки
- Добавить
position: -webkit-stickyдля совместимости - Убедиться, что задан
top: 0(обязательное требование для sticky)
Вопрос 4. Как реализовать адаптивную раскладку карточек: 3 на десктопе, 2 на планшете, 1 на мобильном?
Таймкод: 00:03:16
Ответ собеседника: Правильный. Использовать CSS Grid с grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)) и gap. Это автоматически подбирает количество колонок в зависимости от ширины контейнера.
Правильный ответ:
Кандидат предложил элегантное решение с auto-fit и minmax. Рассмотрим все подходы, их плюсы и минусы.
1. CSS Grid с auto-fit и minmax (декларативный подход)
Самое простое и поддерживаемое решение:
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
padding: 16px;
}
Как это работает:
minmax(300px, 1fr)— каждая колонка минимум 300px, максимум — равная доля свободного пространстваauto-fit— автоматически создаёт столько колонок, сколько помещается- При ширине контейнера 900px+ → 3 колонки, 600–900px → 2 колонки, менее 600px → 1 колонка
Важный нюанс: auto-fit растягивает элементы на всю ширину последней строки, если элементов меньше, чем колонок. Если это нежелательно, используйте auto-fill:
/* auto-fill сохраняет пустые колонки, не растягивая элементы */
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
2. CSS Grid с медиа-запросами (точный контроль)
Если нужен строгий контроль над количеством колонок на конкретных брейкпоинтах:
.card-grid {
display: grid;
gap: 24px;
padding: 16px;
/* Мобильный по умолчанию */
grid-template-columns: 1fr;
}
/* Планшет */
@media (min-width: 600px) {
.card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Десктоп */
@media (min-width: 1024px) {
.card-grid {
grid-template-columns: repeat(3, 1fr);
}
}
Преимущества: полный контроль, предсказуемое поведение, возможность задать разные gap для разных разрешений.
3. Flexbox с медиа-запросами
Альтернатива через Flexbox:
.card-grid {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.card {
flex: 1 1 100%; /* Мобильный: полная ширина */
}
@media (min-width: 600px) {
.card {
flex: 1 1 calc(50% - 12px); /* Планшет: 2 колонки */
}
}
@media (min-width: 1024px) {
.card {
flex: 1 1 calc(33.333% - 16px); /* Десктоп: 3 колонки */
}
}
Недосток Flexbox: последняя строка может быть незаполненной и выглядеть некрасиво. Grid решает это лучше.
4. Container Queries (современный подход)
Позволяют адаптировать раскладку на основе ширины контейнера, а не viewport:
.card-grid-container {
container-type: inline-size;
container-name: card-grid;
}
.card-grid {
display: grid;
gap: 24px;
grid-template-columns: 1fr;
}
@container card-grid (min-width: 600px) {
.card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@container card-grid (min-width: 1024px) {
..card-grid {
grid-template-columns: repeat(3, 1fr);
}
}
Преимущество: компонент адаптируется к своему контейнеру, а не к экрану. Это идеально для компонентного подхода и виджетов, встраиваемых в разные части страницы.
5. Сравнение подходов
| Подход | Контроль | Простота | Поддержка браузерами | Компонентность |
|---|---|---|---|---|
| Grid + auto-fit | Средняя | Высокая | Отличная | Средняя |
| Grid + медиа-запросы | Полный | Средняя | Отличная | Средняя |
| Flexbox + медиа-запросы | Полный | Средняя | Отличная | Средняя |
| Container Queries | Полный | Средняя | Хорошая (2023+) | Высокая |
6. Рекомендации по выбору
- Простой случай —
auto-fit+minmax: минимум кода, работает из коробки - Строгие требования к брейкпоинтам — Grid с медиа-запросами
- Компонентный дизайн — Container Queries (если поддерживается целевыми браузерами)
- Нужна поддержка старых браузеров — Grid с медиа-запросами или Flexbox
7. Дополнительные соображения
- Используйте
gapвместоmarginдля отступов — проще управлять - Задавайте
min-widthкарточкам, чтобы они не сжимались слишком сильно - Учитывайте контент карточек — если высоты разные, Grid выровняет их по умолчанию (align-items: stretch)
- Для карточек с разной высотой добавьте
align-items: start, чтобы избежать растягивания
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
align-items: start; /* карточки не растягиваются по высоте */
}
Вопрос 5. Как центрировать div по горизонтали и вертикали без flexbox или grid?
Таймкод: 00:05:04
Ответ собеседника: Правильный. Использовать абсолютное позиционирование: родителю — position: relative, дочернему — position: absolute, top: 50%, left: 50%, transform: translate(-50%, -50%).
Правильный ответ:
Кандидат назвал самый распространённый способ. Рассмотрим все альтернативные методы центрирования без flexbox и grid.
1. Absolute + Transform (назван кандидатом)
.parent {
position: relative;
height: 400px; /* или любая высота */
}
.child {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
Принцип: top: 50% и left: 50% позиционируют верхний левый угол элемента в центре родителя. transform: translate(-50%, -50%) сдвигают элемент назад на половину его собственных размеров.
Преимущества: работает при неизвестных размерах элемента, не требует вычислений.
2. Absolute + Margin Auto (элемент с фиксированными размерами)
.parent {
position: relative;
height: 400px;
}
.child {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
width: 200px; /* обязательны фиксированные размеры */
height: 100px;
}
Принцип: при всех нулевых отступах (top/right/bottom/left: 0) и margin: auto браузер автоматически вычисляет равные отступы со всех сторон.
Ограничение: элемент должен иметь явно заданную ширину и высоту.
3. Table-Cell вертикальное центрирование
.parent {
display: table;
width: 100%;
height: 400px;
}
.child {
display: table-cell;
vertical-align: middle;
text-align: center;
}
.inner {
display: inline-block; /* для горизонтального центрирования */
}
<div class="parent">
<div class="child">
<div class="inner">Центрированный контент</div>
</div>
</div>
Принцип: vertical-align: middle работает внутри table-cell аналогично ячейкам таблицы.
4. Line-Height (для однострочного текста)
.parent {
height: 400px;
line-height: 400px; /* равна высоте родителя */
text-align: center;
}
.child {
display: inline-block;
vertical-align: middle;
line-height: normal; /* сброс для внутреннего текста */
}
Ограничение: работает только для однострочного текста или inline/inline-block элементов.
5. CSS Calc (при известных размерах)
.parent {
position: relative;
height: 400px;
}
.child {
position: absolute;
width: 200px;
height: 100px;
top: calc(50% - 50px); /* половина высоты элемента */
left: calc(50% - 100px); /* половина ширины элемента */
}
Ограничение: необходимо знать точные размеры элемента.
6. Writing-Mode трюк (экзотический)
.parent {
writing-mode: vertical-lr;
text-align: center;
}
.child {
writing-mode: horizontal-tb;
display: inline-block;
text-align: left;
}
Использует изменение направления потока для перестановки осей выравнивания.
Сравнение методов
| Метод | Неизвестные размеры | Браузеры | Сложность | Побочные эффекты |
|---|---|---|---|---|
| Absolute + Transform | Да | IE9+ | Простой | Новый stacking context |
| Absolute + Margin Auto | Нет | Все | Простой | Требует размеры |
| Table-Cell | Да | Все | Средний | Дополнительная разметка |
| Line-Height | Нет | Все | Простой | Только текст |
| Calc | Нет | IE9+ | Простой | Требует размеры |
Рекомендация: метод с transform: translate(-50%, -50%) — самый универсальный. Он работает при любых размерах элемента, хорошо поддерживается браузерами и не требует дополнительной разметки. Единственный нюанс — создаёт новый stacking context, что может влиять на z-index и работу position: sticky у дочерних элементов.
Вопрос 6. В чём разница между structured clone и JSON.parse(JSON.stringify()) для клонирования объектов?
Таймкод: 00:06:50
Ответ собеседника: Правильный. Structured clone — современный API, поддерживает больше типов (Date, Map, Set) и корректно обрабатывает циклические ссылки. JSON.stringify при циклических ссылках выбрасывает ошибку, а также теряет функции, undefined и Symbol.
Правильный ответ:
Кандидат верно описал ключевые различия. Дополним ответ деталями и примерами.
1. Поддерживаемые типы данных
JSON.parse(JSON.stringify()) работает только с JSON-совместимыми типами:
const obj = {
string: 'hello',
number: 42,
boolean: true,
null: null,
array: [1, 2, 3],
nested: { a: 1 }
};
const clone = JSON.parse(JSON.stringify(obj));
// Работает корректно
Что теряется при JSON-сериализации:
const obj = {
date: new Date(), // → строка "2024-01-15T..."
regex: /test/gi, // → пустой объект {}
func: () => {}, // → исчезает
undef: undefined, // → исчезает
symbol: Symbol('id'), // → исчезает
map: new Map([['a', 1]]), // → пустой объект {}
set: new Set([1, 2, 3]), // → пустой объект {}
nan: NaN, // → null
infinity: Infinity, // → null
bigint: 9007199254740993n // → TypeError!
};
console.log(JSON.parse(JSON.stringify(obj)));
// {
// date: "2024-01-15T...",
// regex: {},
// map: {},
// set: {},
// nan: null,
// infinity: null
// }
// func, undef, symbol — потеряны полностью
// bigint — выбросит ошибку
Structured Clone поддерживает значительно больше типов:
const obj = {
date: new Date(), // → Date (корректно)
regex: /test/gi, // → RegExp (корректно)
map: new Map([['a', 1]]), // → Map (корректно)
set: new Set([1, 2, 3]), // → Set (корректно)
arrayBuffer: new ArrayBuffer(8), // → ArrayBuffer
blob: new Blob(['data']), // → Blob
error: new Error('test'), // → Error
dataView: new DataView(new ArrayBuffer(8))
};
const clone = structuredClone(obj);
// Все типы сохранены корректно
2. Циклические ссылки
const obj = { a: 1 };
obj.self = obj; // циклическая ссылка
// JSON — падает с ошибкой
JSON.parse(JSON.stringify(obj));
// TypeError: Converting circular structure to JSON
// Structured Clone — работает
const clone = structuredClone(obj);
console.log(clone.self === clone); // true
console.log(clone.self === obj); // false — это настоящий клон
3. Производительность
Для больших объектов structured clone обычно быстрее, так как не требует преобразования в строку и обратно:
// Бенчмарк на большом объекте
const largeObj = { /* 1MB данных */ };
console.time('JSON');
JSON.parse(JSON.stringify(largeObj));
console.timeEnd('JSON'); // ~50-100ms
console.time('structuredClone');
structuredClone(largeObj);
console.timeEnd('structuredClone'); // ~10-30ms
4. Ограничения structured clone
Structured clone не поддерживает:
- Функции (выбросит
DataCloneError) - DOM-узлы
- Некоторые свойства объектов (например,
prototype chainне клонируется) - Symbol как ключи или значения
structuredClone({ fn: () => {} });
// DataCloneError: function() could not be cloned
5. Где доступен structured clone
- Браузеры: Chrome 98+, Firefox 94+, Safari 15.4+
- Node.js: 17.0+
- Доступен в Web Workers через
postMessage(всегда использовал structured clone)
6. Альтернативы для полного глубокого клонирования
Если нужна поддержка функций и кастомных типов:
// Lodash
import cloneDeep from 'lodash/cloneDeep';
const clone = cloneDeep(obj);
// Написание собственного клонера
function deepClone(obj, hash = new WeakMap()) {
if (Object(obj) !== obj) return obj; // примитив
if (hash.has(obj)) return hash.get(obj); // циклическая ссылка
const result = Array.isArray(obj) ? [] :
obj instanceof Date ? new Date(obj) :
obj instanceof RegExp ? new RegExp(obj.source, obj.flags) :
obj instanceof Map ? new Map(Array.from(obj, ([k, v]) => [k, deepClone(v, hash)])) :
obj instanceof Set ? new Set(Array.from(obj, v => deepClone(v, hash))) :
new obj.constructor();
hash.set(obj, result);
return Object.assign(result, ...Object.keys(obj).map(
key => ({ [key]: deepClone(obj[key], hash) })
));
}
7. Когда что использовать
- JSON.parse(JSON.stringify()) — простые объекты без специальных типов, нужна максимальная совместимость
- structuredClone() — современные приложения, есть Date/Map/Set, циклические ссылки, важна производительность
- Lodash cloneDeep — нужна поддержка функций, кастомных классов, сложных структур
- Spread {...obj} или Object.assign — только поверхностное копирование (shallow clone)
Вопрос 7. Как реализовать retry-логику с exponential backoff для функции, возвращающей promise?
Таймкод: 00:07:38
Ответ собеседника: Правильный. Создать async функцию retry, принимающую функцию-промис, количество попыток и начальную задержку. В try вызвать функцию, в catch проверить оставшиеся попытки. Если есть — через setTimeout подождать и рекурсивно вызвать retry с уменьшенным числом попыток и увеличенной задержкой (delay * 2).
Правильный ответ:
Кандидат верно описал общий подход. Приведём полную реализацию с различными опциями и рассмотрим нюансы.
1. Базовая реализация
async function retry(fn, maxAttempts = 3, baseDelay = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts) {
throw error; // последняя попытка — пробрасываем ошибку
}
const delay = baseDelay * Math.pow(2, attempt - 1);
console.log(`Попытка ${attempt} не удалась, повтор через ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Использование
const result = retry(
() => fetch('/api/data').then(r => r.json()),
5, // максимум 5 попыток
1000 // начальная задержка 1 секунда
);
2. Реализация с jitter (рандомизацией задержки)
Чистый exponential backoff может вызвать "thundering herd" — множество клиентов одновременно повторяют запросы. Jitter решает эту проблему:
async function retryWithJitter(fn, maxAttempts = 3, baseDelay = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts) throw error;
// Полный jitter: случайная задержка от 0 до экспоненциальной
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
const jitter = Math.random() * exponentialDelay;
const delay = exponentialDelay + jitter;
// Или используйте "decorrelated jitter":
// const delay = Math.random() * baseDelay * Math.pow(3, attempt - 1);
// Или "equal jitter":
// const half = exponentialDelay / 2;
// const delay = half + Math.random() * half;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
Типы jitter:
- Full jitter:
random(0, baseDelay * 2^attempt)— максимальное разброс - Equal jitter:
baseDelay * 2^(attempt-1) / 2 + random(0, baseDelay * 2^(attempt-1) / 2) - Decorrelated jitter:
random(baseDelay, prevDelay * 3)— менее предсказуемый
3. Продвинутая реализация с фильтром ошибок и колбэками
async function retryAdvanced(fn, options = {}) {
const {
maxAttempts = 3,
baseDelay = 1000,
maxDelay = 30000, // потолок задержки
jitter = true,
retryIf = () => true, // функция-фильтр ошибок
onRetry = null // колбэк перед каждым повтором
} = options;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts || !retryIf(error)) {
throw error;
}
let delay = baseDelay * Math.pow(2, attempt - 1);
delay = Math.min(delay, maxDelay); // ограничиваем потолком
if (jitter) {
delay = delay * (0.5 + Math.random() * 0.5);
}
if (onRetry) {
onRetry(error, attempt, delay);
}
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
// Использование с фильтрацией ошибок
const result = retryAdvanced(
() => fetch('/api/data'),
{
maxAttempts: 5,
baseDelay: 1000,
maxDelay: 16000,
retryIf: (error) => {
// Не ретраим 4xx ошибки (клиентские)
if (error.status >= 400 && error.status < 500) return false;
// Ретраим 5xx и сетевые ошибки
return true;
},
onRetry: (error, attempt, delay) => {
console.warn(`Retry ${attempt} через ${delay}ms:`, error.message);
}
}
);
4. AbortController для отмены
async function retryAbortable(fn, options = {}) {
const { signal, ...retryOptions } = options;
return retryAdvanced(async () => {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
return await fn();
}, retryOptions);
}
// Использование
const controller = new AbortController();
retryAbortable(
() => fetch('/api/data', { signal: controller.signal }),
{ signal: controller.signal, maxAttempts: 5 }
);
// Отмена всех попыток
setTimeout(() => controller.abort(), 5000);
5. Реализация на классе с возможностью отмены
class RetryableRequest {
constructor(fn, options = {}) {
this.fn = fn;
this.maxAttempts = options.maxAttempts || 3;
this.baseDelay = options.baseDelay || 1000;
this.maxDelay = options.maxDelay || 30000;
this.jitter = options.jitter !== false;
this.retryIf = options.retryIf || (() => true);
this.onRetry = options.onRetry || null;
this._aborted = false;
}
abort() {
this._aborted = true;
}
async execute() {
let lastError;
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
if (this._aborted) {
throw new DOMException('Aborted', 'AbortError');
}
try {
return await this.fn();
} catch (error) {
lastError = error;
if (attempt === this.maxAttempts || !this.retryIf(error)) {
throw error;
}
let delay = this.baseDelay * Math.pow(2, attempt - 1);
delay = Math.min(delay, this.maxDelay);
if (this.jitter) {
delay = delay * (0.5 + Math.random() * 0.5);
}
if (this.onRetry) {
this.onRetry(error, attempt, delay);
}
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, delay);
// Проверка отмены во время ожидания
const checkAbort = () => {
if (this._aborted) {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
}
};
this._checkInterval = setInterval(checkAbort, 100);
});
}
}
throw lastError;
}
}
// Использование
const request = new RetryableRequest(
() => fetch('/api/data'),
{
maxAttempts: 5,
onRetry: (err, attempt, delay) => console.log(`Retry ${attempt} через ${delay}ms`)
}
);
request.execute().then(console.log).catch(console.error);
// request.abort(); // можно отменить
6. Готовые библиотеки
В production-коде лучше использовать проверенные библиотеки:
- p-retry — минималистичная retry-библиотека
- axios-retry — retry для axios с поддержкой интерцепторов
- got — HTTP-клиент со встроенным retry
- cockatiel — полноценная библиотека с retry, circuit breaker, timeout, bulkhead
import pRetry from 'p-retry';
const result = await pRetry(() => fetch('/api/data'), {
retries: 5,
factor: 2,
minTimeout: 1000,
maxTimeout: 16000,
randomize: true,
onFailedAttempt: error => {
console.log(`Attempt ${error.attemptNumber} failed`);
}
});
7. Важные соображения
- Idempotency: retry безопасен только для идемпотентных операций (GET, PUT с полным обновлением). Для POST без идемпотентного ключа — рискованно
- Rate limiting: сервер может возвраждать 429 Too Many Requests — уважайте заголовок
Retry-After - Circuit breaker: retry без circuit breaker может завалить упавший сервис — комбинируйте паттерны
- Логирование: всегда логируйте попытки для отладки в production
Вопрос 8. Какой реальный пример использования AbortController в React?
Таймкод: 00:11:31
Ответ собеседника: Правильный. Использовать AbortController в cleanup функции useEffect для отмены предыдущих API-запросов при повторном рендере компонента, чтобы предотвратить race conditions и утечки памяти.
Правильный ответ:
Кандидат описал основной сценарий корректно. Приведём конкретные примеры кода с разными ситуациями.
1. Базовый пример: отмена запроса при размонтировании
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function fetchUser() {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal
});
const data = await response.json();
setUser(data);
setError(null);
} catch (err) {
// Не обновляем состояние, если запрос был отменён
if (err.name === 'AbortError') {
console.log('Запрос отменён');
return;
}
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUser();
// Cleanup: отменяем запрос при размонтировании или изменении userId
return () => controller.abort();
}, [userId]);
if (loading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error}</div>;
return <div>Привет, {user?.name}</div>;
}
2. Race condition при быстрой смене параметров
Проблема: пользователь быстро переключается между userId=1, userId=2, userId=3. Запросы завершаются в непредсказуемом порядке, и может отобразиться данные для userId=1 вместо userId=3.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
// Пустой запрос — не делаем запрос
if (!query.trim()) {
setResults([]);
return;
}
const controller = new AbortController();
async function search() {
setLoading(true);
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
const data = await response.json();
setResults(data.results);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Search failed:', err);
}
} finally {
setLoading(false);
}
}
// Добавляем debounce для поиска
const debounceTimer = setTimeout(search, 300);
return () => {
controller.abort(); // отменяем предыдущий запрос
clearTimeout(debounceTimer); // отменяем таймер
};
}, [query]);
return (
<div>
{loading && <span>Поиск...</span>}
{results.map(item => (
<div key={item.id}>{item.title}</div>
))}
</div>
);
}
3. Автокомплит с отменой предыдущих запросов
function Autocomplete({ onSelect }) {
const [input, setInput] = useState('');
const [suggestions, setSuggestions] = useState([]);
useEffect(() => {
if (input.length < 2) {
setSuggestions([]);
return;
}
const controller = new AbortController();
async function fetchSuggestions() {
try {
const response = await fetch(
`/api/suggest?q=${encodeURIComponent(input)}`,
{ signal: controller.signal }
);
const data = await response.json();
setSuggestions(data);
} catch (err) {
if (err.name !== 'AbortError') {
setSuggestions([]);
}
}
}
const timer = setTimeout(fetchSuggestions, 200);
return () => {
controller.abort();
clearTimeout(timer);
};
}, [input]);
return (
<div>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Поиск..."
/>
<ul>
{suggestions.map(s => (
<li key={s.id} onClick={() => onSelect(s)}>
{s.name}
</li>
))}
</ul>
</div>
);
}
**
4. Загрузка файла с прогрессом и возможностью отмены**
```jsx
function FileUploader() \{
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const abortControllerRef = useRef(null);
const handleUpload = async (file) => \{
abortControllerRef.current = new AbortController();
setUploading(true);
setProgress(0);
const formData = new FormData();
formData.append('file', file);
try \{
const xhr = new XMLHttpRequest();
// Связываем XMLHttpRequest с AbortController
abortControllerRef.current.signal.addEventListener('abort', () => \{
xhr.abort();
\});
xhr.upload.addEventListener('progress', (event) => \{
if (event.lengthComputable) \{
setProgress(Math.round((event.loaded / event.total) * 100));
\}
\});
await new Promise((resolve, reject) => \{
xhr.onload = () => resolve(xhr.response);
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.open('POST', '/api/upload');
xhr.send(formData);
\});
console.log('Загрузка завершена');
\} catch (err) \{
if (err.name === 'AbortError') \{
console.log('Загрузка отменена');
\} else \{
console.error('Ошибка загрузки:', err);
\}
\} finally \{
setUploading(false);
\}
\};
const handleCancel = () => \{
abortControllerRef.current?.abort();
\};
return (
<div>
<input
type="file"
onChange=\{e => handleUpload(e.target.files[0])\}
disabled=\{uploading\}
/>
\{uploading && (
<>
<progress value={progress} max="100" />
<span>{progress}%</span>
<button onClick={handleCancel}>Отменить</button>
</>
)\}
</div>
);
\}
5. Кастомный хук useAbortableFetch
import \{ useState, useEffect, useRef, useCallback \} from 'react';
function useAbortableFetch(url, options = \{\}) \{
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const controllerRef = useRef(null);
const execute = useCallback(async (overrideOptions = \{\}) => \{
// Отменяем предыдущий запрос
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setLoading(true);
setError(null);
try \{
const response = await fetch(url, \{
...options,
...overrideOptions,
signal: controller.signal
\});
if (!response.ok) \{
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
\}
const result = await response.json();
setData(result);
return result;
\} catch (err) \{
if (err.name !== 'AbortError') \{
setError(err.message);
throw err;
\}
\} finally \{
setLoading(false);
\}
\}, [url]);
const abort = useCallback(() => \{
controllerRef.current?.abort();
\}, []);
useEffect(() => \{
// Автоматический запрос при монтировании, если указан url
if (url) \{
execute();
\}
return () => controllerRef.current?.abort();
\}, [url]);
return \{ data, loading, error, execute, abort \};
\}
// Использование
function UserList(\{ departmentId \}) \{
const \{ data: users, loading, error, abort \} = useAbortableFetch(
`/api/users?department=${departmentId}`
);
return (
<div>
\{loading && (
<div>
Загрузка...
<button onClick={abort}>Отменить</button>
</div>
)\}
{error && <div>Ошибка: {error}</div>}
\{users?.map(user => (
<div key={user.id}>{user.name}</div>
))\}
</div>
);
\}
6. Важные нюансы
- Проверка AbortError: всегда проверяйте
err.name === 'AbortError'перед обновлением состояния — иначе отменённые запросы будут вызывать ошибки в UI - Библиотеки: axios поддерживает AbortController нативно. React Query / TanStack Query имеет встроенную отмену запросов
- Нельзя переиспользовать: после
abort()контроллер нельзя использовать повторно — создавайте новый для каждого запроса - Совместимость: AbortController поддерживается во всех современных браузерах и Node.js 15+
Вопрос 9. В чём разница между debounce и throttle простыми словами?
Таймкод: 00:12:19
Ответ собеседника: Правильный. Debounce ждёт паузу в активности перед выполнением функции (например, поиск при вводе). Каждый новый ввод сбрасывает таймер. Throttle ограничивает частоту выполнения функции, позволяя вызывать её не чаще определённого интервала.
Правильный ответ:
Кандидат дал точное и понятное объяснение. Дополним примерами реализаций и визуализацией.
1. Аналогия из жизни
Debounce — как дверь в лифт: каждый раз, когда кто-то заходит, таймер перезапускается. Дверь закроется только когда никто не заходит несколько секунд.
Throttle — как семофор: зелёный горит строго раз в 60 секунд, независимо от того, сколько машин ждут.
2. Визуализация поведения
События: * * * * * * * * * *
──────── ── ────────
Debounce: ── ──
(ждём паузу) (пауза) (пауза)
Throttle: * * * * *
(каждые N мс, независимо от частоты событий)
3. Реализация debounce
function debounce(fn, delay) {
let timerId;
return function (...args) {
// Сбрасываем предыдущий таймер
clearTimeout(timerId);
// Запускаем новый
timerId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// Вариант с немедленным первым вызовом (leading edge)
function debounceLeading(fn, delay) {
let timerId;
let canCall = true;
return function (...args) {
if (canCall) {
fn.apply(this, args);
canCall = false;
}
clearTimeout(timerId);
timerId = setTimeout(() => {
canCall = true;
}, delay);
};
}
// Поиск с debounce
const searchInput = document.getElementById('search');
const debouncedSearch = debounce((query) => {
fetch(`/api/search?q=${query}`);
}, 300);
searchInput.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
4. Реализация throttle
function throttle(fn, interval) {
let lastCall = 0;
return function (...args) {
const now = Date.now();
if (now - lastCall >= interval) {
lastCall = now;
fn.apply(this, args);
}
};
}
// Вариант с trailing call (выполнить последний вызов после интервала)
function throttleWithTrailing(fn, interval) {
let lastCall = 0;
let timerId;
return function (...args) {
const now = Date.now();
const timeSinceLastCall = now - lastCall;
if (timeSinceLastCall >= interval) {
lastCall = now;
fn.apply(this, args);
} else {
// Запланировать выполнение последнего вызова
clearTimeout(timerId);
timerId = setTimeout(() => {
lastCall = Date.now();
fn.apply(this, args);
}, interval - timeSinceLastCall);
}
};
}
// Обработка скролла с throttle
const throttledScroll = throttle(() => {
console.log('Scroll position:', window.scrollY);
updateStickyHeader();
}, 100);
window.addEventListener('scroll', throttledScroll);
5. Типичные сценарии использования
Debounce:
- Поиск при вводе текста (автокомплит)
- Валидация формы после окончания ввода
- Автосохранение документа
- Изменение размера окна (resize) — пересчёт лейаута
Throttle:
- Обработка скролла (бесконечная подгрузка, sticky-элементы)
- Отслеживание движения мыши (drag, hover-эффекты)
- Обработка кликов для предотвращения двойного срабатывания
- Отправка аналитики (scroll depth, время на странице)
- Игровой цикл (ограничение FPS)
6. Сравнительная таблица
| Характеристика | Debounce | Throttle |
|---|---|---|
| Когда выполняется | После паузы в событиях | Регулярно, через интервал |
| Гарантия выполнения | Только если события прекратятся | Да, каждые N мс |
| Первый вызов | После задержки (по умолчанию) | Сразу |
| Последний вызов | Да, после паузы | Не гарантирован |
| Аналогия | Дверь лифта | Семофор |
7. Комбинированный подход (debounce + throttle)
Иногда нужна гарантия выполнения, но без избыточных вызовов:
function debounceWithThrottle(fn, debounceDelay, throttleInterval) {
let debounceTimer;
let lastCall = 0;
return function (...args) {
const now = Date.now();
// Throttle: если прошло достаточно времени — выполняем сразу
if (now - lastCall >= throttleInterval) {
lastCall = now;
fn.apply(this, args);
}
// Debounce: всегда перезапускаем таймер для финального вызова
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
lastCall = Date.now();
fn.apply(this, args);
}, debounceDelay);
};
}
// Автосохранение: сохраняем не чаще чем раз 5 секунд,
// но гарантированно сохраняем через 2 секунды после последнего изменения
const autoSave = debounceWithThrottle(
(content) => saveToServer(content),
2000, // debounce: ждём 2 секунды после последнего изменения
5000 // throttle: но сохраняем не чаще раз в 5 секунд
);
Вопрос 10. Написать простую функцию debounce.
Таймкод: 00:13:15
Ответ собеседника: Правильный. Функция debounce принимает функцию и задержку. Использует замыкание для хранения timer ID. При каждом вызове очищает предыдущий таймер (clearTimeout) и устанавливает новый (setTimeout), который вызывает функцию с задержкой через fn.apply() с переданными аргументами.
Правильный ответ:
Кандидат верно описал механизм. Приведём полную реализацию с вариантами.
1. Минимальная реализация
function debounce(fn, delay) {
let timerId;
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
2. Реализация с опциями leading и trailing
function debounce(fn, delay, options = {}) {
let timerId;
let lastCallTime;
let lastInvokeTime = 0;
let lastArgs = null;
let lastThis = null;
const { leading = false, trailing = true, maxWait } = options;
function invokeFunc(time) {
const args = lastArgs;
const thisArg = lastThis;
lastArgs = lastThis = null;
lastInvokeTime = time;
fn.apply(thisArg, args);
}
function startTimer(pendingFunc, wait) {
return setTimeout(pendingFunc, wait);
}
function remainingWait(time) {
const timeSinceLastCall = time - lastCallTime;
const timeSinceLastInvoke = time - lastInvokeTime;
const timeWaiting = delay - timeSinceLastCall;
return maxWait !== undefined
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting;
}
function shouldInvoke(time) {
const timeSinceLastCall = time - lastCallTime;
const timeSinceLastInvoke = time - lastInvokeTime;
return (
lastCallTime === undefined ||
timeSinceLastCall >= delay ||
timeSinceLastCall < 0 ||
(maxWait !== undefined && timeSinceLastInvoke >= maxWait)
);
}
function timerExpired() {
const time = Date.now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
startTimer(timerExpired, remainingWait(time));
}
function trailingEdge(time) {
timerId = undefined;
if (trailing && lastArgs) {
invokeFunc(time);
} else {
lastArgs = lastThis = null;
}
}
function leadingEdge(time) {
lastInvokeTime = time;
startTimer(timerExpired, delay);
if (leading) {
invokeFunc(time);
}
}
const debounced = function (...args) {
const time = Date.now();
const isInvoking = shouldInvoke(time);
lastArgs = args;
lastThis = this;
lastCallTime = time;
if (isInvoking) {
if (timerId === undefined) {
return leadingEdge(time);
}
if (maxWait !== undefined) {
startTimer(timerExpired, delay);
invokeFunc(time);
}
}
if (timerId === undefined) {
startTimer(timerExpired, delay);
}
};
debounced.cancel = function () {
if (timerId !== undefined) {
clearTimeout(timerId);
}
lastInvokeTime = 0;
lastArgs = lastCallTime = lastThis = timerId = undefined;
};
debounced.flush = function () {
if (timerId === undefined) {
return;
}
trailingEdge(Date.now());
};
return debounced;
}
3. Упрощённая версия с leading/trailing
function debounce(fn, delay, { leading = false, trailing = true } = {}) {
let timerId;
return function (...args) {
const isLeading = timerId === undefined;
clearTimeout(timerId);
timerId = setTimeout(() => {
timerId = undefined;
if (trailing) {
fn.apply(this, args);
}
}, delay);
if (leading && isLeading) {
fn.apply(this, args);
}
};
}
// Примеры использования
const debouncedTrailing = debounce(log, 300); // только trailing
const debouncedLeading = debounce(log, 300, { leading: true }); // только leading
const debouncedBoth = debounce(log, 300, { leading: true, trailing: true }); // оба
4. Использование
// Поиск с debounce
const handleSearch = debounce((query) => {
fetch(`/api/search?q=${query}`).then(r => r.json());
}, 300);
searchInput.addEventListener('input', (e) => handleSearch(e.target.value));
// С отменой
const debouncedSave = debounce(saveDocument, 1000);
debouncedSave(content);
debouncedSave.cancel(); // отменить отложенное сохранение
// С принудительным вызовом
debouncedSave.flush(); // вызвать немедленно, если есть отложенный вызов
5. Ключевые моменты реализации
- Замыкание:
timerIdсохраняется между вызовами через замыкание - Контекст:
fn.apply(this, args)сохраняет контекст вызова и передаёт аргументы - clearTimeout: сбрасывает предыдущий таймер при каждом новом вызове
- Ведущий край (leading): вызывает функцию сразу при первом вызове, затем игнорирует до истечения задержки
- Задержка (trailing): вызывает функцию после паузы — стандартное поведение
Вопрос 11. В чём разница по времени выполнения между cleanup в useEffect и useLayoutEffect?
Таймкод: 00:15:25
Ответ собеседника: Правильный. useEffect cleanup выполняется после того, как рендер применён к экрану (асинхронно). useLayoutEffect cleanup выполняется до того, как браузер перерисует страницу (синхронно). useLayoutEffect используется, когда нужны измерения DOM или синхронная работа с layout.
Правильный ответ:
Кандидат верно описал разницу. Разберём жизненный цикл подробнее с визуализацией.
1. Порядок выполнения при монтировании
Рендер компонента
│
▼
useEffect callback (асинхронно, после paint)
│
▼
useLayoutEffect callback (синхронно, до paint)
│
▼
Браузер paint (отрисовка на экран)
2. Порядок выполнения при обновлении (ре-рендер)
Рендер компонента
│
▼
useEffect cleanup (асинхронно, после paint предыдущего рендера)
│
▼
useLayoutEffect cleanup (синхронно, ДО paint)
│
▼
useLayoutEffect callback (синхронно, ДО paint)
│
▼
Браузер paint
│
▼
useEffect callback (асинхронно, после paint)
3. Порядок выполнения при размонтировании
Размонтирование компонента
│
▼
useLayoutEffect cleanup (синхронно)
│
▼
useEffect cleanup (асинхронно)
4. Визуализация разницы
Временная шкала:
─────────────────────────────────────────────────────────►
useEffect:
[РенDER] ──── [PAINT] ──── [useEffect cleanup] ──── [useEffect callback]
↑
Пользователь видит изменения ДО выполнения эффекта
useLayoutEffect:
[РЕНДЕР] ──── [useLayoutEffect cleanup] ──── [useLayoutEffect callback] ──── [PAINT]
↑
Пользователь видит изменения ПОСЛЕ эффекта
5. Практический пример: измерение DOM
function Tooltip({ targetRef, children }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
// useLayoutEffect — правильный выбор для измерений DOM
useLayoutEffect(() => {
const target = targetRef.current;
if (!target) return;
const rect = target.getBoundingClientRect();
setPosition({
top: rect.bottom + 8,
left: rect.left + rect.width / 2
});
}, [targetRef]);
return (
<div
className="tooltip"
style={{ position: 'fixed', top: position.top, left: position.left }}
>
{children}
</div>
);
}
Если использовать useEffect вместо useLayoutEffect — пользователь увидит мерцание: сначала tooltip появится в неправильной позиции, затем сместится в правильную.
6. Практический пример: анимация появления
function FadeIn({ children }) {
const ref = useRef(null);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
// Начальное состояние — невидимый
element.style.opacity = '0';
element.style.transform = 'translateY(20px)';
element.style.transition = 'opacity 300ms, transform 300ms';
// Форсируем reflow, чтобы браузер зафиксировал начальное состояние
element.offsetHeight;
// Запускаем анимацию
requestAnimationFrame(() => {
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
});
}, []);
return <div ref={ref}>{children}</div>;
}
С useEffect пользователь увидит элемент уже видимым, а затем — анимацию с нуля (мерцание).
7. Практический пример: подписка на события
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
// useEffect — правильный выбор для подписок
useEffect(() => {
function handleScroll() {
setScrollY(window.scrollY);
}
window.addEventListener('scroll', handleScroll);
// Cleanup выполнится асинхронно — это нормально для подписок
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
return <div>Scroll Y: {scrollY}</div>;
}
8. Когда что использовать
useLayoutEffect:
- Измерения DOM (getBoundingClientRect, offsetHeight, scrollTop)
- Изменения DOM, видимые пользователю (предотвращение мерцания)
- Синхронная установка состояния на основе DOM
- Анимации, зависящие от размеров элементов
useEffect:
- API-запросы
- Подписки на события (WebSocket, EventSource)
- Таймеры (setInterval, setTimeout)
- Логирование, аналитика
- Работа с сторонними библиотеками
9. Важный нюанс: SSR
useLayoutEffect не работает на сервере. При SSR React выдаст предупреждение:
Warning: useLayoutEffect does nothing on the server...
Решение — использовать useEffect для SSR или динамический импорт:
import { useEffect, useLayoutEffect } from 'react';
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
// Использование
function Component() {
useIsomorphicLayoutEffect(() => {
// Работает и на клиенте, и на сервере
}, []);
}
10. Правило
По умолчанию используйте useEffect. Переключайтесь на useLayoutEffect только если видите визуальное мерцание или нужны синхронные измерения DOM. useLayoutEffect блокирует отрисовку, поэтому злоупотребление им ухудшает производительность.
Вопрос 12. Как оптимизировать тяжёлый React-компонент, который слишком часто перерисовывается из-за рендера родителя?
Таймкод: 00:16:20
Ответ собеседника: Правильный. Обернуть компонент в React.memo для поверхностного сравнения пропсов. Для функций использовать useCallback, для объектов и массивов — useMemo. Также разделить контекст на более мелкие части, чтобы уменьшить область перерисовки.
Правильный ответ:
Кандидат назвал основные инструменты оптимизации. Разберём каждый подробнее с примерами и рассмотрим дополнительные техники.
1. React.memo — мемоизация компонента
// Без мемоизации: компонент перерисовывается при каждом рендере родителя
function HeavyComponent({ data, onSelect }) {
console.log('HeavyComponent render');
return (
<div>
{data.map(item => (
<div key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</div>
))}
</div>
);
}
// С мемоизацией: перерисовывается только при изменении пропсов
const MemoizedHeavyComponent = React.memo(HeavyComponent);
// С кастомным компаратором
const MemoizedHeavyComponent = React.memo(HeavyComponent, (prevProps, nextProps) => {
// Вернуть true — НЕ перерисовывать
// Вернуть false — перерисовать
return prevProps.data.length === nextProps.data.length &&
prevProps.data.every((item, i) => item.id === nextProps.data[i].id);
});
2. useCallback — мемоизация функций
function Parent() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([]);
// Без useCallback: новая функция при каждом рендере → React.memo бесполезен
const handleSelect = (id) => {
console.log('Selected:', id);
};
// С useCallback: функция стабильна между рендерами
const handleSelect = useCallback((id) => {
console.log('Selected:', id);
}, []); // зависимости
// С зависимостью от состояния
const handleDelete = useCallback((id) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoizedHeavyComponent data={items} onSelect={handleSelect} />
</div>
);
}
3. useMemo — мемозация значений
function Parent() {
const [filter, setFilter] = useState('');
const [items, setItems] = useState([]);
// Без useMemo: новый массив при каждом рендере → React.memo бесполезен
const filteredItems = items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
// С useMemo: вычисляется только при изменении items или filter
const filteredItems = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
}, [items, filter]);
// Мемоизация объектов (например, стилей)
const containerStyle = useMemo(() => ({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '16px'
}), []);
return <MemoizedList items={filteredItems} style={containerStyle} />;
}
4. Разделение контекста
// Плохо: один большой контекст — все потребители перерисовываются при любом изменении
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
const value = useMemo(() => ({
user, setUser,
theme, setTheme,
notifications, setNotifications
}), [user, theme, notifications]);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// Хорошо: разделённые контексты
const UserContext = createContext();
const ThemeContext = createContext();
const NotificationsContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
const userValue = useMemo(() => ({ user, setUser }), [user]);
const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
const notificationsValue = useMemo(() => ({ notifications, setNotifications }), [notifications]);
return (
<UserContext.Provider value={userValue}>
<ThemeContext.Provider value={themeValue}>
<NotificationsContext.Provider value={notificationsValue}>
{children}
</NotificationsContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
// Компонент подписан только на theme — не перерисовывается при изменении user
function ThemedButton() {
const { theme } = useContext(ThemeContext);
return <button className={theme}>Click</button>;
}
5. Вынос изменяемого состояния в отдельный компонент
// Плохо: состояние в родителе вызывает ре-рендер всего дерева
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<HeavyComponent /> {/* Перерисовывается при изменении count */}
</div>
);
}
// Хорошо: изолируем изменяемое состояние
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
function Parent() {
return (
<div>
<Counter />
<HeavyComponent /> {/* Не перерисовывается */}
</div>
);
}
6. Использование children для предотвращения ре-рендера
function Layout({ sidebar, content }) {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div>
<Sidebar collapsed={isCollapsed}>
{sidebar} {/* children не перерисовывается при изменении isCollapsed */}
</Sidebar>
<main>{content}</main>
</div>
);
}
// Использование
function App() {
return (
<Layout
sidebar={<Navigation />}
content={<HeavyComponent />}
/>
);
}
7. Виртуализация для тяжёлых списков
import { FixedSizeList } from 'react-window';
function HeavyList({ items }) {
const Row = useCallback(({ index, style }) => (
<div style={style}>
<ExpensiveItem data={items[index]} />
</div>
), [items]);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={80}
width="100%"
>
{Row}
</FixedSizeList>
);
}
8. Диагностика лишних ре-рендеров
// React DevTools → Components → включить "Highlight updates when components render"
// Или добавить логирование
function Component(props) {
console.log('Render:', props);
return <div>{/* ... */}</div>;
}
// Или использовать why-did-you-render
import whyDidYouRender from '@welldone-software/why-did-you-render';
whyDidYouRender(React, {
trackAllPureComponents: true,
logOnDifferentValues: true
});
9. Чеклист оптимизации
- Профилируйте сначала (React DevTools Profiler) — не оптимизируйте вслепую
- Используйте
React.memoдля тяжёлых компонентов с стабильными пропсами - Оборачивайте функции в
useCallback, если передаёте в мемоизированные компоненты - Оборачивайте вычисления в
useMemo, если результат передаётся в мемоизированные компоненты - Разделяйте контексты по доменам ответственности
- Изолируйте изменяемое состояние в отдельных компонентах
- Используйте
childrenпроп для предотвращения ре-рендера - Применяйте виртуализацию для длинных списков
10. Важное предупреждение
Не применяйте оптимизации преждевременно. React.memo, useCallback, useMemo имеют свою стоимость (сравнение пропсов, хранение в памяти). Используйте их только когда профилирование показывает реальную проблему производительности.
Вопрос 13. Как предотвратить перерисовку всего приложения при переключении темы через Context API?
Таймкод: 00:17:18
Ответ собеседника: Правильный. Мемоизировать значение контекста или разделить контекст на более мелкие части (например, отдельно для темы, отдельно для цветовой палитры), чтобы уменьшить область перерисовки. Также можно использовать библиотеки типа Zustand или Redux.
Правильный ответ:
Кандидат верно указал основные подходы. Разберём каждый с конкретными примерами кода.
1. Проблема: один контекст на всё
// Плохой подход: все потребители перерисовываются при любом изменении
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const [fontSize, setFontSize] = useState(16);
const [language, setLanguage] = useState('ru');
// Новый объект при каждом рендере → все потребители перерисовываются
const value = { theme, setTheme, fontSize, setFontSize, language, setLanguage };
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
2. Решение 1: Разделение контекстов
// Отдельные контексты для каждой области
const ThemeContext = createContext();
const FontSizeContext = createContext();
const LanguageContext = createContext();
function AppProvider({ children }) {
const [theme, setTheme] = useState('light');
const [fontSize, setFontSize] = useState(16);
const [language, setLanguage] = useState('ru');
const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
const fontSizeValue = useMemo(() => ({ fontSize, setFontSize }), [fontSize]);
const languageValue = useMemo(() => ({ language, setLanguage }), [language]);
return (
<ThemeContext.Provider value={themeValue}>
<FontSizeContext.Provider value={fontSizeValue}>
<LanguageContext.Provider value={languageValue}>
{children}
</LanguageContext.Provider>
</FontSizeContext.Provider>
</ThemeContext.Provider>
);
}
// Компонент подписан только на тему — не перерисовывается при изменении fontSize
function ThemedButton() {
const { theme } = useContext(ThemeContext);
return <button className={`btn btn-${theme}`}>Click</button>;
}
3. Решение 2: Мемоизация потребителей через React.memo
// Компонент, который НЕ зависит от темы
const StaticHeader = React.memo(function Header() {
return <header>Логотип и навигация</header>;
});
// Компонент, который зависит от темы
function ThemedContent() {
const { theme } = useContext(ThemeContext);
return <main className={theme}>Контент</main>;
}
function App() {
return (
<ThemeProvider>
<StaticHeader /> {/* Не перерисовывается при смене темы */}
<ThemedContent /> {/* Перерисовывается */}
</ThemeProvider>
);
}
4. Решение 3: Разделение на state и dispatch
const ThemeStateContext = createContext();
const ThemeDispatchContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// Dispatch (setTheme) стабилен — не меняется между рендерами
const dispatch = useMemo(() => ({
toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
setTheme
}), []);
return (
<ThemeStateContext.Provider value={theme}>
<ThemeDispatchContext.Provider value={dispatch}>
{children}
</ThemeDispatchContext.Provider>
</ThemeStateContext.Provider>
);
}
// Компоненту нужен только dispatch — не перерисовывается при смене темы
function ThemeToggle() {
const { toggleTheme } = useContext(ThemeDispatchContext);
return <button onClick={toggleTheme}>Переключить тему</button>;
}
// Компоненту нужен только state — перерисовывается при смене темы
function ThemedCard() {
const theme = useContext(ThemeStateContext);
return <div className={`card card-${theme}`}>...</div>;
}
5. Решение 4: CSS-переменные вместо контекста
Для темизации CSS-переменные — самый эффективный подход, так как React не участвует в обновлении стилей:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// Применяем CSS-переменные напрямую к DOM
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.style.setProperty('--bg-color', '#1a1a2e');
root.style.setProperty('--text-color', '#eaeaea');
root.style.setProperty('--card-bg', '#16213e');
} else {
root.style.setProperty('--bg-color', '#ffffff');
root.style.setProperty('--text-color', '#333333');
root.style.setProperty('--card-bg', '#f5f5f5');
}
root.setAttribute('data-theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
/* CSS использует переменные — без ре-рендеров React */
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s, color 0.3s;
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
}
6. Решение 5: Zustand вместо Context
Zustand позволяет компонентам подписываться только на нужные части состояния:
import { create } from 'zustand';
const useThemeStore = create((set) => ({
theme: 'light',
fontSize: 16,
toggleTheme: () => set(state => ({
theme: state.theme === 'light' ? 'dark' : 'light'
})),
setFontSize: (size) => set({ fontSize: size })
}));
// Компонент подписан только на theme — не перерисовывается при изменении fontSize
function ThemedButton() {
const theme = useThemeStore(state => state.theme);
return <button className={theme}>Click</button>;
}
// Компонент подписан только на toggleTheme — не перерисовывается вообще
function ThemeToggle() {
const toggleTheme = useThemeStore(state => state.toggleTheme);
return <button onClick={toggleTheme}>Переключить</button>;
}
7. Решение 6: use-context-selector
Библиотека, добавляющая селекторы в Context API:
import { createContext, useContextSelector } from 'use-context-selector';
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const [fontSize, setFontSize] = useState(16);
const value = useMemo(() => ({ theme, setTheme, fontSize, setFontSize }), [theme, fontSize]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// Компонент перерисовывается ТОЛЬКО при изменении theme
function ThemedButton() {
const theme = useContextSelector(ThemeContext, state => state.theme);
return <button className={theme}>Click</button>;
}
8. Сравнение подходов
| Подход | Сложность | Гибкость | Производительность | Когда использовать |
|---|---|---|---|---|
| Разделение контекстов | Средняя | Высокая | Хорошая | Несколько независимых состояний |
| React.memo | Низкая | Средняя | Хорошая | Простые компоненты |
| State + Dispatch | Средняя | Высокая | Хорошая | Когда нужен только dispatch |
| CSS-переменные | Низкая | Низкая | Отличная | Только темизация стилей |
| Zustand | Низкая | Высокая | Отличная | Новые проекты, сложное состояние |
| use-context-selector | Низкая | Высокая | Хорошая | Нужны селекторы в контексте |
9. Рекомендация
Для темизации оптимальная комбинация:
- CSS-переменные для цветов, шрифтов, отступов — максимальная производительность без ре-рендеров
- Контекст или Zustand для логики (toggle, сохранение в localStorage)
- React.memo для тяжёлых компонентов, не зависящих от темы
Вопрос 14. В чём разница между server components и client components в React 18+?
Таймкод: 00:18:00
Ответ собеседника: Правильный. Server components выполняются на сервере, не включают JS в бандл, имеют доступ к серверным ресурсам (БД), но не могут использовать хуки. Client components выполняются в браузере, поддерживают интерактивность и хуки.
Правильный ответ:
Кандидат верно описал основные различия. Раскроем тему глубже с примерами и архитектурными нюансами.
1. Определения
Server Components (RSC — React Server Components):
- Выполняются только на сервере
- Результат сериализуется и отправляется клиенту в специальном формате (RSC Payload)
- JavaScript-код компонента НЕ попадает в клиентский бандл
- Могут быть асинхронными (async/await)
Client Components:
- Выполняются в браузере (и могут рендериться на сервере при SSR)
- JavaScript-код включается в бандл
- Поддерживают интерактивность: хуки, обработчики событий, состояние
- Объявляются директивой
"use client"
2. Сравнительная таблица
| Характеристика | Server Component | Client Component |
|---|---|---|
| Где выполняется | Сервер | Браузер (+ сервер при SSR) |
| JS в бандле | Нет | Да |
| async/await | Да | Нет |
| useState, useEffect | Нет | Да |
| onClick, onChange | Нет | Да |
| Доступ к БД | Да (напрямую) | Нет (только через API) |
| Доступ к файловой системе | Да | Нет |
| Размер бандла | 0 байт | Полный размер компонента |
3. Примеры кода
Server Component (по умолчанию в Next.js App Router):
// app/users/page.tsx — Server Component по умолчанию
import { db } from '@/lib/db';
// Может быть async!
export default async function UsersPage() {
// Прямой доступ к базе данных — без API-слоя
const users = await db.user.findMany({
where: { active: true },
include: { posts: true }
});
// Доступ к файловой системе
const config = await fs.readFile('./config.json', 'utf-8');
return (
<div>
<h1>Пользователи ({users.length})</h1>
<UserList users={users} />
{/* НЕЛЬЗЯ: onClick, useState, useEffect */}
</div>
);
}
Client Component:
// components/UserSearch.tsx
"use use client"; // директива обязательна
import { useState, useEffect } from 'react';
export function UserSearch({ users }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(users);
useEffect(() => {
setFiltered(
users.filter(u => u.name.toLowerCase().includes(query.toLowerCase()))
);
}, [query, users]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Поиск..."
/>
{filtered.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
4. Композиция: вложенность компонентов
// Server Component может содержать Client Components
// app/page.tsx (Server)
import { InteractiveChart } from './InteractiveChart'; // Client Component
import { getData } from './getData';
export default async function Dashboard() {
const data = await getData(); // серверный запрос
return (
<div>
<h1>Дашборд</h1>
{/* Server Component передаёт данные в Client Component */}
<InteractiveChart data={data} />
</div>
);
}
// Client Component НЕ может импортировать Server Component
// Но может получать его как children или props
// Client Component с Server Component как children
"use client";
import { useState } from 'react';
function ClientWrapper({ children }) {
const [collapsed, setCollapsed] = useState(false);
return (
<div>
<button onClick={() => setCollapsed(!collapsed)}>
{collapsed ? 'Показать' : 'Скрыть'}
</button>
{!collapsed && children} {/* Server Component как children */}
</div>
);
}
// Использование
function Page() {
return (
<ClientWrapper>
<ServerComponent /> {/* Работает! */}
</ClientWrapper>
);
}
5. Паттерн: Server Component для данных, Client для интерактивности
// app/products/page.tsx (Server)
import { ProductCard } from './ProductCard'; // Client
import { db } from '@/lib/db';
export default async function ProductsPage({ searchParams }) {
const category = searchParams.category;
const products = await db.product.findMany({
where: category ? { category } : {},
orderBy: { createdAt: 'desc' }
});
return (
<div>
<h1>Товары</h1>
<CategoryFilter /> {/* Client Component для фильтрации */}
<div className="grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
// components/ProductCard.tsx (Client)
"use client";
import { useState } from 'react';
export function ProductCard({ product }) {
const [liked, setLiked] = useState(false);
return (
<div className="card">
<h3>{product.name}</h3>
<p>{product.price} ₽</p>
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
</div>
);
}
6. Что можно и нельзя
Server Components МОГУТ:
- Быть async и использовать await
- Обращаться к БД, файловой системе, внутренним API
- Импортировать другие Server Components
- Импортировать Client Components
- Использовать серверные библиотеки (bcrypt, sharp и т.д.)
Server Components НЕ МОГУТ:
- Использовать хуки (useState, useEffect, useContext и т.д.)
- Использовать обработчики событий (onClick, onChange)
- Использовать браузерные API (localStorage, window)
- Использовать контекст React (только через специальные решения)
7. Преимущества Server Components
- Меньший бандл: серверный код не отправляется клиенту
- Прямой доступ к данным: без промежуточного API-слоя
- Безопасность: секреты (API-ключи, строки подключения) остаются на сервере
- Автоматическое разделение кода: неиспользуемые компоненты не попадают в бандл
- Улучшенный FCP: пользователь видит HTML быстрее
8. Когда что использовать
Server Components:
- Отображение данных из БД
- Тяжёлые вычисления
- Статический контент
- Страницы с большим количеством текста
Client Components:
- Формы с валидацией
- Интерактивные элементы (модалки, дропдауны)
- Анимации
- Подписки на WebSocket
- Использование браузерных API
Вопрос 15. Как избежать обновления state размонтированного компонента при быстром уходе пользователя со страницы?
Таймкод: 00:18:53
Ответ собеседника: Правильный. React выдаст предупреждение при попытке обновить state размонтированного компонента. Можно использовать AbortController для отмены запроса или флаг (isMounted), который устанавливается в false при размонтировании, чтобы проверять перед обновлением state.
Правильный ответ:
Кандидат назвал оба основных подхода. Рассмотрим каждый с примерами и обсудим нюансы.
1. AbortController — рекомендуемый подход
AbortController не только предотвращает обновление state, но и реально отменяет HTTP-запрос, освобождая сетевые ресурсы.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function fetchUser() {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal
});
const data = await response.json();
setUser(data);
} catch (err) {
// Важно: не обновляем state при отмене
if (err.name === 'AbortError') {
console.log('Запрос отменён');
return;
}
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUser();
// Cleanup: отменяем запрос при размонтировании или изменении userId
return () => controller.abort();
}, [userId]);
if (loading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error}</div>;
return <div>{user?.name}</div>;
}
2. Флаг isMounted — устаревший подход
Этот подход был популярен до широкого распространения AbortController. Он не отменяет запрос, а только предотвращает обновление state.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let isMounted = true; // флаг
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Обновляем state только если компонент ещё смонтирован
if (isMounted) {
setUser(data);
}
} catch (err) {
if (isMounted) {
console.error(err);
}
}
}
fetchUser();
return () => {
isMounted = false; // сбрасываем флаг при размонтировании
};
}, [userId]);
return <div>{user?.name}</div>;
}
Недостаток isMounted: HTTP-запрос продолжает выполняться в фоне, потребляя трафик и ресурсы. AbortController реально прерывает запрос.
3. Кастомный хук useSafeAsync
import { useEffect, useRef, useState, useCallback } from 'react';
function useSafeAsync() {
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const safeSetState = useCallback((setter) => {
if (mountedRef.current) {
setter();
}
}, []);
return safeSetState;
}
// Использование
function Component() {
const [data, setData] = useState(null);
const safeSetData = useSafeAsync();
useEffect(() => {
fetch('/api/data')
.then(r => r.json())
.then(result => safeSetData(() => setData(result)));
}, []);
return <div>{data}</div>;
}
4. Кастомный хук useFetch с AbortController
import { useState, useEffect, useRef } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const controllerRef = useRef(null);
useEffect(() => {
controllerRef.current = new AbortController();
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
...options,
signal: controllerRef.current.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchData();
return () => controllerRef.current?.abort();
}, [url]);
const refetch = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = new AbortController();
// ...повторный запрос
}, [url]);
return { data, loading, error, refetch };
}
// Использование
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error}</div>;
return <div>{user?.name}</div>;
}
5. React Query / TanStack Query — автоматическая отмена
import { useQuery, useQueryClient } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/users/${userId}`, { signal });
return response.json();
},
// React Query автоматически отменяет запрос при размонтировании
});
if (isLoading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
return <div>{user?.name}</div>;
}
React Query автоматически связывает AbortController с запросом и отменяет его при размонтировании компонента или инвалидации кэша.
6. Предупреждение React в React 18+
Начиная с React 18, предупреждение "Can't perform a React state update on an unmounted component" было удалено. Это не значит, что проблема исчезла — просто React больше не считает это ошибкой. Однако:
- Утечка памяти всё ещё возможна (замыкания удерживаются)
- Лишние сетевые запросы продолжают выполняться
- Код становится менее предсказуемым
Поэтому использование AbortController по-прежнему рекомендуется.
7. Сравнение подходов
| Подход | Отменяет запрос | Предотвращает setState | Сложность | Рекомендация |
|---|---|---|---|---|
| AbortController | Да | Да | Низкая | ✅ Рекомендуется |
| isMounted флаг | Нет | Да | Низкая | ⚠️ Устаревший |
| React Query | Да | Да | Низкая | ✅ Для сложных приложений |
| Игнорировать | Нет | Нет | Нет | ❌ Не рекомендуется |
8. Итоговые рекомендации
- Всегда используйте cleanup функцию в useEffect для отмены асинхронных операций
- AbortController — предпочтительный способ для fetch-запросов
- React Query / TanStack Query — если приложение активно работает с серверными данными
- Не полагайтесь на отсутствие предупреждений в React 18 — утечки всё ещё возможны
Вопрос 16. Реализовать кастомный хук useLocalStorage, который синхронизирует state с localStorage.
Таймкод: 00:19:54
Ответ собеседника: Правильный. Хук useLocalStorage принимает key и initialValue. Использует useState с ленивой инициализацией: при первом рендере читает значение из localStorage.getItem(key) и парсит JSON, иначе использует initialValue. useEffect синхронизирует значение с localStorage через setItem при изменении value или key. Возвращает [value, setValue].
Правильный ответ:
Кандидат верно описал механизм. Приведём полную реализацию с обработкой ошибок и дополнительными функциями.
1. Базовая реализация
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Ленивая инициализация: читаем из localStorage только при первом рендере
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Синхронизация с localStorage при изменении
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error(`Error writing localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
// Использование
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Текущая тема: {theme}
</button>
);
}
2. Расширенная реализация с функциональным обновлением
import { useState, useEffect, useCallback, useRef } from 'react';
function useLocalStorage(key, initialValue) {
const keyRef = useRef(key);
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Обёртка setValue, поддерживающая функциональные обновления
const setValue = useCallback((value) => {
try {
setStoredValue(prev => {
const valueToStore = value instanceof Function ? value(prev) : value;
localStorage.setItem(keyRef.current, JSON.stringify(valueToStore));
return valueToStore;
});
} catch (error) {
console.error(`Error writing localStorage key "${key}":`, error);
}
}, []);
// Обработка изменения ключа
useEffect(() => {
if (keyRef.current !== key) {
keyRef.current = key;
try {
const item = localStorage.getItem(key);
setStoredValue(item ? JSON.parse(item) : initialValue);
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
setStoredValue(initialValue);
}
}
}, [key, initialValue]);
// Синхронизация с localStorage
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error(`Error writing localStorage key "${key}":`, error);
}
}, [key, storedValue]);
// Удаление значения
const removeValue = useCallback(() => {
try {
localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.error(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
// Использование
function Counter() {
const [count, setCount, removeCount] = useLocalStorage('count', 0);
return (
<div>
<p>Счётчик: {count}</p>
<button onClick={() => setCount(prev => prev + 1)}>+1</button>
<button onClick={() => setCount(prev => prev - 1)}>-1</button>
<button onClick={removeCount}>Сбросить</button>
</div>
);
}
3. Реализация с синхронизацией между вкладками
import { useState, useEffect, useCallback } from 'react';
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
// Синхронизация с localStorage
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error(error);
}
}, [key, storedValue]);
// Слушаем изменения из других вкладок
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === key && event.newValue !== null) {
try {
setStoredValue(JSON.parse(event.newValue));
} catch {
// Игнорируем невалидный JSON
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
const setValue = useCallback((value) => {
setStoredValue(prev => {
const valueToStore = value instanceof Function ? value(prev) : value;
return valueToStore;
});
}, []);
return [storedValue, setValue];
}
// Использование: изменение в одной вкладке отразится в другой
function UserSettings() {
const [username, setUsername] = useLocalStorage('username', '');
return (
<input
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Имя пользователя"
/>
);
}
4. Реализация с TTL (временем жизни)
import { useState, useCallback } from 'react';
function useLocalStorageWithTTL(key, initialValue, ttlMs = null) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
if (!item) return initialValue;
const { value, expiresAt } = JSON.parse(item);
// Проверяем TTL
if (ttlMs && expiresAt && Date.now() > expiresAt) {
localStorage.removeItem(key);
return initialValue;
}
return value;
} catch {
return initialValue;
}
});
const setValue = useCallback((value) => {
setStoredValue(prev => {
const valueToStore = value instanceof Function ? value(prev) : value;
const expiresAt = ttlMs ? Date.now() + ttlMs : null;
try {
localStorage.setItem(key, JSON.stringify({
value: valueToStore,
expiresAt
}));
} catch (error) {
console.error(error);
}
return valueToStore;
});
}, [key, ttlMs]);
return [storedValue, setValue];
}
// Использование: кэш истекает через 5 минут
function CachedData() {
const [data, setData] = useLocalStorageWithTTL('api-cache', null, 5 * 60 * 1000);
useEffect(() => {
if (!data) {
fetch('/api/data')
.then(r => r.json())
.then(setData);
}
}, []);
return <div>{data ? 'Загружено' : 'Загрузка...'}</div>;
}
5. Обработка ошибок и edge cases
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
// SSR: localStorage недоступен
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = localStorage.getItem(key);
// Ключ не найден
if (item === null) {
return initialValue;
}
return JSON.parse(item);
} catch (error) {
// Невалидный JSON или localStorage заполнен
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = useCallback((value) => {
if (typeof window === 'undefined') {
console.warn('localStorage is not available');
return;
}
try {
setStoredValue(prev => {
const valueToStore = value instanceof Function ? value(prev) : value;
localStorage.setItem(key, JSON.stringify(valueToStore));
return valueToStore;
});
} catch (error) {
// localStorage заполнен (обычно 5MB лимит)
if (error.name === 'QuotaExceededError') {
console.error('localStorage quota exceeded');
} else {
console.error(`Error writing localStorage key "${key}":`, error);
}
}
}, [key]);
return [storedValue, setValue];
}
6. Использование с TypeScript
import { useState, useCallback } from 'react';
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value: T | ((prev: T) => T)) => {
setStoredValue(prev => {
const valueToStore = value instanceof Function ? value(prev) : value;
localStorage.setItem(key, JSON.stringify(valueToStore));
return valueToStore;
});
}, [key]);
return [storedValue, setValue];
}
// Использование с типизацией
interface UserPreferences {
theme: 'light' | 'dark';
fontSize: number;
language: string;
}
function Settings() {
const [prefs, setPrefs] = useLocalStorage<UserPreferences>('preferences', {
theme: 'light',
fontSize: 16,
language: 'ru'
});
return (
<div>
<select
value={prefs.theme}
onChange={e => setPrefs(prev => ({ ...prev, theme: e.target.value as 'light' | 'dark' }))}
>
<option value="light">Светлая</option>
<option value="dark">Тёмная</option>
</select>
</div>
);
}
7. Важные соображения
- Ленивая инициализация: используйте
useState(() => ...)вместоuseState(localStorage.getItem(...))— чтение из localStorage при каждом рендере замедляет работу - Сериализация: localStorage хранит только строки — используйте JSON.stringify/parse
- SSR: localStorage недоступен на сервере — проверяйте
typeof window !== 'undefined' - Лимит: обычно 5MB на домен — обрабатывайте QuotaExceededError
- Синхронизация: событие
storageсрабатывает только в других вкладках, не в текущей
Вопрос 17. Как отладить медленную начальную загрузку React-дашборда при нормальном размере бандла?
Таймкод: 00:23:48
Ответ собеседника: Правильный. Проверить: блокируют ли API-запросы рендеринг, нет ли бесконечных циклов рендера, проанализировать большие зависимости через bundle analyzer, проверить code splitting, являются ли API-запросы последовательными или параллельными, используются ли dynamic imports и Suspense для lazy loading тяжёлых компонентов. Также использовать React Profiler для измерения времени рендеринга и выявления тяжёлых компонентов.
Правильный ответ:
Кандидат перечислил основные направления отладки. Систематизируем подход и добавим конкретные инструменты.
1. Профилирование: определение узкого места
Прежде чем оптимизировать — нужно точно определить, где проблема.
React DevTools Profiler:
1. Открыть React DevTools → Profiler
2. Нажать запись (Record)
3. Выполнить начальную загрузку дашборда
4. Остановить запись
5. Анализировать:
- Flamegraph: какие компоненты рендерятся дольше всего
- Ranked: компоненты, отсортированные по времени рендера
- Причины рендера: почему компонент перерисовался
Chrome DevTools Performance:
1. F12 → Performance → запись
2. Перезагрузить страницу
3. Анализировать:
- Network: время загрузки ресурсов
- Main thread: длительные задачи (Long Tasks > 50ms)
- Timings: FCP, LCP, TTI
Web Vitals:
import { onFCP, onLCP, onTTFB, onCLS, onINP } from 'web-vitals';
onFCP(console.log); // First Contentful Paint
onLCP(console.log); // Largest Contentful Paint
onTTFB(console.log); // Time to First Byte
onCLS(console.log); // Cumulative Layout Shift
onINP(console.log); // Interaction to Next Paint
2. Анализ сетевых запросов
Водопад запросов (Network Waterfall):
Проблема: последовательные запросы
├── /api/user 200ms
│ └── /api/dashboard 300ms (ждёт завершения user)
│ └── /api/widgets 250ms (ждёт завершения dashboard)
└── Итого: 750ms
Решение: параллельные запросы
├── /api/user 200ms
├── /api/dashboard 300ms
├── /api/widgets 250ms
└── Итого: 300ms (максимум из всех)
// Плохо: последовательные запросы
async function loadDashboard() {
const user = await fetch('/api/user'); // 200ms
const dashboard = await fetch('/api/dashboard'); // 300ms
const widgets = await fetch('/api/widgets'); // 250ms
// Итого: 750ms
}
// Хорошо: параллельные запросы
async function loadDashboard() {
const [user, dashboard, widgets] = await Promise.all([
fetch('/api/user'), // 200ms
fetch('/api/dashboard'), // 300ms
fetch('/api/widgets') // 250ms
]);
// Итого: 300ms
}
3. Анализ рендеринга
Блокирующие рендеры:
// Проблема: рендер ждёт данные
function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData); // Рендер заблокирован до получения данных
}, []);
if (!data) return <Loading />; // Пользователь видит лоадер
return <HeavyDashboard data={data} />;
}
// Решение: Suspense + lazy loading
const HeavyDashboard = lazy(() => import('./HeavyDashboard'));
function Dashboard() {
return (
<Suspense fallback={<Skeleton />}>
<HeavyDashboard />
</Suspense>
);
}
Бесконечные циклы рендера:
// Проблема: бесконечный цикл
function Dashboard() {
const [filters, setFilters] = useState({});
// Новый объект при каждом рендере → useEffect срабатывает → setState → новый рендер
useEffect(() => {
fetchData(filters).then(setData);
}, [filters]); // filters — новый объект каждый раз!
return <div>{/* ... */}</div>;
}
// Решение: стабильная зависимость
const filters = useMemo(() => ({ status: 'active' }), []);
useEffect(() => {
fetchData(filters).then(setData);
}, [filters]);
4. Code Splitting и Lazy Loading
// Плохо: всё загружается сразу
import HeavyChart from './HeavyChart';
import DataTable from './DataTable';
import MapView from './MapView';
// Хорошо: lazy loading тяжёлых компонентов
import { lazy, Suspense } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));
const MapView = lazy(() => import('./MapView'));
function Dashboard() {
return (
<div>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
<Suspense fallback={<MapSkeleton />}>
<MapView />
</Suspense>
</div>
);
}
// Предзагрузка при наведении мыши
function PreloadOnHover() {
const preloadChart = () => {
import('./HeavyChart');
};
return <button onMouseEnter={preloadChart}>Открыть график</button>;
}
5. Анализ зависимостей
# Webpack Bundle Analyzer
npx source-map-explorer 'build/static/js/*.js'
# Или с помощью vite-plugin-visualizer
npx vite-bundle-analyzer
Типичные проблемы:
- Большие библиотеки (moment.js → date-fns, lodash → lodash-es)
- Дублирование зависимостей
- Импорт всей библиотеки вместо конкретных функций
// Плохо: импорт всей библиотеки
import _ from 'lodash';
_.debounce(fn, 300);
// Хорошо: импорт конкретной функции
import debounce from 'lodash/debounce';
debounce(fn, 300);
// Или замена на более лёгкую альтернативу
import { debounce } from 'date-fns';
6. Оптимизация первого рендера
Приоритизация видимого контента (Above the Fold):
function Dashboard() {
return (
<div>
{/* Критический контент — загружается сразу */}
<Header />
<KPIRow />
{/* Некритический контент — отложенная загрузка */}
<Suspense fallback={<Skeleton />}>
<DeferredCharts />
</Suspense>
</div>
);
}
// Компонент с отложенной загрузкой
function DeferredCharts() {
const [visible, setVisible] = useState(false);
useEffect(() => {
// Загружаем после первого рендера
const timer = requestIdleCallback(() => setVisible(true));
return () => cancelIdleCallback(timer);
}, []);
if (!visible) return <Skeleton />;
return <HeavyCharts />;
}
7. Мемоизация тяжёлых вычислений
function Dashboard({ rawData }) {
// Без мемоизации: вычисляется при каждом рендере
const processedData = rawData
.filter(d => d.status === 'active')
.map(d => ({ ...d, formatted: formatDate(d.date) }))
.sort((a, b) => b.value - a.value);
// С мемоизацией: вычисляется только при изменении rawData
const processedData = useMemo(() => {
return rawData
.filter(d => d.status === 'active')
.map(d => ({ ...d, formatted: formatDate(d.date) }))
.sort((a, b) => b.value - a.value);
}, [rawData]);
return <DataTable data={processedData} />;
}
8. Чеклист отладки
| Шаг | Инструмент | Что искать |
|---|---|---|
| 1. Метрики | Lighthouse, Web Vitals | FCP, LCP, TTI, TBT |
| 2. Сеть | Network tab | Водопад запросов, размеры, кеширование |
| 3. Рендеринг | React Profiler | Долгие рендеры, лишние ре-рендеры |
| 4. Бандл | Bundle Analyzer | Большие зависимости, дублирование |
| 5. Main thread | Performance tab | Long Tasks > 50ms |
| 6. Память | Memory tab | Утечки памяти |
9. Типичные причины медленной загрузки при нормальном бандле
- Последовательные API-запросы вместо параллельных
- Блокирующий рендер: компонент ждёт данные перед отрисовкой
- Тяжёлые вычисления при первом рендере без мемоизации
- Отсутствие code splitting: весь код загружается сразу
- Большие сторонние библиотеки, загружаемые синхронно
- Отсутствие кеширования на уровне HTTP или Service Worker
- Медленный TTFB (Time to First Byte) — проблема сервера, не клиента
