Senior Frontend Interview 2026 🎉 | Javascript 🎯 (Mock) [Most Asked Questions]
Сегодня мы разберём собеседование на позицию 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. Сравнительная таблица
| Характеристика | Debounce | Throttle |
|---|---|---|
| Когда срабатывает | После паузы в событиях | Регулярно через интервал |
| Первый вызов | С задержкой | Сразу (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:
- new Binding — если функция вызвана с
new - Explicit Binding — если вызвана через
call,apply,bind - Implicit Binding — если вызвана как метод объекта
- 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. Детальное сравнение
| Характеристика | useState | useRef |
|---|---|---|
| Вызывает ре-рендер | Да | Нет |
| Когда обновляется | Асинхронно (батчинг) | Синхронно немедленно |
| Использование в 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. Таблица соответствия методов жизненного цикла и хуков
| Метод жизненного цикла | Эквивалент в хуках |
|---|---|
constructor | useState с инициализатором |
componentDidMount | useEffect(() => {}, []) |
componentDidUpdate | useEffect(() => {}, [deps]) |
componentWillUnmount | useEffect(() => { return () => {} }, []) |
shouldComponentUpdate | React.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":
- Рендер с roomId="B"
- React обновляет DOM
- Выполняется функция очистки от roomId="A" (disconnect)
- Выполняется эффект для 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(() => {
// Измерения и синхронные мутации
}, []);
}
Порядок выполнения хуков эффектов:
useInsertionEffect— самый ранний (для стилей)useLayoutEffect— после мутаций DOM, до paintuseEffect— после 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. Сравнение подходов к рендерингу
| Характеристика | CSR | SSR | SSG | ISR |
|---|---|---|---|---|
| Когда рендерится | В браузере | На сервере для каждого запроса | При сборке | При сборке + периодически |
| SEO | Плохое | Отличное | Отличное | Отличное |
| FCP | Медленный | Быстрый | Очень быстрый | Очень быстрый |
| Актуальность данных | Всегда свежие | Всегда свежие | Устаревшие | Свежие после revalidation |
| Нагрузка на сервер | Минимальная | Максимальная | Минимальная | Умеренная |
| Сложность инфраструктуры | Простая | Нужен сервер | CDN | CDN + сервер |
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-20 | 55-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
| Характеристика | HTTP | WebSocket |
|---|---|---|
| Модель связи | Запрос-ответ (клиент → сервер) | Полнодуплексная (оба направления) |
| Соединение | Создаётся заново для каждого запроса | Одно постоянное соединение |
| Инициатор | Только клиент | Клиент и сервер |
| Заголовки | Отправляются с каждым запросом | Только при установке соединения |
| Накладные расходы | Высокие (заголовки на каждый запрос) | Низкие (после 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для корректной работы с замыканиями - Выносите логику в кастомные хуки для переиспользования
