Перейти к основному содержимому

Senior Frontend Interview 2026 🎉 | Javascript 🎯 (Mock) [Most Asked Questions]

· 59 мин. чтения

Сегодня мы разберём собеседование на позицию React-разработчика, в ходе которого кандидат демонстрировал знания по производительности веб-приложений, браузерному рендерингу, ключевым концепциям JavaScript и React, а также решал практическую задачу по созданию секундомера. Интервью охватывало как теоретические аспекты — такие как SSR, замыкания, виртуализация списков и жизненный цикл компонентов, — так и навыки практического кодирования, где кандидат использовал useRef и useState для управления состоянием и интервалами. Несмотря на некоторые затруднения с обработкой ошибок и тонкостями работы с хуками, кандидат показал уверенное понимание основных принципов современной фронтенд-разработки.

Вопрос 1. Какие методы и подходы вы используете для улучшения производительности Go-приложений?

Таймкод: 00:00:04

Ответ собеседника: Неправильный. Ответ охватывает несколько аспектов: устранение лишних ре-рендеров с помощью профилирования, использование мемоизации (useMemo, useCallback), оптимизацию структуры состояния и вынос в глобальное состояние (Redux/Zustand), динамические импорты для тяжёлых компонентов с React Suspense, оптимизацию размера бандла, tree shaking и gzip-сжатие.

Правильный ответ:

Описанные в ответе техники относятся к оптимизации фронтенд-приложений на React и не имеют отношения к производительности Go-сервисов. Вот ключевые подходы для оптимизации Go-приложений:

1. Профилирование и бенчмаркинга

Первый шаг — измерение, а не угадывание. Go предоставляет мощные инструменты из коробки:

  • pprof — профилирование CPU, памяти, блокировок, горутин
  • testing.B — написание бенчмарков для критичных участков кода
  • trace — трассировка выполнения для анализа конкурентности
import _ "net/http/pprof"

func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// ... основная логика
}

После запуска можно анализировать через go tool pprof и go tool trace.

2. Оптимизация работы с памятью и аллокациями

Go — язык с управляемой памятью, но неаккуратная работа с аллокациями создаёт нагрузку на GC:

  • sync.Pool для переиспользования объектов в горячих циклах
  • Предварительное выделение ёмкости слайсов и мап (make([]T, 0, capacity))
  • Избегание лишних аллокаций в горячих путях (escape analysis)
  • Использование strings.Builder вместо конкатенации строк
  • Работа с []byte вместо string где возможно
// Плохо — аллокация на каждой итерации
var s string
for _, part := range parts {
s += part
}

// Хорошо — минимальное число аллокаций
var b strings.Builder
b.Grow(estimatedSize)
for _, part := range parts {
b.WriteString(part)
}
result := b.String()

3. Конкурентность и параллелизм

Go создан для конкурентности, но неправильное использование горутин ухудшает производительность:

  • Worker pools для ограничения числа горутин при обработке задач
  • Fan-out / fan-in паттерны для распараллеливания и сбора результатов
  • Использование errgroup для управления группами горутин
  • Контроль размера каналов (буфер vs небуфер) в зависимости от паттерна
  • sync.Map для кэшей с высокой конкурентной нагрузкой на чтение
func processItems(items []Item, workers int) []Result {
input := make(chan Item, workers)
output := make(chan Result, workers)

var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range input {
output <- process(item)
}
}()
}

go func() {
for _, item := range items {
input <- item
}
close(input)
wg.Wait()
close(output)
}()

var results []Result
for r := range output {
results = append(results, r)
}
return results
}

4. Оптимизация работы с сетью и I/O

  • Connection pooling для HTTP-клиентов и соединений с базами данных
  • Настройка http.Transport: MaxIdleConns, MaxIdleConnsPerHost, IdleConnTimeout
  • Использование bufio для буферизованного чтения/записи
  • Потоковая обработка данных вместо загрузки всего в память
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
}
client := &http.Client{Transport: transport}

5. Оптимизация работы с базой данных

  • Индексы на часто используемых полях в WHERE, JOIN, ORDER BY
  • Пакетные операции (INSERT ... VALUES (...), (...), ...) вместо поштучных
  • Использование Prepare для часто выполняемых запросов
  • Пагинация вместо выборки всех записей
  • Кэширование результатов (Redis, in-memory cache)
  • Оптимизация самих запросов через EXPLAIN ANALYZE

6. Кэширование

  • In-memory кэш (LRU, TTL-based) для горячих данных
  • Распределённый кэш (Redis, Memcached) для масштабируемых систем
  • HTTP-кэширование через заголовки Cache-Control, ETag
  • Кэширование на уровне приложения с инвалидацией по событиям

7. Оптимизация бинарника и сборки

  • Сборка с -ldflags="-s -w" для уменьшения размера бинарника
  • Использование upx для дополнительного сжатия (с учётом компромиссов)
  • Статическая линковка для контейнеров (CGO_ENABLED=0)

8. Мониторинг и постоянное измерение

  • Экспорт метрик через Prometheus client
  • Трейсинг через OpenTelemetry / Jaeger
  • Алерты на деградацию latency, error rate, throughput
  • Сравнение метрик до и после оптимизаций

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

Вопрос 2. Что такое tree shaking и как он работает?

Таймкод: 00:02:21

Ответ собеседника: Правильный. Tree shaking — это процесс удаления неиспользуемых импортов и библиотек из бандла, что уменьшает его размер и улучшает производительность.

Правильный ответ:

Tree shaking — это техника исключения «мёртвого» (неиспользуемого) кода из итогового бандла при сборке приложения. Термин пришёл из мира JavaScript и фронтенд-сборщиков, но концепция имеет прямые аналоги и в экосистеме Go.

1. Как работает tree shaking в JavaScript/TypeScript

В экосистеме JS tree shaking выполняется сборщиками (Webpack, Rollup, esbuild) и опирается на модульную систему ES Modules:

  • Статический анализ импортов/экспортов. ESM-модули имеют статическую структуру — импорты и экспорты определяются на этапе парсинга, а не выполнения. Это позволяет сборщику построить граф зависимостей и определить, какие экспорты реально используются.
  • Пометка неиспользуемого кода. Сборщик начинает от точки входа (entry point) и рекурсивно обходит граф импортов, помещая только достижимый код. Всё, что не достижимо — отбрасывается.
  • Dead code elimination. Дополнительно удаляются ветки кода, которые никогда не выполняются (например, if (false) { ... } или код после return).
// utils.js
export function usedFunction() { return 1; }
export function unusedFunction() { return 2; } // будет удалена

// main.js
import { usedFunction } from './utils';
console.log(usedFunction()); // unusedFunction не попадёт в бандл

Ограничения tree shaking в JS:

  • Не работает с CommonJS (require/module.exports) из-за динамической природы
  • Побочные эффекты (side effects) мешают — модули с "sideEffects": true в package.json сохраняются целиком
  • Динамические импорты (import(variable)) не анализируются статически

2. Аналог tree shaking в Go

В Go нет прямого аналога фронтенд-сборщика, но компилятор и линкер выполняют схожую работу:

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

  • Если пакет импортирован, но ни один из его экспортируемых символов не используется — компилятор выдаёт ошибку (это особенность Go, а не баг)
  • Если пакет импортирован и используется только часть его функций — компилятор включает весь пакет, но не весь модуль
  • Линкер (начиная с Go 1.12+) выполняет Dead Code Elimination — удаляет неиспользуемые методы, функции и типы из итогового бинарника
// Если импортируете пакет, но не используете его — ошибка компиляции
import "fmt" // unused import — ошибка

// Если используете только часть пакета — линкер удалит неиспользуемые методы
import "net/http"
// Используете только http.Get — неиспользуемые методы пакета будут вырезаны линкером

Оптимизация размера бинарника Go (аналог tree shaking):

# Удаление отладочной информации и таблицы символов
go build -ldflags="-s -w" -o app

# Отключение CGO для статической линковки
CGO_ENABLED=0 go build -ldflags="-s -w" -o app

# Дополнительное сжатие через upx (с увеличением времени старта)
upx --best app

3. Практические рекомендации

Для фронтенд-приложений:

  • Используйте ESM-модули вместо CommonJS
  • Настраивайте "sideEffects": false в package.json для библиотек без побочных эффектов
  • Импортируйте только нужные функции: import { map } from 'lodash-es' вместо import _ from 'lodash'
  • Используйте анализаторы бандла (webpack-bundle-analyzer) для поиска тяжёлых зависимостей

Для Go-приложений:

  • Избегайте ненужных импортов — компилятор сам подскажет
  • Разбивайте большие пакеты на мелкие — линкер эффективнее удаляет неиспользуемый код
  • Используйте -ldflags="-s -w" в production-сборках
  • Профилируйте размер бинарника с помощью go tool nm и go tool objdump

Tree shaking — важная часть оптимизации, но он работает в связке с другими техниками: code splitting, lazy loading, минификация, сжатие.

Вопрос 3. Что такое reconciliation в React и как он работает?

Таймкод: 00:02:57

Ответ собеседника: Правильный. Reconciliation — это процесс, при котором React сравнивает новый virtual DOM с предыдущим с помощью diffing-алгоритма и обновляет только изменившиеся части реального DOM.

Правильный ответ:

Reconciliation — это алгоритм, посредством которого React определяет, какие части реального DOM нужно обновить при изменении состояния или пропсов компонента. Это ключевой механизм, обеспечивающий декларативность React: разработчик описывает, как UI должен выглядеть при определённом состоянии, а React сам вычисляет минимальный набор изменений.

1. Общий процесс reconciliation

Когда состояние компонента меняется, React выполняет следующие шаги:

  • Рендер нового виртуального DOM. React вызывает функцию компонента (или метод render классового компонента) и получает новое дерево React-элементов (виртуальный DOM).
  • Diffing — сравнение деревьев. React сравнивает новое дерево с предыдущим, используя эвристический O(n) алгоритм.
  • Commit — применение изменений. React применяет вычисленные изменения к реальному DOM за один проход.

2. Принципы diffing-алгоритма

React использует два ключевых допущения для достижения O(n) сложности:

А. Разные типы элементов порождают разные деревья

Если корневой элемент изменил тип, React уничтожает старое дерево полностью и строит новое с нуля:

// Старое дерево
<div>
<Counter />
</div>

// Новое дерево — тип корня изменился
<span>
<Counter />
</div>

// React: уничтожает div и всё внутри, создаёт span с нуля

Б. Ключи (keys) помогают идентифицировать стабильные элементы

При рендере списков React использует key для сопоставления элементов между рендерами:

// Без ключей — React не может определить, какой элемент куда переместился
// и будет обновлять каждый элемент по порядку (неэффективно)

// С ключами — React понимает перемещения, вставки и удаления
const items = data.map(item => (
<li key={item.id}>{item.name}</li>
));

3. Сравнение элементов одного типа

Когда тип элемента совпадает, React:

  • Обновляет атрибуты. Сравнивает старые и новые props, обновляет только изменившиеся атрибуты DOM-элемента.
  • Рекурсивно обходит потомков. Вызывает render дочерних компонентов и повторяет процесс.
// Было
<div className="old" title="title" />

// Стало
<div className="new" title="title" />

// React обновит только className, title не тронет

4. Сравнение списков потомков

Для списков React использует алгоритм, оптимизированный для типичных сценариев:

  • При совпадении ключей — перемещает элемент или обновляет его props
  • При новом ключе — создаёт новый элемент
  • При отсутствующем ключе в новом дереве — удаляет элемент

Проблема с индексами как ключами:

// Плохо — при вставке в начало все элементы перерендерятся
{items.map((item, index) => (
<Item key={index} data={item} />
))}

// Хорошо — стабильные идентификаторы
{items.map(item => (
<Item key={item.id} data={item} />
))}

5. Fiber архитектуетура (React 16+)

Начиная с React 16, reconciliation реализован через Fiber — внутреннюю структуру данных, позволяющую:

  • Инкрементальный рендеринг. Работа разбивается на чанки, что позволяет прерывать рендеринг для обработки более приоритетных задач (ввод пользователя, анимации).
  • Приоритизация обновлений. Срочные обновления (клики, ввод) обрабатываются раньше, чем фоновые (загрузка данных).
  • Возможность отката. Если рендеринг прерван, можно отбросить незавершённую работу и начать заново.

6. Оптимимизация reconciliation

Разработчик может влиять на эффективность reconciliation:

  • React.memo — предотвращает рендер компонента, если props не изменились (shallow comparison)
  • useMemo — мемоизирует вычисляемые значения между рендерами
  • useCallback — мемоизирует функции, предотвращая лишние рендеры дочерних компонентов
  • Стабильные ключи в списках
  • Избегание создания новых объектов/функций в render — они ломают мемоизацию
const MemoizedComponent = React.memo(function MyComponent({ data }) {
return <div>{data.name}</div>;
});

function Parent() {
const [count, setCount] = useState(0);
const data = useMemo(() => ({ name: 'stable' }), []);
const handler = useCallback(() => {}, []);

return <MemoizedComponent data={data} onClick={handler} />;
}

Reconciliation — это то, что делает React эффективным «из коробки», но понимание его работы позволяет избежать типичных проблем производительности и писать более оптимальный код.

Вопрос 4. Как браузер рендерит веб-страницу? Опишите процесс от получения HTML до отрисовки пикселей на экране.

Таймкод: 00:03:46

Ответ собеседника: Неполный. Браузер парсит HTML и CSS, строит DOM и CSSOM, формирует layout, выполняет paint, затем отправляет данные на GPU для отрисовки пикселей. Весь процесс называется critical rendering path.

Правильный ответ:

Рендеринг веб-страницы — это многоэтапный процесс, который браузер выполняет для преобразования HTML, CSS и JavaScript в видимые пиксели на экране. Этот процесс называется Critical Rendering Path (CRP) и состоит из нескольких последовательных этапов.

1. Парсинг HTML и построение DOM (Document Object Model)

Браузер получает HTML-документ и начинает его парсить посимвольно:

  • Токенизация: разбиение HTML на токены (открывающие/закрывающие теги, атрибуты, текст)
  • Построение дерева: токены преобразуются в узлы DOM-дерева
  • DOM — это древовидная структура, где каждый элемент HTML представлен узлом
<html>
<head><title>Page</title></head>
<body><div class="content"><p>Hello</p></div></body>
</html>

Парсинг HTML является блокирующим для рендеринга — браузер не начнёт отрисовку, пока не построит DOM.

2. Парсинг CSS и построение CSSOM (CSS Object Model)

Параллельно с HTML браузер парсит CSS:

  • Внешние файлы CSS (<link rel="stylesheet">) загружаются и парсятся
  • Инлайн-стили (<style>) и атрибуты style также обрабатываются
  • Результат — CSSOM, дерево, аналогичное DOM, но с применёнными стилями

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

3. Выполнение JavaScript

JavaScript может модифицировать и DOM, и CSSOM, поэтому:

  • Парсинг HTML приостанавливается при встрече тега <script> (по умолчанию)
  • Скрипт загружается и выполняется
  • После выполнения скрипта парсинг HTML продолжается
<!-- Блокирует парсинг HTML -->
<script src="app.js"></script>

<!-- Не блокирует парсинг HTML, выполняется после построения DOM -->
<script src="app.js" defer></script>

<!-- Не блокирует парсинг HTML, выполняется как можно скорее -->
<script src="app.js" async></script>

4. Построение Render Tree

Render Tree — это комбинация DOM и CSSOM:

  • Включает только видимые элементы (исключаются <head>, display: none, мета-теги)
  • Каждый видимый элемент получает вычисленные стили
  • Текстовые узлы также включаются в Render Tree
/* Элемент с display: none не попадёт в Render Tree */
.hidden { display: none; }

/* Элемент с visibility: hidden попадёт в Render Tree, но будет невидим */
.invisible { visibility: hidden; }

5. Layout (Reflow)

На этом этапе браузер вычисляет геометрию каждого элемента:

  • Позиция (x, y координаты)
  • Размеры (width, height)
  • Отступы (margin, padding, border)

Layout — ресурсоёмкая операция, особенно при изменении размеров или позиций элементов. Триггеры layout:

  • Изменение размеров окна
  • Добавление/удаление DOM-элементов
  • Изменение стилей, влияющих на геометрию
  • Чтение свойств, требующих актуальной геометрии (offsetWidth, getBoundingClientRect())

6. Paint (Отрисовка)

Браузер преобразует Render Tree в пиксели:

  • Заполнение цветов, градиенты, изображения
  • Тени, границы, текст
  • Каждый элемент рисуется на отдельном слое (где возможно)

Paint происходит на Paint Records — записях о том, в каком порядке и какие операции рисования нужно выполнить.

7. Compositing (Композитинг)

Современные браузеры используют многослойную архитектуру:

  • Элементы с transform, opacity, will-change или position: fixed выносятся на отдельные слои
  • Слои композитируются (объединяются) на GPU
  • Это позволяет анимировать элементы без пересчёта layout и repaint
/* Элемент будет вынесен на отдельный слой GPU */
.animated {
will-change: transform;
/* или */
transform: translateZ(0); /* hack для промота на отдельный слой */
}

8. Оптимизация Critical Rendering Path

Для ускорения первой отрисовки страницы:

  • Минимизировать количество критических ресурсов — объединять CSS, откладывать некритичные JS
  • Уменьшить размер критических ресурсов — минификация, сжатие gzip/brotli
  • Уменьшить длину критического пути — загружать CSS как можно раньше, JS с defer/async
<head>
<!-- Критический CSS инлайн -->
<style>/* critical styles */</style>

<!-- Некритический CSS загружается асинхронно -->
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">

<!-- JS не блокирует рендеринг -->
<script src="app.js" defer></script>
</head>

9. Repaint и Reflow при обновлениях

После первоначального рендеринга изменения могут вызывать:

  • Repaint — перерисовка без изменения геометрии (изменение цвета, фона)
  • Reflow — пересчёт геометрии, ведущий к перерисовке (изменение размеров, добавление элементов)

Reflow значительно дороже repaint, поэтому его следует минимизировать.

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

Вопрос 5. В чём разница между debounce и throttle? Приведите примеры использования каждого подхода.

Таймкод: 00:04:42

Ответ собеседника: Правильный. Debounce срабатывает один раз после задержки после того, как пользователь прекратил действия. Throttle срабатывает не чаще одного раза за указанный интервал, независимо от количества событий.

Правильный ответ:

Debounce и throttle — это два паттерна ограничения частоты вызова функций, которые решают разные задачи и применяются в разных сценариях.

1. Debounce (размытие)

Суть: функция вызывается только после того, как прошёл указанный интервал времени без новых событий. Каждое новое событие сбрасывает таймер.

Аналогия: лифт — двери закроются через N секунд после того, как последний человек зашёл. Каждый новый вход сбрасывает таймер.

Типичные сценарии использования:

  • Поисковые подсказки (автокомплит) — запрос к API после того, как пользователь перестал печатать
  • Валидация формы — проверка корректности поля после завершения ввода
  • Сохранение черновика — автосохранение после паузы в наборе текста
  • Ресайз окна — пересчёт лейаута после завершения изменения размера

Реализация на JavaScript:

function debounce(func, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}

// Использование
const handleSearch = debounce((query) => {
fetch(`/api/search?q=${query}`);
}, 300);

input.addEventListener('input', (e) => handleSearch(e.target.value));

Реализация на Go (для серверной стороны):

package main

import (
"sync"
"time"
)

type Debouncer struct {
mu sync.Mutex
timer *time.Timer
delay time.Duration
fn func()
}

func NewDebouncer(delay time.Duration, fn func()) *Debouncer {
return &Debouncer{delay: delay, fn: fn}
}

func (d *Debouncer) Trigger() {
d.mu.Lock()
defer d.mu.Unlock()

if d.timer != nil {
d.timer.Stop()
}
d.timer = time.AfterFunc(d.delay, d.fn)
}

// Использование
func main() {
debouncer := NewDebouncer(300*time.Millisecond, func() {
// Сохраняем данные или отправляем запрос
saveDraft()
})

// Каждое событие ввода вызывает Trigger
// Реальное сохранение произойдёт через 300ms после последнего события
debouncer.Trigger()
}

2. Throttle (дросселирование)

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

Аналогия: дверь в метро — открывается раз в 30 секунд, независимо от того, сколько людей ждут.

Типичные сценарии использования:

  • Скролл-обработчики — обновление sticky-header или подгрузка контента
  • Движение мыши — обновление координат курсора (например, в графическом редакторе)
  • Кнопки — предотвращение повторных кликов (например, отправка формы)
  • Игровой цикл — ограничение частоты обновления состояния

Реализация на JavaScript (leading edge — первый вызов сразу):

function throttle(func, limit) {
let inThrottle = false;
return function (...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
}

// Использование
const handleScroll = throttle(() => {
updateHeaderVisibility();
checkInfiniteScroll();
}, 100);

window.addEventListener('scroll', handleScroll);

Реализация на JavaScript (trailing edge — последний вызов после интервала):

function throttleTrailing(func, limit) {
let lastCall = 0;
let timeoutId = null;
return function (...args) {
const now = Date.now();
const remaining = limit - (now - lastCall);

if (remaining <= 0) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
lastCall = now;
func.apply(this, args);
} else if (!timeoutId) {
timeoutId = setTimeout(() => {
lastCall = Date.now();
timeoutId = null;
func.apply(this, args);
}, remaining);
}
};
}

Реализация на Go с использованием time.Ticker:

package main

import (
"context"
"time"
)

type Throttle struct {
interval time.Duration
ch chan func()
}

func NewThrottle(interval time.Duration) *Throttle {
t := &Throttle{
interval: interval,
ch: make(chan func(), 100),
}
go t.worker()
return t
}

func (t *Throttle) worker() {
ticker := time.NewTicker(t.interval)
defer ticker.Stop()

var pending func()
hasPending := false

for {
select {
case fn := <-t.ch:
pending = fn
hasPending = true
case <-ticker.C:
if hasPending {
pending()
hasPending = false
}
}
}
}

func (t *Throttle) Submit(fn func()) {
select {
case t.ch <- fn:
default:
// Канал полный — пропускаем
}
}

// Использование
func main() {
throttle := NewThrottle(100 * time.Millisecond)

// При скролле или движении мыши
for i := 0; i < 1000; i++ {
throttle.Submit(func() {
updateUI()
})
}
}

3. Сравнительная таблица

ХарактеристикаDebounceThrottle
Когда срабатываетПосле паузы в событияхРегулярно через интервал
Первый вызовС задержкойСразу (leading) или через интервал (trailing)
Последний вызовГарантированНе гарантирован (может быть отброшен)
Пример использованияПоиск, валидацияСкролл, движение мыши

4. Комбинированный подход

Иногда нужен первый вызов сразу и последний вызов после паузы:

function debounceWithLeading(func, delay) {
let timeoutId = null;
let isLeading = true;

return function (...args) {
if (isLeading) {
func.apply(this, args);
isLeading = false;
}

clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
isLeading = true;
}, delay);
};
}

Выбор между debounce и throttle зависит от конкретной задачи: если важен результат после завершения действий — debounce, если важна регулярность обновлений — throttle.

Вопрос 6. Что такое замыкание (closure) в JavaScript? Приведите примеры использования.

Таймкод: 00:06:24

Ответ собеседника: Правильный. Замыкание — это способность функции запоминать и обращаться к переменным из внешней функции, даже после того как внешняя функция завершила выполнение.

Правильный ответ:

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

1. Механизм работы замыканий

Когда функция создаётся, она захватывает ссылку на все переменные из внешних областей видимости (scope chain). Эти переменные хранятся в специальном внутреннем свойстве [[Environment]] функции.

function outer() {
let count = 0; // переменная из внешней области видимости

function inner() {
count++; // замыкание обращается к count
console.log(count);
}

return inner;
}

const counter = outer(); // outer завершила выполнение, но count "живёт" в замыкании
counter(); // 1
counter(); // 2
counter(); // 3

2. Цепочка областей видимости (Scope Chain)

Замыкания могут захватывать переменные из нескольких уровней вложенности:

function level1() {
const a = 'level1';

function level2() {
const b = 'level2';

function level3() {
const c = 'level3';
console.log(a, b, c); // доступ ко всем трём переменным
}

return level3;
}

return level2;
}

const fn = level1()();
fn(); // "level1 level2 level3"

3. Практические примеры использования

А. Создание приватных переменных (модульный паттерн)

function createBankAccount(initialBalance) {
let balance = initialBalance; // приватная переменная, недоступная извне

return {
deposit(amount) {
if (amount > 0) {
balance += amount;
return true;
}
return false;
},
withdraw(amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
return true;
}
return false;
},
getBalance() {
return balance;
}
};
}

const account = createBankAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
console.log(account.balance); // undefined — нет прямого доступа

Б. Фабрики функций

function createMultiplier(multiplier) {
return function (number) {
return number * multiplier;
};
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

В. Мемоизация (кэширование результатов)

function memoize(fn) {
const cache = {};

return function (...args) {
const key = JSON.stringify(args);
if (key in cache) {
console.log('Возврат из кэша');
return cache[key];
}
const result = fn.apply(this, args);
cache[key] = result;
return result;
};
}

const expensiveCalculation = memoize(function (n) {
console.log('Вычисление...');
let sum = 0;
for (let i = 0; i < n; i++) {
sum += Math.sqrt(i);
}
return sum;
});

expensiveCalculation(1000000); // Вычисление...
expensiveCalculation(1000000); // Возврат из кэша

Г. Обработчики событий и колбэки

function setupButton(buttonId, message) {
const button = document.getElementById(buttonId);

button.addEventListener('click', function () {
// замыкание захватывает message
console.log(message);
});
}

setupButton('btn1', 'Кнопка 1 нажата');
setupButton('btn2', 'Кнопка 2 нажата');

Д. Каррирование (Currying)

function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return function (...moreArgs) {
return curried.apply(this, [...args, ...moreArgs]);
};
};
}

function sum(a, b, c) {
return a + b + c;
}

const curriedSum = curry(sum);
console.log(curriedSum(1)(2)(3)); // 6
console.log(curriedSum(1, 2)(3)); // 6
console.log(curriedSum(1)(2, 3)); // 6

4. Классическая проблема с замыканиями в циклах

// Проблема: все функции ссылаются на одну переменную i
const functions = [];
for (var i = 0; i < 3; i++) {
functions.push(function () {
console.log(i);
});
}
functions[0](); // 3 (не 0!)
functions[1](); // 3
functions[2](); // 3

// Решение 1: IIFE для создания нового scope
const functionsFixed = [];
for (var i = 0; i < 3; i++) {
(function (j) {
functionsFixed.push(function () {
console.log(j);
});
})(i);
}
functionsFixed[0](); // 0
functionsFixed[1](); // 1
functionsFixed[2](); // 2

// Решение 2: использование let (ES6)
const functionsLet = [];
for (let i = 0; i < 3; i++) {
functionsLet.push(function () {
console.log(i);
});
}
functionsLet[0](); // 0
functionsLet[1](); // 1
functionsLet[2](); // 2

5. Утечки памяти из-за замыканий

Замыкания удерживают ссылки на переменные, что может приводить к утечкам памяти:

function createHeavyObject() {
const largeData = new Array(1000000).fill('data');

return function () {
// даже если используем только часть данных,
// всё largeData остаётся в памяти
return largeData.length;
};
}

const getSize = createHeavyObject();
// largeData остаётся в памяти, пока существует ссылка на getSize

// Решение: не захватывать лишнее
function createHeavyObjectFixed() {
const largeData = new Array(1000000).fill('data');
const length = largeData.length; // сохраняем только нужное
largeData = null; // освобождаем ссылку

return function () {
return length;
};
}

6. Замыкания в асинхронном коде

function fetchWithRetry(url, maxRetries) {
let attempts = 0;

async function attempt() {
try {
attempts++;
const response = await fetch(url);
if (!response.ok) throw new Error('HTTP error');
return response.json();
} catch (error) {
if (attempts < maxRetries) {
console.log(`Попытка ${attempts} не удалась, повтор...`);
await new Promise(resolve => setTimeout(resolve, 1000));
return attempt(); // рекурсивный вызов через замыкание
}
throw error;
}
}

return attempt();
}

Замыкания — один из самых мощных механизмов JavaScript, лежащий в основе многих паттернов: модулей, фабрик, декораторов, middleware. Понимание замыканий критически важно для написания качественного кода и избежания типичных ошибок с областями видимости.

Вопрос 7. Как работает ключевое слово this в JavaScript? Опишите все правила определения this.

Таймкод: 00:07:15

Ответ собеседника: Неполный. this ссылается на объект, который вызывает функцию. В стрелочных функциях this берётся из лексического окружения. Также можно явно задавать this через bind, call и apply (explicit binding).

Правильный ответ:

this в JavaScript — один из самых запутанных механизмов языка, потому что его значение определяется не местом объявления функции, а способом её вызова. Существует четыре правила привязки this, применяемых в порядке приоритета.

1. Default Binding (привязка по умолчанию)

Применяется при обычном вызове функции без контекста:

function showThis() {
console.log(this);
}

showThis(); // В нестрогом режиме: window (браузер) или global (Node.js)
// В строгом режиме ('use strict'): undefined

'use strict';
function strictThis() {
console.log(this); // undefined
}

Важно: в строгом режиме this при обычном вызове — undefined, а не глобальный объект.

2. Implicit Binding (неявная привязка)

Применяется, когда функция вызывается как метод объекта — this ссылается на объект перед точкой:

const user = {
name: 'Alice',
greet() {
console.log(`Hello, ${this.name}`);
}
};

user.greet(); // "Hello, Alice" — this = user

// Потеря контекста
const greet = user.greet;
greet(); // "Hello, undefined" — this = window/undefined (default binding)

Проблема с потерей контекста:

const user = {
name: 'Alice',
greet() {
console.log(this.name);
}
};

setTimeout(user.greet, 100); // "undefined" — this потерян

// Решения:
setTimeout(() => user.greet(), 100); // стрелочная функция сохраняет контекст
setTimeout(user.greet.bind(user), 100); // явная привязка

3. Explicit Binding (явная привязка)

Принудительное задание this через call, apply или bind:

function introduce(age, city) {
console.log(`${this.name}, ${age} years old, from ${city}`);
}

const person = { name: 'Bob' };

// call — аргументы передаются через запятую
introduce.call(person, 30, 'Moscow'); // "Bob, 30 years old, from Moscow"

// apply — аргументы передаются массивом
introduce.apply(person, [30, 'Moscow']); // "Bob, 30 years old, from Moscow"

// bind — создаёт новую функцию с привязанным this
const boundIntroduce = introduce.bind(person);
boundIntroduce(25, 'SPb'); // "Bob, 25 years old, from SPb"

Различия между call, apply и bind:

МетодВызовАргументыВозвращает
callНемедленныйЧерез запятуюРезультат функции
applyНемедленныйМассивРезультат функции
bindОтложенныйЧерез запятуюНовую функцию

Практическое применение — карринг с bind:

function multiply(a, b) {
return a * b;
}

const double = multiply.bind(null, 2);
const triple = multiply.bind(null, 3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

4. new Binding (привязка через new)

При вызове функции с this ссылается на только что созданный экземпляр:

function Person(name, age) {
// this = {} (неявно)
this.name = name;
this.age = age;
// return this (неявно)
}

const alice = new Person('Alice', 30);
console.log(alice.name); // "Alice"

Порядок приоритета правил привязки this:

  1. new Binding — если функция вызвана с new
  2. Explicit Binding — если вызвана через call, apply, bind
  3. Implicit Binding — если вызвана как метод объекта
  4. Default Binding — во всех остальных случаях

5. Arrow Functions (стрелочные функции)

Стрелочные функции не имеют собственного this. Они захватывают this из окружающего лексического контекста — это не отдельное правило привязки, а отсутствие привязки вообще:

const team = {
name: 'Frontend',
members: ['Alice', 'Bob', 'Charlie'],

// Обычная функция — this теряется во вложенном колбэке
showMembersBroken() {
this.members.forEach(function (member) {
console.log(`${this.name}: ${member}`); // this.name = undefined
});
},

// Стрелочная функция — this берётся из showMembers
showMembersFixed() {
this.members.forEach((member) => {
console.log(`${this.name}: ${member}`); // this.name = "Frontend"
});
}
};

team.showMembersBroken();
// undefined: Alice
// undefined: Bob
// undefined: Charlie

team.showMembersFixed();
// Frontend: Alice
// Frontend: Bob
// Frontend: Charlie

Важные особенности стрелочных функций:

  • Нельзя использовать с new (нет внутреннего [[Construct]])
  • Нет собственного arguments (захватывается из внешней функции)
  • Нельзя изменить this через call, apply, bind
const arrowFn = () => console.log(this);
const obj = { name: 'test' };

arrowFn.call(obj); // this остаётся из внешнего контекста, не obj

6. this в классах

В классах методы по умолчанию работают как обычные функции, но при передаче метода как колбэка контекст теряется:

class Counter {
constructor() {
this.count = 0;
}

increment() {
this.count++;
console.log(this.count);
}
}

const counter = new Counter();
counter.increment(); // 1 — всё работает

const increment = counter.increment;
increment(); // TypeError: Cannot read properties of undefined

// Решение 1: bind в конструкторе
class CounterFixed {
constructor() {
this.count = 0;
this.increment = this.increment.bind(this);
}

increment() {
this.count++;
}
}

// Решение 2: стрелочная функция как поле класса
class CounterArrow {
count = 0;

increment = () => {
this.count++; // this всегда привязан к экземпляру
};
}

7. this в DOM-обработчиках

// Обычная функция — this = элемент, на котором произошло событие
button.addEventListener('click', function () {
console.log(this); // <button>
this.classList.toggle('active');
});

// Стрелочная функция — this = внешний контекст
button.addEventListener('click', () => {
console.log(this); // window или внешний this
// this.classList.toggle('active'); // Ошибка!
});

Понимание всех правил привязки this критически важно для предсказуемого поведения кода, особенно при работе с колбэками, обработчиками событий и асинхронными операциями.

Вопрос 8. В чём разница между useRef и useState? Когда использовать каждый из них?

Таймкод: 00:08:44

Ответ собеседника: Правильный. useRef хранит значение между рендерами без вызова ре-рендера, а useState хранит состояние, при изменении которого вызывается ре-рендер компонента для обновления UI.

Правильный ответ:

useRef и useState — два фундаментальных хука React, которые решают разные задачи хранения данных. Понимание их различий критически важно для правильной архитектуры компонентов.

1. useState — состояние, вызывающее ре-рендер

useState предназначен для хранения данных, которые должны отражаться в UI. При изменении состояния React планирует и выполняет ре-рендер компонента.

function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<p>Счётчик: {count}</p>
<button onClick={() => setCount(count + 1)}>Увеличить</button>
</div>
);
}

Ключевые особенности useState:

  • Изменение значения через setState вызывает ре-рендер компонента
  • Значение доступно в JSX для отображения
  • Поддерживает функциональное обновление: setCount(prev => prev + 1)
  • Принимает функцию для ленивой инициализации: useState(() => expensiveComputation())

2. useRef — мутабельная ссылка без ре-рендера

useRef возвращает объект { current: value }, который сохраняется между рендерами. Изменение .current не вызывает ре-рендер.

function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null);

const start = () => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
};

const stop = () => {
clearInterval(intervalRef.current);
};

useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);

return (
<div>
<p>{count}</p>
<button onClick={start}>Старт</button>
<button onClick={stop}>Стоп</button>
</div>
);
}

Ключевые особенности useRef:

  • Изменение .current не вызывает ре-рендер
  • Значение доступно синхронно (в отличие от state, который обновляется асинхронно)
  • Объект ref стабилен между рендерами (одна и та же ссылка)
  • Может хранить любое значение: числа, объекты, DOM-элементы, таймеры

3. Детальное сравнение

ХарактеристикаuseStateuseRef
Вызывает ре-рендерДаНет
Когда обновляетсяАсинхронно (батчинг)Синхронно немедленно
Использование в JSXДа, для отображенияНет, не для отображения
Стабильность ссылкиНовое значение при каждом вызовеОдин объект между рендерами
Типичное применениеUI-состояниеDOM-ссылки, таймеры, предыдущие значения

4. Типичные сценарии использования useRef

А. Доступ к DOM-элементам

function TextInput() {
const inputRef = useRef(null);

const focusInput = () => {
inputRef.current.focus();
};

return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Фокус</button>
</div>
);
}

Б. Хранение предыдущего значения

function usePrevious(value) {
const ref = useRef();

useEffect(() => {
ref.current = value;
}, [value]);

return ref.current; // возвращает значение с предыдущего рендера
}

function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);

return (
<div>
Текущее: {count}, Предыдущее: {prevCount}
</div>
);
}

В. Хранение мутабельных значений (флаги, счётчики)

function Component() {
const renderCount = useRef(0);
renderCount.current += 1;

const isFirstRender = useRef(true);
useEffect(() => {
isFirstRender.current = false;
}, []);

return <div>Рендеров: {renderCount.current}</div>;
}

Г. Хранение экземпляров и таймеров

function useInterval(callback, delay) {
const savedCallback = useRef(callback);

useEffect(() => {
savedCallback.current = callback;
}, [callback]);

useEffect(() => {
if (delay === null) return;

const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}

5. Почему нельзя использовать useRef вместо useState для UI?

// НЕПРАВИЛЬНО — UI не обновится
function BrokenCounter() {
const countRef = useRef(0);

return (
<div>
<p>{countRef.current}</p> {/* Всегда показывает 0 */}
<button onClick={() => countRef.current++}>
Увеличить
</button>
</div>
);
}

// ПРАВИЛЬНО — UI обновляется
function WorkingCounter() {
const [count, setCount] = useState(0);

return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>
Увеличить
</button>
</div>
);
}

6. Почему нельзя использовать useState для всего?

// НЕПРАВИЛЬНО — лишние ре-рендеры
function InefficientComponent() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });

useEffect(() => {
const handler = (e) => {
setMousePosition({ x: e.clientX, y: e.clientY }); // Ре-рендер на каждый mousemove!
};
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);

return <div>{mousePosition.x}, {mousePosition.y}</div>;
}

// ПРАVILЬНО — ref для частых обновлений, state для отображения
function EfficientComponent() {
const [displayPosition, setDisplayPosition] = useState({ x: 0, y: 0 });
const mousePosition = useRef({ x: 0, y: 0 });

useEffect(() => {
const handler = throttle((e) => {
mousePosition.current = { x: e.clientX, y: e.clientY };
setDisplayPosition(mousePosition.current); // Обновление с throttle
}, 100);
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);

return <div>{displayPosition.x}, {displayPosition.y}</div>;
}

7. Комбинирование useRef и useState

Часто используются вместе для сложных сценариев:

function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const abortControllerRef = useRef(null);

const handleSearch = async (searchQuery) => {
// Отменяем предыдущий запрос
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}

const controller = new AbortController();
abortControllerRef.current = controller;

try {
const response = await fetch(`/api/search?q=${searchQuery}`, {
signal: controller.signal
});
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
};

return (
<div>
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
handleSearch(e.target.value);
}}
/>
{/* Отображение results */}
</div>
);
}

Ключевое правило: если значение нужно отображать в UI — используйте useState. Если значение нужно только для внутренней логики (таймеры, DOM-ссылки, флаги, предыдущие значения) — используйте useRef.

Вопрос 9. Какие методы жизненного цикла есть в React? Как они соотносятся с хуками?

Таймкод: 00:09:55

Ответ собеседника: Неполный. До появления хуков использовались методы жизненного цикла: componentDidMount, componentDidUnmount и другие. С появлением хуков (React 16.8) их заменил useEffect с массивом зависимостей — пустой массив вызывает эффект при монтировании, возврат функции — при размонтировании, а указанные зависимости вызывают повторный запуск при их изменении.

Правильный ответ:

Жизненный цикл компонента в React описывает последовательность этапов от создания до удаления компонента. Существует два подхода: классовые компоненты с методами жизненного цикла и функциональные компоненты с хуками.

1. Методы жизненного цикла классовых компонентов

Жизненный цикл делится на три фазы: монтирование (mounting), обновление (updating) и размонтирование (unmounting).

Фаза монтирования (Mounting):

class MyComponent extends React.Component {
// 1. Конструктор — инициализация state и привязка методов
constructor(props) {
super(props);
this.state = { count: 0 };
this.handleClick = this.handleClick.bind(this);
}

// 2. static getDerivedStateFromProps — вызывается перед render
static getDerivedStateFromProps(props, state) {
// Возвращает объект для обновления state или null
if (props.value !== state.prevValue) {
return { prevValue: props.value, count: props.value };
}
return null;
}

// 3. render — единственный обязательный метод
render() {
return <div>{this.state.count}</div>;
}

// 4. componentDidMount — вызывается после первого рендера
componentDidMount() {
// Идеальное место для:
// - Запросов к API
// - Подписки на события
// - Инициализации сторонних библиотек
// - Работы с DOM
this.fetchData();
this.subscription = eventBus.subscribe(this.handleEvent);
}
}

Фаза обновления (Updating):

class MyComponent extends React.Component {
// 1. static getDerivedStateFromProps
static getDerivedStateFromProps(props, state) { /* ... */ }

// 2. shouldComponentUpdate — оптимизация ре-рендеров
shouldComponentUpdate(nextProps, nextState) {
// Возвращает true (обновить) или false (пропустить рендер)
return nextProps.value !== this.props.value;
}

// 3. render
render() { /* ... */ }

// 4. getSnapshotBeforeUpdate — вызывается перед коммитом в DOM
getSnapshotBeforeUpdate(prevProps, prevState) {
// Возвращает "снимок" или null
// Используется для сохранения позиции скролла и т.д.
if (prevProps.items.length < this.props.items.length) {
const list = this.listRef.current;
return list.scrollHeight - list.scrollTop;
}
return null;
}

// 5. componentDidUpdate — вызывается после обновления
componentDidUpdate(prevProps, prevState, snapshot) {
// Выполняется после рендера и коммита в DOM
// Не вызывается при первом рендере
if (prevProps.id !== this.props.id) {
this.fetchData();
}
// Использование snapshot из getSnapshotBeforeUpdate
if (snapshot !== null) {
this.listRef.current.scrollTop =
this.listRef.current.scrollHeight - snapshot;
}
}
}

Фаза размонтирования (Unmounting):

class MyComponent extends React.Component {
// componentWillUnmount — вызывается перед удалением компонента
componentWillUnmount() {
// Идеальное место для:
// - Отмены подписок
// - Очистки таймеров
// - Отмены запросов
// - Очистки слушателей событий
this.subscription.unsubscribe();
clearInterval(this.timerId);
if (this.abortController) {
this.abortController.abort();
}
}
}

2. Эквиваленты в функциональных компонентах с хуками

useEffect заменяет несколько методов жизненного цикла одним хуком:

function MyComponent({ id }) {
const [count, setCount] = useState(0);

// componentDidMount + componentDidUpdate + componentWillUnmount
useEffect(() => {
// Код выполняется после монтирования и при изменении зависимостей
console.log('Эффект выполнен');

// Возвращаемая функция выполняется при размонтировании
// и перед повторным выполнением эффекта
return () => {
console.log('Очистка эффекта');
};
}, [id]); // Зависимости

// Только componentDidMount (пустой массив зависимостей)
useEffect(() => {
console.log('Выполняется только при монтировании');
const subscription = eventBus.subscribe(handleEvent);

return () => {
console.log('Выполняется только при размонтировании');
subscription.unsubscribe();
};
}, []);

// shouldComponentUpdate → React.memo + useMemo/useCallback
const memoizedValue = useMemo(() => {
return expensiveCalculation(count);
}, [count]);

const memoizedCallback = useCallback(() => {
setCount(c => c + 1);
}, []);

return <div>{count}</div>;
}

// React.memo — аналог shouldComponentUpdate / PureComponent
const MemoizedComponent = React.memo(MyComponent);

3. Таблица соответствия методов жизненного цикла и хуков

Метод жизненного циклаЭквивалент в хуках
constructoruseState с инициализатором
componentDidMountuseEffect(() => {}, [])
componentDidUpdateuseEffect(() => {}, [deps])
componentWillUnmountuseEffect(() => { return () => {} }, [])
shouldComponentUpdateReact.memo + useMemo/useCallback
getDerivedStateFromPropsВычисление при рендере или useEffect
getSnapshotBeforeUpdateНет прямого аналога (используются refs)

4. Подробнее об очистке эффектов

function ChatRoom({ roomId }) {
useEffect(() => {
// Настройка при монтировании и при смене roomId
const connection = createConnection(roomId);
connection.connect();

// Очистка перед следующим эффектом и при размонтировании
return () => {
connection.disconnect();
};
}, [roomId]);

return <div>Комната: {roomId}</div>;
}

Порядок выполнения при смене roomId с "A" на "B":

  1. Рендер с roomId="B"
  2. React обновляет DOM
  3. Выполняется функция очистки от roomId="A" (disconnect)
  4. Выполняется эффект для roomId="B" (connect)

5. useLayoutEffect — синхронный аналог

useLayoutEffect выполняется синхронно после всех изменений DOM, но до отрисовки на экране:

function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);

useLayoutEffect(() => {
// Выполняется ДО отрисовки на экране
// Можно измерить элемент и сразу применить изменения
const height = ref.current.getBoundingClientRect().height;
setTooltipHeight(height);
}, []);

return <div ref={div}>{/* tooltip с учётом height */}</div>;
}

Когда использовать useLayoutEffect:

  • Измерение DOM-элементов перед отрисовкой
  • Предотвращение визуального мерцания (flicker)
  • Синхронные мутации DOM

Когда использовать useEffect:

  • Запросы к API
  • Подписки
  • Таймеры
  • Любая работа, не требующая синхронного выполнения

6. Современные хуки для управления жизненным циклом

function ModernComponent({ id }) {
// useInsertionEffect — для вставки стилей (выполняется до useLayoutEffect)
useInsertionEffect(() => {
// Используется CSS-in-JS библиотеками
}, []);

// useEffect — основной хук для побочных эффектов
useEffect(() => {
// Основная логика
}, [id]);

// useLayoutEffect — для синхронных операций с DOM
useLayoutEffect(() => {
// Измерения и синхронные мутации
}, []);
}

Порядок выполнения хуков эффектов:

  1. useInsertionEffect — самый ранний (для стилей)
  2. useLayoutEffect — после мутаций DOM, до paint
  3. useEffect — после paint (асинхронно)

Хотя классовые компоненты всё ещё поддерживаются, функциональные компоненты с хуками являются рекомендуемым подходом в современном React. Хуки обеспечивают более чистый код, лучшее переиспользование логики (через кастомные хуки) и более предсказуемое поведение.

Вопрос 10. Что такое SSR в Next.js и какие преимущества он даёт? Чем отличается от других подходов к рендерингу?

Таймкод: 00:11:04

Ответ собеседника: Неполный. SSR (server-side rendering) — рендеринг на сервере, при котором HTML формируется для каждого запроса и сразу отправляется клиенту, что обеспечивает мгновенный рендеринг. Преимущества: улучшенная производительность, лучшая SEO-оптимизация, улучшенные метрики LCP и FCP. Подходит для страниц с часто обновляющимися данными, например каталог товаров.

Правильный ответ:

SSR (Server-Side Rendering) — это подход к рендерингу, при котором HTML-страница формируется на сервере для каждого запроса и отправляется клиенту уже готовой. Next.js предоставляет несколько стратегий рендеринга, и понимание их различий критически важно для выбора правильного подхода.

1. Типы рендеринга в Next.js

CSR (Client-Side Rendering) — рендеринг на клиенте

Традиционный подход SPA: сервер отправляет пустой HTML и JavaScript-бандл, а весь рендеринг происходит в браузере.

// Обычный React-компонент — рендерится на клиенте
function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}

// Данные загружаются на клиенте через useEffect
function ProductPageCSR() {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(data => {
setProduct(data);
setLoading(false);
});
}, [id]);

if (loading) return <Spinner />;
return <ProductDetails product={product} />;
}

Недостатки CSR:

  • Пустая страница до загрузки JavaScript
  • Плохая SEO (поисковые боты видят пустой HTML)
  • Медленный First Contentful Paint (FCP)
  • Нужно показывать спиннеры/скелетоны

SSR (Server-Side Rendering) — рендеринг на сервере

HTML формируется на сервере для каждого запроса. В Next.js App Router это поведение по умолчанию.

// Next.js App Router — SSR по умолчанию
// app/products/[id]/page.tsx

// Эта функция выполняется на сервере для каждого запроса
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
// Данные всегда свежие, не кэшируются
cache: 'no-store'
});
return res.json();
}

export default async function ProductPage({ params }: { params: { id: string } }) {
// Данные загружаются на сервере до рендеринга
const product = await getProduct(params.id);

// Клиент получает готовый HTML
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}

SSG (Static Site Generation) — статическая генерация

HTML формируется один раз во время сборки и обслуживается как статический файл.

// Next.js Pages Router — SSG
export async function getStaticProps() {
const res = await fetch('https://api.example.com/products');
const products = await res.json();

return {
props: { products },
// Revalidate — ISR (Incremental Static Regeneration)
revalidate: 60 // Перегенерировать каждые 60 секунд
};
}

export async function getStaticPaths() {
return {
paths: [
{ params: { id: '1' } },
{ params: { id: '2' } }
],
fallback: 'blocking' // Генерировать новые страницы при запросе
};
}

export default function ProductPage({ product }) {
return <div>{product.name}</div>;
}

ISR (Incremental Static Regeneration) — инкрементальная регенерация

Гибридный подход: статические страницы перегенерируются в фоне без пересборки всего приложения.

// Next.js — ISR с revalidation
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // Кэш на 1 час
});
return res.json();
}

2. Сравнение подходов к рендерингу

ХарактеристикаCSRSSRSSGISR
Когда рендеритсяВ браузереНа сервере для каждого запросаПри сборкеПри сборке + периодически
SEOПлохоеОтличноеОтличноеОтличное
FCPМедленныйБыстрыйОчень быстрыйОчень быстрый
Актуальность данныхВсегда свежиеВсегда свежиеУстаревшиеСвежие после revalidation
Нагрузка на серверМинимальнаяМаксимальнаяМинимальнаяУмеренная
Сложность инфраструктурыПростаяНужен серверCDNCDN + сервер

3. Преимущества SSR

SEO-оптимизация: Поисковые боты получают полностью отрендеренный HTML с контентом, что критически важно для индексации.

Производительность:

  • Быстрый First Contentful Paint (FCP) — пользователь сразу видит контент
  • Быстрый Largest Contentful Paint (LCP) — основной контент загружается быстро
  • Нет «моргания» спиннеров → контент

Социальные сети: При шеринге ссылок в соцсетях (Open Graph, Twitter Cards) метаданные уже встроены в HTML.

// app/products/[id]/page.tsx
export async function generateMetadata({ params }) {
const product = await getProduct(params.id);

return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [product.image]
}
};
}

Доступность: Контент доступен даже при отключённом JavaScript или на медленных устройствах.

4. Недостатки SSR и способы их решения

Нагрузка на сервер: Каждый запрос требует вычислений на сервере.

// Решение: кэширование на уровне запросов
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // Кэшировать на 1 час
});

// Решение: React Cache для дедупликации запросов
import { cache } from 'react';

const getProduct = cache(async (id: string) => {
const res = await fetch(`https://api.example.com/products/${id}`);
return res.json();
});

Время ответа (TTFB): Серверу нужно время на получение данных и рендеринг.

// Решение: Streaming — отправка HTML частями
import { Suspense } from 'react';

export default function ProductPage({ params }) {
return (
<div>
{/* Статическая часть отправляется сразу */}
<Header />
<ProductInfo id={params.id} />

{/* Медленная часть загружается отдельно */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews id={params.id} />
</Suspense>

<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations id={params.id} />
</Suspense>
</div>
);
}

Сложность разработки: Код выполняется и на сервере, и на клиенте, что требует внимания к совместимости.

// Проблема: window не существует на сервере
function Component() {
// Ошибка при SSR!
const width = window.innerWidth;

// Решение: проверка среды
const width = typeof window !== 'undefined' ? window.innerWidth : 0;

// Или использование useEffect (выполняется только на клиенте)
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth);
}, []);
}

5. Выбор стратегии рендеринга

// SSR — для страниц с часто меняющимися данными
// Пример: каталог товаров, профиль пользователя, дашборд
export const dynamic = 'force-dynamic'; // Принудительный SSR

// SSG — для статического контента
// Пример: блог, документация, лендинги
export const dynamic = 'force-static'; // Принудительный SSG

// ISR — для контента, который обновляется периодически
// Пример: список товаров, новости, рейтинги
export const revalidate = 3600; // Обновление каждый час

// Streaming — для страниц с разной скоростью загрузки данных
// Пример: страница с быстрым заголовком и медленными рекомендациями

6. Гибридный подход в Next.js

Modern Next.js позволяет комбинировать подходы на уровне компонентов:

// Страница использует SSR по умолчанию
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);

return (
<div>
{/* Серверный компонент — рендерится на сервере */}
<ProductDetails product={product} />

{/* Клиентский компонент — интерактивность на клиенте */}
<AddToCartButton productId={product.id} />

{/* Статическая часть */}
<Footer />

{/* Потоковая загрузка медленных данных */}
<Suspense fallback={<Skeleton />}>
<Reviews productId={product.id} />
</Suspense>
</div>
);
}

SSR — мощный инструмент, но не универсальное решение. Оптимальный подход — использовать гибридную стратегию: SSR для критичных страниц, SSG для статического контента, и CSR для интерактивных элементов.

Вопрос 11. Как реализовать глобальные error boundaries для перехвата ошибок во всех компонентах приложения?

Таймкод: 00:13:06

Ответ собеседника: Неполный. Кандидат знает, что в Next.js есть специальная страница error page для обработки ошибок, и упомянул AggregateError. Однако не смог точно объяснить механизм глобального перехвата ошибок в функциональных компонентах. В классовых компонентах для этого используется метод componentDidCatch, а в функциональных — аналога нет из коробки.

Правильный ответ:

Error boundaries — это механизм React для перехвата ошибок в дереве компонентов и отображения запасного UI вместо падения всего приложения. Важно понимать ограничения и правильные паттерны использования.

1. Что могут и не могут перехватывать error boundaries

Перехватывают:

  • Ошибки во время рендеринга
  • Ошибки в методах жизненного цикла
  • Ошибки в конструкторах дочерних компонентов

Не перехватывают:

  • Ошибки в обработчиках событий (onClick, onChange и т.д.)
  • Асинхронный код (setTimeout, промисы)
  • Ошибки на самом error boundary
  • Ошибки на сервере (SSR)

2. Создание error boundary через классовый компонент

Error boundary можно создать только как классовый компонент с методами componentDidCatch и/или getDerivedStateFromError:

import React from 'react';

class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}

// Обновляет state для отображения fallback UI
static getDerivedStateFromError(error) {
return { hasError: true, error };
}

// Вызывается после перехвата ошибки — для логирования
componentDidCatch(error, errorInfo) {
this.setState({ errorInfo });

// Отправка ошибки в систему мониторинга
logErrorToService({
error: error.toString(),
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href
});
}

handleReset = () => {
this.setState({ hasError: false, error: null, errorInfo: null });
};

render() {
if (this.state.hasError) {
// Кастомный fallback UI
if (this.props.fallback) {
return this.props.fallback({
error: this.state.error,
reset: this.handleReset
});
}

// Стандартный fallback UI
return (
<div role="alert" style={styles.errorContainer}>
<h2>Что-то пошло не так</h2>
<details style={styles.details}>
<summary>Подробности ошибки</summary>
<pre>{this.state.error?.toString()}</pre>
<pre>{this.state.errorInfo?.componentStack}</pre>
</details>
<button onClick={this.handleReset}>
Попробовать снова
</button>
</div>
);
}

return this.props.children;
}
}

const styles = {
errorContainer: {
padding: '20px',
margin: '20px',
border: '1px solid #ff6b6b',
borderRadius: '8px',
backgroundColor: '#fff5f5'
},
details: {
margin: '10px 0',
padding: '10px',
backgroundColor: '#f8f9fa',
borderRadius: '4px'
}
};

export default ErrorBoundary;

3. Использование error boundary

// Оборачивание отдельных компонентов
function App() {
return (
<div>
<Header />

<ErrorBoundary fallback={({ error, reset }) => (
<div>
<h3>Ошибка в списке товаров</h3>
<button onClick={reset}>Повторить</button>
</div>
)}>
<ProductList />
</ErrorBoundary>

<ErrorBoundary>
<ShoppingCart />
</ErrorBoundary>

<Footer />
</div>
);
}

4. Глобальный error boundary в Next.js

App Router (Next.js 13+):

// app/error.tsx — автоматически оборачивает все дочерние сегменты
'use client'; // Error boundaries должны быть клиентскими компонентами

import { useEffect } from 'react';

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Логирование ошибки
console.error('Global error:', error);
logErrorToService(error);
}, [error]);

return (
<div>
<h2>Что-то пошло не так!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>
Попробовать снова
</button>
</div>
);
}

Иерархия error boundaries в Next.js:

app/
├── error.tsx # Глобальный error boundary
├── layout.tsx
├── page.tsx
├── products/
│ ├── error.tsx # Error boundary для сегмента /products
│ ├── layout.tsx
│ ├── page.tsx
│ └── [id]/
│ ├── error.tsx # Error boundary для /products/[id]
│ └── page.tsx
└── dashboard/
├── error.tsx # Error boundary для /dashboard
└── page.tsx

Pages Router (Next.js 12 и ниже):

// pages/_error.tsx — глобальная страница ошибок
import { NextPageContext } from 'next';

function Error({ statusCode }: { statusCode: number }) {
return (
<p>
{statusCode
? `Ошибка ${statusCode} на сервере`
: 'Ошибка на клиенте'}
</p>
);
}

Error.getInitialProps = ({ res, err }: NextPageContext) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
return { statusCode };
};

export default Error;

// pages/_app.tsx — оборачивание всего приложения
import ErrorBoundary from '../components/ErrorBoundary';

function MyApp({ Component, pageProps }) {
return (
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
);
}

export default MyApp;

5. Перехват ошибок в обработчиках событий

Error boundaries не перехватывают ошибки в обработчиках событий. Для них нужен try/catch:

function Component() {
const [error, setError] = useState(null);

const handleClick = async () => {
try {
await riskyOperation();
} catch (err) {
setError(err);
}
};

if (error) {
return <div>Ошибка: {error.message}</div>;
}

return <button onClick={handleClick}>Выполнить</button>;
}

// Или через глобальный обработчик ошибок
useEffect(() => {
const handleError = (event: ErrorEvent) => {
console.error('Global error:', event.error);
logErrorToService(event.error);
};

const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
console.error('Unhandled promise rejection:', event.reason);
logErrorToService(event.reason);
};

window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);

return () => {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, []);

6. Продвинутый error boundary с логированием

import React from 'react';

interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ReactNode | ((props: FallbackProps) => React.ReactNode);
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
resetKeys?: unknown[];
}

interface FallbackProps {
error: Error;
resetErrorBoundary: () => void;
}

interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}

class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
// Логирование
this.props.onError?.(error, errorInfo);

// Отправка в Sentry / LogRocket / другой сервис
this.logError(error, errorInfo);
}

componentDidUpdate(prevProps: ErrorBoundaryProps): void {
// Сброс error boundary при изменении resetKeys
if (
this.state.hasError &&
prevProps.resetKeys !== this.props.resetKeys &&
prevProps.resetKeys?.length !== this.props.resetKeys?.length
) {
this.resetErrorBoundary();
}
}

logError = (error: Error, errorInfo: React.ErrorInfo): void => {
const errorData = {
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
};

// Отправка на сервер
fetch('/api/log-error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorData),
}).catch(console.error);
};

resetErrorBoundary = (): void => {
this.setState({ hasError: false, error: null });
};

render(): React.ReactNode {
if (this.state.hasError) {
const { fallback } = this.props;
const { error } = this.state;

if (typeof fallback === 'function') {
return fallback({
error: error!,
resetErrorBoundary: this.resetErrorBoundary,
});
}

return fallback || <DefaultFallback error={error!} reset={this.resetErrorBoundary} />;
}

return this.props.children;
}
}

function DefaultFallback({ error, reset }: { error: Error; reset: () => void }) {
return (
<div role="alert">
<h2>Произошла ошибка</h2>
<p>{error.message}</p>
<button onClick={reset}>Попробовать снова</button>
</div>
);
}

export default ErrorBoundary;

7. Использование с resetKeys для автоматического сброса

function ProductPage({ productId }: { productId: string }) {
return (
<ErrorBoundary
resetKeys={[productId]} // Сбросится при смене товара
fallback={({ error, resetErrorBoundary }) => (
<div>
<h3>Ошибка загрузки товара</h3>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>Повторить</button>
</div>
)}
>
<ProductDetails productId={productId} />
</ErrorBoundary>
);
}

8. Кастомный хук для использования в функциональных компонентах

// Хелпер для использования error boundary в функциональных компонентах
function useErrorHandler() {
const [error, setError] = useState<Error | null>(null);

if (error) {
throw error; // Будет перехвачен ближайшим error boundary
}

return setError;
}

// Использование
function Component() {
const handleError = useErrorHandler();

const handleClick = async () => {
try {
await riskyOperation();
} catch (err) {
handleError(err as Error);
}
};

return <button onClick={handleClick}>Выполнить</button>;
}

9. Рекомендации по использованию

  • Размещайте error boundaries на стратегических уровнях: вокруг независимых виджетов, маршрутов, сложных компонентов
  • Не оборачивайте каждый компонент — это усложнит отладку
  • Всегда логируйте ошибки в componentDidCatch
  • Предоставляйте пользователю способ восстановления (кнопка «Повторить»)
  • Используйте разные fallback UI для разных типов ошибок
  • Комбинируйте с глобальными обработчиками window.onerror и unhandledrejection

Error boundaries — важный инструмент для создания устойчивых приложений, но они не заменяют правильную обработку ошибок в асинхронном коде и обработчиках событий.

Вопрос 12. Как отобразить список из 10 000 элементов без проблем с производительностью?

Таймкод: 00:15:46

Ответ собеседника: Правильный. Использовать виртуализацию списка (virtual list), при которой рендерятся только видимые элементы (например, 10 штук), а остальные подгружаются при скролле. Это уменьшает количество элементов в DOM. Кандидат также упомянул библиотеку react-window.

Правильный ответ:

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

1. Проблема с обычным рендерингом

// ПЛОХО — создаёт 10 000 DOM-узлов сразу
function BigList({ items }) {
return (
<div className="list">
{items.map(item => (
<div key={item.id} className="item">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
))}
</div>
);
}

// Проблемы:
// - 10 000 DOM-узлов в памяти
// - Медленный начальный рендер
// - Высокое потребление памяти
// - Медленный скролл

2. Библиотека react-window (рекомендуемая)

import { FixedSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';

// Компонент одной строки
function Row({ index, style, data }) {
const item = data[index];

return (
<div style={style} className="item">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
);
}

// Виртуализированный список
function VirtualizedList({ items }) {
return (
<AutoSizer>
{({ height, width }) => (
<List
height={height} // Высота видимой области
width={width} // Ширина видимой области
itemCount={items.length} // Общее количество элементов
itemSize={80} // Высота одного элемента
itemData={items} // Данные для рендеринга
>
{Row}
</List>
)}
</AutoSizer>
);
}

3. Библиотека react-virtualized (более функциональная)

import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';

// Для элементов с переменной высотой
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 100
});

function rowRenderer({ key, index, style, parent, data }) {
const item = data[index];

return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
<div style={style} className="item">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
</CellMeasurer>
);
}

function VirtualizedList({ items }) {
return (
<AutoSizer>
{({ height, width }) => (
<List
width={width}
height={height}
rowCount={items.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
itemData={items}
overscanRowCount={5} // Дополнительные строки для плавного скролла
/>
)}
</AutoSizer>
);
}

4. Библиотека @tanstack/react-virtual (современная альтернатива)

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

function VirtualizedList({ items }) {
const parentRef = useRef(null);

const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // Ориентировочная высота элемента
overscan: 5, // Количество дополнительных элементов
});

return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
{/* Контейнер с полной высотой для скроллбара */}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="item">
<h3>{items[virtualItem.index].title}</h3>
<p>{items[virtualItem.index].description}</p>
</div>
</div>
))}
</div>
</div>
);
}

5. Сравнение библиотек

БиблиотекаРазмерОсобенностиКогда использовать
react-window~6KBЛёгкая, простаяФиксированная высота элементов
react-virtualized~30KBМного компонентовСложные сценарии, таблицы
@tanstack/react-virtual~5KBСовременная, хук-basedНовые проекты, гибкость

6. Виртуализация в Next.js с SSR

// Серверный компонент — первая порция данных
async function ProductList({ initialProducts }) {
return (
<div>
{/* Первые 20 элементов рендерятся на сервере для SEO */}
{initialProducts.slice(0, 20).map(product => (
<ProductCard key={product.id} product={product} />
))}

{/* Остальные элементы загружаются на клиенте с виртуализацией */}
<ClientProductList initialProducts={initialProducts} />
</div>
);
}

// Клиентский компонент — виртуализация
'use client';
import { useVirtualizer } from '@tanstack/react-virtual';

function ClientProductList({ initialProducts }) {
const [products, setProducts] = useState(initialProducts);
const parentRef = useRef(null);

const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
});

// Загрузка дополнительных данных при скролле
useEffect(() => {
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();

if (!lastItem) return;

if (lastItem.index >= products.length - 10) {
loadMoreProducts();
}
}, [virtualizer.getVirtualItems()]);

return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ProductCard product={products[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}

7. Дополнительные оптимизации

Мемоизация элементов списка:

import { memo } from 'react';

// Предотвращает ре-рендер при скролле
const ListItem = memo(function ListItem({ item }) {
return (
<div className="item">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
);
});

function VirtualizedList({ items }) {
// ... virtualizer setup

return (
<div>
{virtualizer.getVirtualItems().map((virtualItem) => (
<ListItem
key={items[virtualItem.index].id}
item={items[virtualItem.index]}
/>
))}
</div>
);
}

Ленивая загрузка изображений:

function ProductItem({ product }) {
return (
<div className="product-item">
<img
src={product.image}
alt={product.title}
loading="lazy" // Нативная ленивая загрузка
width={200}
height={150}
/>
<h3>{product.title}</h3>
</div>
);
}

8. Когда виртуализация не нужна

  • Менее 100-200 элементов — обычный рендер работает хорошо
  • Элементы с очень сложной логикой — виртуализация может усложнить код
  • Списки с частой сортировкой/фильтрацией — нужны дополнительные оптимизации

9. Альтернативные подходы

Пагинация:

function PaginatedList({ items, pageSize = 50 }) {
const [page, setPage] = useState(0);

const visibleItems = items.slice(
page * pageSize,
(page + 1) * pageSize
);

return (
<div>
{visibleItems.map(item => (
<Item key={item.id} item={item} />
))}
<Pagination
current={page}
total={Math.ceil(items.length / pageSize)}
onChange={setPage}
/>
</div>
);
}

Бесконечный скролл с Intersection Observer:

function InfiniteList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loaderRef = useRef(null);

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore();
}
},
{ threshold: 0.1 }
);

if (loaderRef.current) {
observer.observe(loaderRef.current);
}

return () => observer.disconnect();
}, [hasMore]);

const loadMore = async () => {
const newItems = await fetchItems(page);
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
if (newItems.length === 0) setHasMore(false);
};

return (
<div>
{items.map(item => <Item key={item.id} item={item} />)}
{hasMore && <div ref={loaderRef}>Загрузка...</div>}
</div>
);
}

10. Метрики производительности

МетрикаБез виртуализацииС виртуализацией
DOM-узлы10 000~20-30
Время рендера~500ms~50ms
Память~50MB~5MB
FPS при скролле15-2055-60

Виртуализация — обязательная техника для больших списков. Выбор библиотеки зависит от проекта: react-window для простых случаев, @tanstack/react-virtual для современных проектов, react-virtualized для сложных сценариев с таблицами и сетками.

Вопрос 13. Что такое code splitting и как его реализовать? Какие существуют подходы?

Таймкод: 00:16:48

Ответ собеседника: Неполный. Code splitting — это техника оптимизации, при которой на клиент отправляется только код, необходимый для текущей страницы, а не весь бандл приложения. Это уменьшает размер загружаемого бандла и ускоряет загрузку.

Правильный ответ:

Code splitting — техника разделения JavaScript-бандла на части (chunks), которые загружаются по мере необходимости. Это критически важно для производительности: пользователь не должен ждать загрузки всего приложения, чтобы увидеть первую страницу.

1. Проблема без code splitting

// Всё приложение в одном бандле
// main.bundle.js — 5MB (все страницы, все компоненты, все библиотеки)

// Пользователь заходит на /login и ждёт загрузки:
// - Компоненты dashboard (которые ему не нужны)
// - Компоненты settings (которые ему не нужны)
// - Тяжёлые библиотеки для графиков (которые ему не нужны)
// - Код всех остальных страниц

2. Route-based code splitting (разделение по маршрутам)

React.lazy + Suspense:

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Динамические импорты — каждый компонент в отдельном chunk
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Profile = lazy(() => import('./pages/Profile'));
const HeavyChart = lazy(() => import('./components/HeavyChart'));

function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}

// Скелетон во время загрузки
function PageSkeleton() {
return (
<div className="skeleton">
<div className="skeleton-header" />
<div className="skeleton-content" />
<div className="skeleton-content" />
</div>
);
}

Next.js — автоматический code splitting:

// Next.js автоматически разбивает код по страницам
// Каждый файл в app/ или pages/ — отдельный chunk

// app/page.tsx — загружается только при визите на /
export default function Home() {
return <h1>Главная страница</h1>;
}

// app/dashboard/page.tsx — загружается только при визите на /dashboard
export default function Dashboard() {
return <h1>Дашборд</h1>;
}

// Динамический импорт компонентов
import dynamic from 'next/dynamic';

// Компонент загружается только на клиенте
const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false // Отключаем SSR для этого компонента
});

// С именованным экспортом
const NamedExport = dynamic(
() => import('../components/Chart').then(mod => mod.LineChart),
{ loading: () => <ChartSkeleton /> }
);

3. Component-based code splitting (разделение по компонентам)

import { lazy, Suspense, useState } from 'react';

// Модальное окно загружается только при открытии
const Modal = lazy(() => import('./Modal'));
const HeavyForm = lazy(() => import('./HeavyForm'));

function Page() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [showForm, setShowForm] = useState(false);

return (
<div>
<button onClick={() => setIsModalOpen(true)}>Открыть модалку</button>
<button onClick={() => setShowForm(true)}>Показать форму</button>

{isModalOpen && (
<Suspense fallback={<ModalSkeleton />}>
<Modal onClose={() => setIsModalOpen(false)} />
</Suspense>
)}

{showForm && (
<Suspense fallback={<FormSkeleton />}>
<HeavyForm />
</Suspense>
)}
</div>
);
}

4. Preloading — предзагрузка критичных чанков

import { lazy, Suspense, useEffect } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));

function Home() {
useEffect(() => {
// Предзагрузка Dashboard после рендера Home
// Пользователь уже увидел контент, а следующая страница загружается в фоне
import('./pages/Dashboard');
}, []);

return (
<div>
<h1>Главная</h1>
<a href="/dashboard">Перейти в дашборд</a>
</div>
);
}

Предзагрузка при наведении (hover):

function Navigation() {
const preloadDashboard = () => {
import('./pages/Dashboard');
};

return (
<nav>
<a
href="/dashboard"
onMouseEnter={preloadDashboard}
onFocus={preloadDashboard}
>
Дашборд
</a>
</nav>
);
}

5. Webpack magic comments для контроля чанков

// Имя чанка для отладки и анализа
const Dashboard = lazy(() => import(
/* webpackChunkName: "dashboard" */
/* webpackPrefetch: true */
'./pages/Dashboard'
));

// webpackPreload: true — загрузить параллельно с родительским чанком
// webpackPrefetch: true — загрузить после загрузки основного контента
const Settings = lazy(() => import(
/* webpackChunkName: "settings" */
/* webpackPreload: true */
'./pages/Settings'
));

// Группировка нескольких компонентов в один chunk
const Chart1 = lazy(() => import(
/* webpackChunkName: "charts" */
'./charts/Chart1'
));
const Chart2 = lazy(() => import(
/* webpackChunkName: "charts" */
'./charts/Chart2'
));

6. Анализ бандла

# Установка анализатора
npm install --save-dev webpack-bundle-analyzer

# Генерация отчёта
npx webpack-bundle-analyzer stats.json

# Для Next.js
npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
// конфигурация Next.js
});

7. Оптимизация размера чанков

// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// Выносим вендоров в отдельный chunk
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
// Общий код приложения
common: {
minChunks: 2,
chunks: 'all',
enforce: true,
},
},
},
},
};

8. Error boundary для лениво загружаемых компонентов

import { lazy, Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function Page() {
return (
<ErrorBoundary fallback={<div>Ошибка загрузки компонента</div>}>
<Suspense fallback={<LoadingSpinner />}>
<HeavyComponent />
</Suspense>
</ErrorBoundary>
);
}

9. Структура бандла после code splitting

До code splitting:
├── main.bundle.js (5MB) — всё приложение

После code splitting:
├── main.bundle.js (500KB) — ядро приложения
├── vendors.bundle.js (1MB) — сторонние библиотеки
├── home.chunk.js (100KB) — страница Home
├── dashboard.chunk.js (300KB) — страница Dashboard
├── settings.chunk.js (200KB) — страница Settings
├── charts.chunk.js (400KB) — компоненты графиков
└── heavy-form.chunk.js (150KB) — тяжёлая форма

10. Сравнение подходов

ПодходКогда использоватьПлюсыМинусы
Route-basedМногостраничные приложенияАвтоматическое разделениеНужен Suspense
Component-basedТяжёлые модалки, формыГранулярный контрольРучное управление
Vendor splittingЛюбое приложениеКэширование библиотекДополнительный запрос
PreloadingПредсказуемые переходыМгновенная навигацияЛишний трафик

11. Рекомендации

  • Используйте route-based splitting как базовый подход
  • Применяйте component-based для тяжёлых компонентов (модалки, графики, редакторы)
  • Настраивайте vendor splitting для кэширования библиотек
  • Используйте prefetch для предсказуемых переходов
  • Анализируйте бандл регулярно с webpack-bundle-analyzer
  • Добавляйте Suspense с осмысленными fallback-компонентами
  • Оборачивайте ленивые компоненты в ErrorBoundary

Code splitting — одна из самых эффективных оптимизаций производительности. Правильная стратегия может уменьшить время загрузки первой страницы на 50-70%.

Вопрос 14. Что такое WebSocket и для чего используется? Чем отличается от HTTP?

Таймкод: 00:17:48

Ответ собеседника: Неполный. WebSocket — это протокол для передачи данных в реальном времени без необходимости постоянных запросов от клиента. В отличие от HTTP (запрос-ответ), WebSocket позволяет серверу отправлять данные клиенту непрерывно через «комнаты». Используется для живых данных: котировки акций, чаты, уведомления.

Правильный ответ:

WebSocket — это протокол связи, обеспечивающий полнодуплексную связь через одно TCP-соединение. В отличие от HTTP, где клиент всегда инициирует запрос, WebSocket позволяет серверу отправлять данные клиенту без явного запроса.

1. Ключевые отличия WebSocket от HTTP

ХарактеристикаHTTPWebSocket
Модель связиЗапрос-ответ (клиент → сервер)Полнодуплексная (оба направления)
СоединениеСоздаётся заново для каждого запросаОдно постоянное соединение
ИнициаторТолько клиентКлиент и сервер
ЗаголовкиОтправляются с каждым запросомТолько при установке соединения
Накладные расходыВысокие (заголовки на каждый запрос)Низкие (после handshake)
НаправлениеКлиент → СерверДвунаправленное

2. Как работает WebSocket

Установка соединения (Handshake):

Клиент → Сервер:
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Сервер → Клиент:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

После успешного HTTP-хэндшейка соединение «апгрейдится» до WebSocket.

Передача данных:

// После установки соединения данные передаются через фреймы
// Минимальные накладные расходы: 2-14 байт на фрейм
// Вместо HTTP-заголовков в несколько килобайт

3. Реализация WebSocket-сервера на Go

package main

import (
"log"
"net/http"
"sync"

"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// В production — строгая проверка origin
return true
},
}

// Клиент — структура для хранения соединения
type Client struct {
conn *websocket.Conn
send chan []byte
}

// Хаб — центральная точка для управления клиентами
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
mu sync.RWMutex
}

func newHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
}
}

func (h *Hub) run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client] = true
h.mu.Unlock()
log.Printf("Клиент подключён. Всего: %d", len(h.clients))

case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
h.mu.Unlock()
log.Printf("Клиент отключён. Всего: %d", len(h.clients))

case message := <-h.broadcast:
h.mu.RLock()
for client := range h.clients {
select {
case client.send <- message:
default:
// Канал полный — отключаем клиента
close(client.send)
delete(h.clients, client)
}
}
h.mu.RUnlock()
}
}
}

func (c *Client) readPump(hub *Hub) {
defer func() {
hub.unregister <- c
c.conn.Close()
}()

c.conn.SetReadLimit(512 * 1024) // Максимальный размер сообщения — 512KB

for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway,
websocket.CloseAbnormalClosure) {
log.Printf("Ошибка чтения: %v", err)
}
break
}

// Обработка входящего сообщения
processed := processMessage(message)
hub.broadcast <- processed
}
}

func (c *Client) writePump() {
defer c.conn.Close()

for {
select {
case message, ok := <-c.send:
if !ok {
// Канал закрыт — отправляем close frame
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}

c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))

if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
}
}
}

func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Ошибка upgrade: %v", err)
return
}

client := &Client{
conn: conn,
send: make(chan []byte, 256),
}

hub.register <- client

// Запускаем горутины для чтения и записи
go client.writePump()
go client.readPump(hub)
}

func main() {
hub := newHub()
go hub.run()

http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
serveWs(hub, w, r)
})

log.Println("Сервер запущен на :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}

4. Комнаты (Rooms) для группировки клиентов

type RoomHub struct {
rooms map[string]map[*Client]bool
mu sync.RWMutex
}

func (h *RoomHub) joinRoom(client *Client, roomID string) {
h.mu.Lock()
defer h.mu.Unlock()

if h.rooms[roomID] == nil {
h.rooms[roomID] = make(map[*Client]bool)
}
h.rooms[roomID][client] = true
client.rooms[roomID] = true
}

func (h *RoomHub) leaveRoom(client *Client, roomID string) {
h.mu.Lock()
defer h.mu.Unlock()

if room, ok := h.rooms[roomID]; ok {
delete(room, client)
if len(room) == 0 {
delete(h.rooms, roomID)
}
}
delete(client.rooms, roomID)
}

func (h *RoomHub) broadcastToRoom(roomID string, message []byte) {
h.mu.RLock()
defer h.mu.RUnlock()

if room, ok := h.rooms[roomID]; ok {
for client := range room {
select {
case client.send <- message:
default:
// Канал полный — пропускаем
}
}
}
}

5. Использование на клиенте (JavaScript)

class WebSocketClient {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.listeners = new Map();
}

connect() {
this.ws = new WebSocket(this.url);

this.ws.onopen = () => {
console.log('WebSocket соединение установлено');
this.reconnectAttempts = 0;
this.emit('connected');
};

this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.emit(data.type, data.payload);
} catch (e) {
this.emit('message', event.data);
}
};

this.ws.onclose = (event) => {
console.log('WebSocket соединение закрыто', event.code, event.reason);
this.emit('disconnected');
this.attemptReconnect();
};

this.ws.onerror = (error) => {
console.error('WebSocket ошибка:', error);
this.emit('error', error);
};
}

attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Достигнут лимит попыток переподключения');
return;
}

this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);

console.log(`Переподключение через ${delay}ms (попытка ${this.reconnectAttempts})`);

setTimeout(() => this.connect(), delay);
}

send(type, payload) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, payload }));
} else {
console.error('WebSocket не подключён');
}
}

subscribe(roomId) {
this.send('subscribe', { room: roomId });
}

unsubscribe(roomId) {
this.send('unsubscribe', { room: roomId });
}

on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
}

emit(event, data) {
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.forEach(cb => cb(data));
}
}

disconnect() {
if (this.ws) {
this.ws.close(1000, 'Клиент отключился');
}
}
}

// Использование
const ws = new WebSocketClient('ws://localhost:8080/ws');

ws.on('connected', () => {
ws.subscribe('room-123');
});

ws.on('message', (data) => {
console.log('Получено сообщение:', data);
});

ws.on('disconnected', () => {
console.log('Соединение потеряно');
});

ws.connect();

6. Типичные сценарии использования WebSocket

  • Чаты и мессенджеры — мгновенная доставка сообщений
  • Уведомления в реальном времени — новые события, обновления
  • Совместное редактирование — Google Docs, Figma
  • Финансовые данные — котировки акций, криптовалюты
  • Игры — многопользовательские онлайн-игры
  • IoT — телеметрия устройств
  • Живые ленты — новости, спортивные трансляции

7. Альтернативы WebSocket

Server-Sent Events (SSE):

// Однонаправленная связь: сервер → клиент
const eventSource = new EventSource('/api/events');

eventSource.onmessage = (event) => {
console.log('Получено:', event.data);
};

eventSource.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
showNotification(data);
});
// Go-сервер для SSE
func serveSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")

flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming не поддерживается", http.StatusInternalServerError)
return
}

ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

for {
select {
case <-ticker.C:
fmt.Fprintf(w, "data: %s\n\n", time.Now().Format(time.RFC3339))
flusher.Flush()
case <-r.Context().Done():
return
}
}
}

Long Polling:

// Эмуляция real-time через HTTP
async function longPoll() {
try {
const response = await fetch('/api/poll', {
method: 'POST',
body: JSON.stringify({ lastEventId: getLastEventId() })
});
const data = await response.json();
processEvents(data.events);
} finally {
// Сразу запускаем следующий запрос
longPoll();
}
}

8. Сравнение подходов real-time

ПодходНаправлениеСложностьНадёжностьКогда использовать
WebSocketДвунаправленнаяСредняяВысокаяЧаты, игры, совместная работа
SSEСервер → КлиентНизкаяСредняяУведомления, ленты, обновления
Long PollingКлиент → СерверНизкаяНизкаяЛегаси-системы, простые задачи
HTTP PollingКлиент → СерверНизкаяНизкаяРедкие обновления

9. Масштабирование WebSocket

Для горизонтального масштабирования нужен брокер сообщений:

// Использование Redis Pub/Sub для масштабирования
import "github.com/go-redis/redis"

type ScalableHub struct {
redis *redis.Client
localHub *Hub
roomID string
}

func (h *ScalableHub) subscribeToRedis() {
pubsub := h.redis.Subscribe(h.roomID)
defer pubsub.Close()

ch := pubsub.Channel()
for msg := range ch {
// Рассылаем локальным клиентам
h.localHub.broadcast <- []byte(msg.Payload)
}
}

func (h *ScalableHub) publishToRedis(message []byte) {
h.redis.Publish(h.roomID, message)
}

WebSocket — оптимальный выбор для real-time приложений с двунаправленной связью. Для однонаправленных сценариев (уведомления, ленты) проще использовать SSE.

Вопрос 15. Реализовать секундомер с кнопками Start и Stop на React.

Таймкод: 00:18:47

Ответ собеседника: Неполный. Кандидат начал реализацию секундомера, используя useRef для хранения ID интервала и useState для счётчика. Возникли трудности: не мог очистить интервал при остановке, пытался использовать useEffect неправильно. С подсказками интервьюера разобрался с useRef(.current), но не самостоятельно нашёл баг — при многократном клике на Start создавалось несколько интервалов. Для исправления нужно проверять, что ref пуст перед созданием нового интервала, и сбрасывать ref в null при остановке.

Правильный ответ:

Секундомер — классическое задание для проверки понимания React-хуков, работы с интервалами и управления побочными эффектами.

1. Базовая реализация секундомера

import { useState, useRef, useCallback } from 'react';

function Stopwatch() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null);

const start = useCallback(() => {
// Защита от создания нескольких интервалов
if (intervalRef.current !== null) return;

setIsRunning(true);
intervalRef.current = setInterval(() => {
setTime(prevTime => prevTime + 10); // Обновление каждые 10мс
}, 10);
}, []);

const stop = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsRunning(false);
}, []);

const reset = useCallback(() => {
stop();
setTime(0);
}, [stop]);

// Форматирование времени в MM:SS.ms
const formatTime = (ms) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const milliseconds = Math.floor((ms % 1000) / 10);

return `${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`;
};

return (
<div className="stopwatch">
<div className="display">{formatTime(time)}</div>
<div className="controls">
{!isRunning ? (
<button onClick={start}>Start</button>
) : (
<button onClick={stop}>Stop</button>
)}
<button onClick={reset}>Reset</button>
</div>
</div>
);
}

export default Stopwatch;

2. Ключевые моменты реализации

Почему useRef для хранения ID интервала:

// ПЛОХО — использование state для ID интервала
const [intervalId, setIntervalId] = useState(null);

const start = () => {
// Проблема: setState асинхронен, intervalId не обновится сразу
const id = setInterval(() => { /* ... */ }, 10);
setIntervalId(id); // Обновится только после рендера
};

const stop = () => {
clearInterval(intervalId); // Может быть null!
};

// ХОРОШО — использование ref
const intervalRef = useRef(null);

const start = () => {
if (intervalRef.current !== null) return; // Защита от дублирования

intervalRef.current = setInterval(() => { /* ... */ }, 10);
};

const stop = () => {
clearInterval(intervalRef.current); // Синхронный доступ
intervalRef.current = null;
};

Защита от множественных интервалов:

// Без защиты: каждый клик на Start создаёт новый интервал
// Секундомер начинает идти быстрее с каждым кликом

// С защитой: проверяем, что интервал не запущен
const start = useCallback(() => {
if (intervalRef.current !== null) {
return; // Интервал уже запущен — игнорируем клик
}

intervalRef.current = setInterval(() => {
setTime(prev => prev + 10);
}, 10);
}, []);

Функциональное обновление state:

// ПЛОХО — замыкание на старое значение
setInterval(() => {
setTime(time + 10); // time всегда равен значению при создании интервала
}, 10);

// ХОРОШО — функциональное обновление
setInterval(() => {
setTime(prevTime => prevTime + 10); // Всегда актуальное значение
}, 10);

3. Продвинутая реализация с использованием useEffect

import { useState, useRef, useEffect, useCallback } from 'react';

function StopwatchAdvanced() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null);
const startTimeRef = useRef(0);

// Эффект управляет интервалом на основе isRunning
useEffect(() => {
if (isRunning) {
startTimeRef.current = Date.now() - time;

intervalRef.current = setInterval(() => {
setTime(Date.now() - startTimeRef.current);
}, 10);
} else {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}

// Очистка при размонтировании или изменении isRunning
return () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
}
};
}, [isRunning]); // Зависимость от isRunning

const start = useCallback(() => {
setIsRunning(true);
}, []);

const stop = useCallback(() => {
setIsRunning(false);
}, []);

const reset = useCallback(() => {
setIsRunning(false);
setTime(0);
}, []);

const formatTime = (ms) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const centiseconds = Math.floor((ms % 1000) / 10);

return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centiseconds).padStart(2, '0')}`;
};

return (
<div className="stopwatch">
<div className="time">{formatTime(time)}</div>
<div className="buttons">
{!isRunning ? (
<button onClick={start} disabled={isRunning}>
Start
</button>
) : (
<button onClick={stop} disabled={!isRunning}>
Stop
</button>
)}
<button onClick={reset}>Reset</button>
</div>
</div>
);
}

4. Кастомный хук useStopwatch

import { useState, useRef, useCallback } from 'react';

function useStopwatch() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null);

const start = useCallback(() => {
if (intervalRef.current !== null) return;

setIsRunning(true);
intervalRef.current = setInterval(() => {
setTime(prev => prev + 10);
}, 10);
}, []);

const stop = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsRunning(false);
}, []);

const reset = useCallback(() => {
stop();
setTime(0);
}, [stop]);

const toggle = useCallback(() => {
if (isRunning) {
stop();
} else {
start();
}
}, [isRunning, start, stop]);

return {
time,
isRunning,
start,
stop,
reset,
toggle,
};
}

// Использование
function Stopwatch() {
const { time, isRunning, start, stop, reset } = useStopwatch();

return (
<div>
<div>{formatTime(time)}</div>
{!isRunning ? (
<button onClick={start}>Start</button>
) : (
<button onClick={stop}>Stop</button>
)}
<button onClick={reset}>Reset</button>
</div>
);
}

5. Реализация с круговыми записями (Lap times)

function StopwatchWithLaps() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const [laps, setLaps] = useState([]);
const intervalRef = useRef(null);
const lastLapTimeRef = useRef(0);

const start = useCallback(() => {
if (intervalRef.current !== null) return;

setIsRunning(true);
intervalRef.current = setInterval(() => {
setTime(prev => prev + 10);
}, 10);
}, []);

const stop = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsRunning(false);
}, []);

const lap = useCallback(() => {
const lapTime = time - lastLapTimeRef.current;
lastLapTimeRef.current = time;

setLaps(prev => [...prev, {
number: prev.length + 1,
split: formatTime(lapTime),
total: formatTime(time)
}]);
}, [time]);

const reset = useCallback(() => {
stop();
setTime(0);
setLaps([]);
lastLapTimeRef.current = 0;
}, [stop]);

return (
<div>
<div className="time">{formatTime(time)}</div>
<div className="controls">
{!isRunning ? (
<button onClick={start}>Start</button>
) : (
<button onClick={stop}>Stop</button>
)}
<button onClick={lap} disabled={!isRunning}>
Lap
</button>
<button onClick={reset}>Reset</button>
</div>

{laps.length > 0 && (
<div className="laps">
<h3>Круги:</h3>
<ul>
{laps.map(lap => (
<li key={lap.number}>
Круг {lap.number}: {lap.split} (всего: {lap.total})
</li>
))}
</ul>
</div>
)}
</div>
);
}

6. Типичные ошибки и их решения

Ошибка 1: Утечка интервала при размонтировании

// ПЛОХО — интервал продолжает работать после размонтирования
function Stopwatch() {
const [time, setTime] = useState(0);

useEffect(() => {
const id = setInterval(() => {
setTime(prev => prev + 10); // Ошибка при размонтировании!
}, 10);
// Нет очистки!
}, []);

return <div>{time}</div>;
}

// ХОРОШО — очистка в return
function Stopwatch() {
const [time, setTime] = useState(0);

useEffect(() => {
const id = setInterval(() => {
setTime(prev => prev + 10);
}, 10);

return () => clearInterval(id); // Очистка при размонтировании
}, []);

return <div>{time}</div>;
}

Ошибка 2: Замыкание на устаревшее значение

// ПЛОХО
const start = () => {
setInterval(() => {
setTime(time + 10); // time всегда 0 при первом вызове
}, 10);
};

// ХОРОШО
const start = () => {
setInterval(() => {
setTime(prev => prev + 10); // Функциональное обновление
}, 10);
};

Ошибка 3: Множественные интервалы

// ПЛОХО — нет проверки
const start = () => {
intervalRef.current = setInterval(() => { /* ... */ }, 10);
};

// ХОРОШО — проверка перед созданием
const start = () => {
if (intervalRef.current !== null) return;
intervalRef.current = setInterval(() => { /* ... */ }, 10);
};

7. Стилизация (CSS)

.stopwatch {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
font-family: 'Courier New', monospace;
}

.display {
font-size: 48px;
font-weight: bold;
margin-bottom: 20px;
padding: 10px 20px;
background: #1a1a2e;
color: #00ff88;
border-radius: 8px;
min-width: 200px;
text-align: center;
}

.controls {
display: flex;
gap: 10px;
}

.controls button {
padding: 10px 20px;
font-size: 16px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}

.controls button:first-child {
background: #4CAF50;
color: white;
}

.controls button:nth-child(2) {
background: #f44336;
color: white;
}

.controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

Ключевые выводы для собеседования:

  • Используйте useRef для хранения ID интервала (не вызывает ре-рендер)
  • Всегда очищайте интервал при размонтировании (cleanup в useEffect)
  • Защищайтесь от создания нескольких интервалов
  • Используйте функциональное обновление setState для корректной работы с замыканиями
  • Выносите логику в кастомные хуки для переиспользования