РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ на 300к. SENIOR FRONTEND, Live Coding
Сегодня мы разберем собеседование на позицию фронтенд-разработчика, в ходе которого кандидат последовательно решает задачи на JavaScript (Event Loop, промисы, микро-/макрозадачи), реализует класс EventEmitter, разбирается с оптимизацией рендеринга в React (мемоизация, дебаунс), а также строит мини-приложение поиска с обработкой ошибок, загрузки и отменой запросов через AbortController. Интервью проходит в формате живого кодирования с глубоким обсуждением деталей — от поведения браузерного event loop до борьбы с гонками запросов в асинхронном коде.
Вопрос 1. Какой будет порядок вывода в консоль для данного кода с console.log, Promise.resolve и setTimeout?
Таймкод: 00:01:36
Ответ собеседника: Правильный. Сначала выполняется синхронный код (console.log), затем микротаски (Promise.resolve попадает в очередь микрозадач), потом макротаски (setTimeout попадает в очередь макрозадач). Порядок: 1, 4, 6, затем 2, 5, затем 3 — из-за механизма Event Loop.
Правильный ответ:
Механизм Event Loop в JavaScript
Event Loop — это механизм, который управляет порядком выполнения кода в однопоточном окружении JavaScript. Он обеспечивает асинхронное выполнение операций, разделяя задачи на синхронные, микрозадачи и макрозадачи.
Приоритеты выполнения
В порядке убывания приоритета:
- Синхронный код — выполняется немедленно, блокируя поток выполнения
- Микрозадачи (Microtask Queue) — включают:
- Promise.then/catch/finally обработчики
- queueMicrotask()
- MutationObserver
- process.nextTick() в Node.js (имеет наивысший приоритет среди микрозадач)
- Макрозадачи (Macrotask Queue) — включают:
- setTimeout/setInterval
- setImmediate (Node.js)
- I/O операции
- UI rendering (в браузере)
- requestAnimationFrame (браузер)
Пример кода для демонстрации
console.log('1'); // Синхронный
setTimeout(() => {
console.log('2'); // Макрозадача
}, 0);
Promise.resolve().then(() => {
console.log('3'); // Микрозадача
});
console.log('4'); // Синхронный
setTimeout(() => {
console.log('5'); // Макрозадача
}, 0);
Promise.resolve().then(() => {
console.log('6'); // Микрозадача
});
Порядок выполнения:
- Синхронный код:
1,4 - Все микрозадачи:
3,6 - Макрозадачи:
2,5
Итоговый вывод:
1
4
3
6
2
5
Важные нюансы
- Микрозадачи выполняются полностью до перехода к макрозадачам
- Если микрозадача порождает новые микрозадачи, они также выполняются до макрозадач
- Это может привести к «голоданию» макрозадач при бесконечном добавлении микрозадач
- В Node.js
process.nextTick()имеет приоритет над другими микрозадачами - Браузер может выполнять рендеринг между макрозадачами, но не между микрозадачами
Практическое применение
Понимание Event Loop критически важно для:
- Оптимизации производительности
- Предотвращения блокировки UI
- Правильной последовательности операций
- Отладки асинхронного кода
Вопрос 2. Что произойдет при бесконечном рекурсивном вызове через Promise.resolve — будет ли выполнен setTimeout?
Таймкод: 00:03:37
Ответ собеседника: Правильный. Очередь микрозадач будет постоянно пополняться и никогда не очистится, что приведёт к блокировке выполнения макрозадач и рендеринга страницы.
Правильный ответ:
Проблема "голодания" макрозадач (Microtask Starvation)
При бесконечном добавлении микрозадач возникает ситуация, когда макрозадачи никогда не получают возможность выполниться. Это происходит из-за приоритетной обработки микрозадач в Event Loop.
Пример проблемного кода
setTimeout(() => {
console.log('Макрозадача - никогда не выполнится');
}, 0);
function infiniteMicrotask() {
Promise.resolve().then(infiniteMicrotask);
}
infiniteMicrotask();
Что происходит внутри Event Loop:
- Синхронный код завершается
- Обрабатывается очередь микрозадач
- Каждый обработчик добавляет новую микрозадачу
- Очередь микрозадач никогда не становится пустой
- Event Loop не переходит к макрозадачам
Последствия:
- Зависание интерфейса — браузер не может выполнить рендеринг
- Не выполняются таймеры — setTimeout/setInterval блокируются
- I/O операции игнорируются — сетевые запросы не обрабатываются
- Приложение становится неотзывчивым — пользователь не может взаимодействовать
Демонстрация с счётчиком
let microtaskCount = 0;
function countMicrotasks() {
microtaskCount++;
Promise.resolve().then(countMicrotasks);
}
countMicrotask();
setTimeout(() => {
console.log(`Выполнено микрозадач: ${microtaskCount}`);
}, 1000);
// setTimeout никогда не выполнится,
// а microtaskCount будет расти бесконечно
Как избежать проблемы:
1. Использование setTimeout для разбиения задач
function processChunk(data, index = 0) {
const chunkSize = 1000;
for (let i = index; i < Math.min(index + chunkSize, data.length); i++) {
// Обработка элемента
}
if (index + chunkSize < data.length) {
setTimeout(() => processChunk(data, index + chunkSize), 0);
}
}
2. Использование requestAnimationFrame
function processWithRAF(data, index = 0) {
const chunkSize = 100;
for (let i = index; i < Math.min(index + chunkSize, data.length); i++) {
// Обработка элемента
}
if (index + chunkSize < data.length) {
requestAnimationFrame(() => processWithRAF(data, index + chunkSize));
}
}
3. Контроль глубины рекурсии
function safeMicrotask(fn, depth = 0) {
const MAX_DEPTH = 1000;
if (depth >= MAX_DEPTH) {
setTimeout(fn, 0);
} else {
Promise.resolve().then(() => fn(depth + 1));
}
}
Практические рекомендации:
- Избегайте бесконечных цепочек микрозадач
- Для длительных операций используйте макрозадачи
- Разбивайте большие задачи на части
- Используйте Web Workers для тяжелых вычислений
- Мониторьте производительность с помощью Performance API
Вывод:
Бесконечные микрозадачи действительно блокируют выполнение макрозадач, включая setTimeout, рендеринг и I/O операции. Это одна из распространенных ловушок при работе с асинхронным кодом в JavaScript.
Вопрос 3. Как поведут себя интерактивные элементы страницы при бесконечной рекурсии микрозадач через Promise.resolve по сравнению с setTimeout?
Таймкод: 00:05:27
Ответ собеседника: Правильный. При рекурсии через Promise страница станет неинтерактивной, потому что очередь микрозадач блокирует рендеринг. Если использовать setTimeout — страница не заблокируется, так как макрозадачи разбираются по одной и между ними браузер может выполнить рендер.
Правильный ответ:
Влияние на интерактивность страницы
Ключевое различие между микрозадачами и макрозадачами — возможность браузера выполнять рендеринг и обрабатывать пользовательские события между задачами.
Рекурсия через Promise.resolve — блокировка UI
function blockUI() {
// Каждый вызов добавляет микрозадачу
Promise.resolve().then(blockUI);
}
blockUI();
// Результат:
// - Страница зависает
// - Кнопки не кликаются
// - Скролл не работает
// - Анимации останавливаются
// - DevTools может не отвечать
Почему это происходит:
- Event Loop обрабатывает ВСЕ микрозадачи до перехода к следующей фазе
- Рендеринг происходит ТОЛЬКО после полной очистки очереди микрозадач
- Обработка пользовательских событий (click, scroll) — это макрозадачи
- Бесконечная цепочка микрозадач не даёт браузеру выполнить рендер
Рекурсия через setTimeout — UI остаётся отзывчивым
function nonBlockUI() {
setTimeout(nonBlockUI, 0);
}
nonBlockUI();
// Результат:
// - Страница остаётся интерактивной
// - Можно кликать по кнопкам
// - Работает скролл
// - Анимации продолжаются
// - Но производительность снижена
Почему это работает:
- Каждый setTimeout — отдельная макрозадача
- Между макрозадачами браузер выполняет:
- Style Calculation
- Layout
- Paint
- Composite
- Обработку пользовательских событий
Демонстрация разницы
<button id="test-btn">Кликни меня!</button>
<div id="counter">Кликов: 0</div>
<script>
let clicks = 0;
document.getElementById('test-btn').addEventListener('click', () => {
document.getElementById('counter').textContent = `Кликов: ${++clicks}`;
});
// Тест 1: Блокировка через Promise
function blockWithPromise() {
Promise.resolve().then(blockWithPromise);
}
// Тест 2: Блокировка через setTimeout
function blockWithTimeout() {
setTimeout(blockWithTimeout, 0);
}
// Раскомментируйте один из вариантов для тестирования:
// blockWithPromise(); // Кнопка перестанет работать
// blockWithTimeout(); // Кнопка продолжит работать
</script>
Визуализация работы Event Loop
Микрозадачи (Promise):
[микрозадача] → [микрозадача] → [микрозадача] → ... → ❌ рендер не выполняется
Макрозадачи (setTimeout):
[макрозадача] → [рендер] → [макрозадача] → [рендер] → [макрозадача] → ...
Коллаборация с основным потоком
Браузер использует кооперативную многозадачность:
- Микрозадачи — не кооперативные, выполняются до полного завершения
- Макрозадачи — кооперативные, каждая задача получает квант времени
Практические последствия
| Аспект | Promise.resolve рекурсия | setTimeout рекурсия |
|---|---|---|
| Клики по кнопкам | ❌ Не работают | ✅ Работают |
| Скролл страницы | ❌ Заблокирован | ✅ Работает |
| CSS анимации | ❌ Остановлены | ✅ Продолжаются |
| Ввод в формы | ❌ Игнорируется | ✅ Работает |
| DevTools | ⚠️ Может зависнуть | ✅ Работает |
| Производительность | 🔴 Критическая | 🟡 Сниженная |
Рекомендации для тяжёлых вычислений
1. Используйте Web Workers
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ data: hugeArray });
worker.onmessage = (e) => {
console.log('Результат:', e.data);
};
// worker.js
self.onmessage = (e) => {
const result = heavyComputation(e.data);
self.postMessage(result);
};
2. Разбивайте задачи на чанки
async function processLargeArray(array) {
const chunkSize = 1000;
for (let i = 0; i < array.length; i += chunkSize) {
const chunk = array.slice(i, i + chunkSize);
processChunk(chunk);
// Даём браузеру возможность отрендерить
await new Promise(resolve => setTimeout(resolve, 0));
}
}
3. Используйте requestIdleCallback
function processInIdleTime(deadline) {
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
const task = tasks.shift();
processTask(task);
}
if (tasks.length > 0) {
requestIdleCallback(processInIdleTime);
}
}
requestIdleCallback(processInIdleTime);
Вывод:
Promise.resolve рекурсия полностью блокирует UI, так как микрозадачи выполняются без возможности рендеринга между ними. setTimeout рекурсия сохраняет интерактивность, потому что макрозадачи чередуются с фазами рендеринга в Event Loop.
Вопрос 4. Будет ли страница блокироваться при использовании setTimeout вместо Promise.resolve для бесконечной рекурсии?
Таймкод: 00:06:06
Ответ собеседника: Правильный. Нет, страница не заблокируется, потому что макрозадачи разбираются по одной, и после каждой из них браузер может выполнить рендер и обработать другие события.
Правильный ответ:
setTimeout рекурсия не блокирует UI
В отличие от Promise.resolve, бесконечная рекурсия через setTimeout не приводит к полной блокировке страницы, хотя и создаёт значительную нагрузку на основной поток.
Сравнение поведения
// ❌ Блокирует UI полностью
function blockWithPromise() {
Promise.resolve().then(blockWithPromise);
}
// ✅ Не блокирует UI (но нагружает поток)
function blockWithTimeout() {
setTimeout(blockWithTimeout, 0);
}
Почему setTimeout не блокирует рендеринг
Event Loop при работе с макрозадачами следует циклу:
┌─────────────────────────────┐
│ Выполнить макрозадачу │
└──────────────┬──────────────┘
▼
┌─────────────────────────────┐
│ Выполнить все микрозадачи │
└──────────────┬──────────────┘
▼
┌─────────────────────────────┐
│ Рендеринг │
│ - Style Calculation │
│ - Layout │
│ - Paint │
│ - Composite │
└──────────────┬──────────────┘
▼
┌─────────────────────────────┐
│ Проверить следующую задачу │
└─────────────────────────────┘
Демонстрация работоспособности UI
<!DOCTYPE html>
<html>
<head>
<title>setTimeout vs Promise</title>
<style>
.box {
width: 100px;
height: 100px;
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
animation: rotate 2s linear infinite;
margin: 20px;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
#status {
padding: 10px;
margin: 10px;
border: 1px solid #ccc;
}
</style>
</head>
<body>
<div class="box"></div>
<button id="testBtn">Кликни меня</button>
<input type="text" placeholder="Введите текст...">
<div id="status">Кликов: 0</div>
<script>
let clickCount = 0;
let timeoutCount = 0;
document.getElementById('testBtn').addEventListener('click', () => {
clickCount++;
document.getElementById('status').textContent =
`Кликов: ${clickCount}, setTimeout вызовов: ${timeoutCount}`;
});
function recursiveTimeout() {
timeoutCount++;
setTimeout(recursiveTimeout, 0);
}
// Запускаем бесконечную рекурсию
recursiveTimeout();
// Всё равно можно:
// - Кликать по кнопкам
// - Вводить текст
// - Наблюдать анимацию
// - Скроллить страницу
</script>
</body>
</html>
Ограничения и нюансы
Хотя UI остаётся интерактивным, есть важные ограничения:
1. Задержка обработки событий
let lastClickTime = Date.now();
button.addEventListener('click', () => {
const now = Date.now();
const delay = now - lastClickTime;
console.log(`Задержка обработки: ${delay}мс`);
lastClickTime = now;
});
// При активной setTimeout рекурсии задержка может быть значительной
2. Минимальная задержка setTimeout
Браузеры устанавливают минимальную задержку для setTimeout:
- Вкладка активна: ~4мс
- Вкладка неактивна: ~1000мс (для экономии ресурсов)
function measureActualDelay() {
const start = performance.now();
setTimeout(() => {
const actualDelay = performance.now() - start;
console.log(`Запрошено: 0мс, фактически: ${actualDelay.toFixed(2)}мс`);
measureActualDelay();
}, 0);
}
measureActualDelay();
3. Производительность
// Мониторинг использования процессора
function monitorPerformance() {
const start = performance.now();
let iterations = 0;
function count() {
iterations++;
if (performance.now() - start < 1000) {
setTimeout(count, 0);
} else {
console.log(`Итераций за секунду: ${iterations}`);
// Обычно 200-300 при delay=0
setTimeout(monitorPerformance, 1000);
}
}
count();
}
monitorPerformance();
Оптимизация с requestAnimationFrame
Для анимаций лучше использовать rAF:
function animationLoop() {
// Обновление анимации
updateAnimation();
// Следующий кадр
requestAnimationFrame(animationLoop);
}
animationLoop();
Сравнительная таблица
| Характеристика | Promise.resolve | setTimeout | requestAnimationFrame |
|---|---|---|---|
| Блокировка UI | ✅ Да | ❌ Нет | ❌ Нет |
| Частота вызовов | ~100000/сек | ~250/сек | ~60/сек |
| Синхронизация с рендером | ❌ Нет | ❌ Нет | ✅ Да |
| Приоритет | Высокий | Низкий | Средний |
| Для анимаций | ❌ Плохо | ⚠️ Средне | ✅ Отлично |
Практический пример: неблокирующая обработка
class TaskScheduler {
constructor() {
this.tasks = [];
this.isRunning = false;
}
addTask(task) {
this.tasks.push(task);
if (!this.isRunning) {
this.run();
}
}
run() {
if (this.tasks.length === 0) {
this.isRunning = false;
return;
}
this.isRunning = true;
const task = this.tasks.shift();
// Выполняем задачу
task();
// Следующая задача через setTimeout
// Позволяет браузеру отрендерить между задачами
setTimeout(() => this.run(), 0);
}
}
const scheduler = new TaskScheduler();
// Добавляем много задач
for (let i = 0; i < 10000; i++) {
scheduler.addTask(() => {
console.log(`Задача ${i}`);
});
}
Вывод:
setTimeout рекурсия не блокирует UI, так как между макрозадачами браузер выполняет рендеринг и обработку событий. Однако это создаёт нагрузку на основной поток и может вызывать задержки в обработке пользовательских действий.
Вопрос 5. Как можно поставить функцию в очередь микрозадач?
Таймкод: 00:07:38
Ответ собеседника: Правильный. Через Promise.resolve().then() или через queueMicrotask().
Правильный ответ:
Способы добавления функций в очередь микрозадач
Существует несколько способов явно и неявно добавить функцию в очередь микрозадач JavaScript.
1. queueMicrotask() — нативный API
console.log('Синхронный код');
queueMicrotask(() => {
console.log('Микрозадача 1');
});
queueMicrotask(() => {
console.log('Микрозадача 2');
});
console.log('Синхронный код 2');
// Вывод:
// Синхронный код
// Синхронный код 2
// Микрозадача 1
// Микрозадача 2
Преимущества queueMicrotask():
- Явное намерение — код самодокументируемый
- Не создаёт лишний Promise
- Семантически корректный способ
- Работает во всех современных браузерах и Node.js
2. Promise.then() / Promise.catch() / Promise.finally()
// Разные способы создания микрозадач через Promise
// Через resolved Promise
Promise.resolve().then(() => {
console.log('Микрозадача через then');
});
// Через rejected Promise
Promise.reject().catch(() => {
console.log('Микрозадача через catch');
});
// Через finally
Promise.resolve().finally(() => {
console.log('Микрозадача через finally');
});
// Цепочка Promise
Promise.resolve()
.then(() => console.log('Первая микрозадача'))
.then(() => console.log('Вторая микрозадача'))
.then(() => console.log('Третья микрозадача'));
3. async/await (неявно создают микрозадачи)
async function asyncFunction() {
console.log('1: До await');
await Promise.resolve();
// Код после await выполняется как микрозадача
console.log('3: После await');
}
console.log('2: До вызова');
asyncFunction();
console.log('4: После вызова');
// Вывод:
// 2: До вызова
// 1: До await
// 4: После вызова
// 3: После await
4. MutationObserver (браузер)
const observer = new MutationObserver(() => {
console.log('Микрозадача через MutationObserver');
});
const target = document.createElement('div');
observer.observe(target, { attributes: true });
// Мутация вызовет микрозадачу
target.setAttribute('data-test', 'value');
5. process.nextTick() (Node.js)
console.log('Синхронный');
process.nextTick(() => {
console.log('nextTick — высший приоритет в Node.js');
});
Promise.resolve().then(() => {
console.log('Promise микрозадача');
});
// Вывод в Node.js:
// Синхронный
// nextTick — высший приоритет в Node.js
// Promise микрозадача
Приоритеты микрозадач в Node.js
process.nextTick(() => console.log('1: nextTick'));
Promise.resolve().then(() => console.log('2: Promise'));
queueMicrotask(() => console.log('3: queueMicrotask'));
process.nextTick(() => {
process.nextTick(() => console.log('4: вложенный nextTick'));
});
// Вывод:
// 1: nextTick
// 4: вложенный nextTick
// 2: Promise
// 3: queueMicrotask
Практическое применение
Отложенное выполнение после рендеринга:
function updateUI() {
// Изменение DOM
element.textContent = 'Новый контент';
// Выполнить после рендеринга, но до следующей макрозадачи
queueMicrotask(() => {
console.log('Высота элемента:', element.offsetHeight);
});
}
Группировка обновлений:
class Batcher {
constructor() {
this.pending = [];
this.scheduled = false;
}
add(task) {
this.pending.push(task);
if (!this.scheduled) {
this.scheduled = true;
queueMicrotask(() => this.flush());
}
}
flush() {
this.scheduled = false;
const tasks = this.pending;
this.pending = [];
for (const task of tasks) {
task();
}
}
}
const batcher = new Batcher();
batcher.add(() => console.log('Задача 1'));
batcher.add(() => console.log('Задача 2'));
batcher.add(() => console.log('Задача 3'));
// Все три задачи выполнятся в одной микрозадаче
Безопасное выполнение после текущего контекста:
function safeCallback(callback) {
// Гарантирует выполнение после текущего синхронного кода
// Без задержки setTimeout
queueMicrotask(callback);
}
function processData(data) {
// Синхронная обработка
const result = transform(data);
// Уведомление подписчиков после завершения
queueMicrotask(() => {
notifySubscribers(result);
});
return result;
}
Сравнение способов
| Способ | Браузер | Node.js | Приоритет | Явность |
|---|---|---|---|---|
| queueMicrotask() | ✅ | ✅ | Обычный | Явный |
| Promise.then() | ✅ | ✅ | Обычный | Неявный |
| async/await | ✅ | ✅ | Обычный | Неявный |
| MutationObserver | ✅ | ❌ | Обычный | Неявный |
| process.nextTick() | ❌ | ✅ | Высокий | Явный |
Рекомендации
- Используйте
queueMicrotask()для явного добавления микрозадач - Избегайте бесконечных цепочек микрозадач
- Помните, что микрозадачи блокируют рендеринг
- Для отложенного выполнения без блокировки используйте
setTimeout
Вопрос 6. Почему бесконечная очередь микрозадач блокирует рендеринг браузера и что происходит с пользовательскими событиями (click, scroll)?
Таймкод: 00:08:03
Ответ собеседника: Правильный. Браузер переходит к рендеру только после полной очистки стека вызовов, микрозадач и макрозадач. Если очередь микрозадач бесконечна, Event Loop никогда не дойдёт до этапа рендеринга. Пользовательские события попадают в очередь макрозадач и не будут обработаны, пока микрозадачи не завершатся.
Правильный ответ:
Механизм блокировки рендеринга
Браузер исстрого определённый цикл обработки задач, и рендеринг происходит только на определённом этапе этого цикла.
Цикл Event Loop в браузере
┌─────────────────────────────────────┐
│ Стек вызовов (Call Stack) │
│ Выполняется синхронный код │
└──────────────────┬──────────────────┘
▼
┌─────────────────────────────────────┐
│ Очередь микрозадач (Microtask) │
│ Выполняется ПОЛНОСТЬЮ │
│ (Promise, queueMicrotask) │
└──────────────────┬──────────────────┘
▼
┌─────────────────────────────────────┐
│ Рендеринг (Render) │
│ - Style Calculation │
│ - Layout │
│ - Paint │
│ - Composite │
└──────────────────┬──────────────────┘
▼
┌─────────────────────────────────────┐
│ Очередь макрозадач (Macrotask) │
│ Выполняется ОДНА задача │
│ (setTimeout, events, I/O) │
└─────────────────────────────────────┘
Почему бесконечные микрозадачи блокируют всё
function infiniteMicrotask() {
Promise.resolve().then(infiniteMicrotask);
}
infiniteMicrotask();
// Event Loop застревает здесь:
// [микрозадача] → [микрозадача] → [микрозадача] → ...
// Рендеринг никогда не наступает
Последовательность событий:
- Синхронный код завершается
- Event Loop переходит к обработке микрозадач
- Каждая микрозадача порождает новую
- Очередь никогда не становится пустой
- Event Loop не переходит к рендерингу
- Макрозадачи (включая события) не обрабатываются
Что происходит с пользовательскими событиями
Все пользовательские события — это макрозадачи:
// Все эти события попадают в очередь макрозадач:
element.addEventListener('click', handler); // Макрозадача
element.addEventListener('scroll', handler); // Макрозадача
element.addEventListener('keydown', handler); // Макрозадача
element.addEventListener('mousemove', handler); // Макрозадача
Демонстрация блокировки событий
<!DOCTYPE html>
<html>
<head>
<style>
#box {
width: 100px;
height: 100px;
background: red;
transition: transform 0.3s;
}
#box:hover {
transform: scale(1.2);
}
#log {
height: 200px;
overflow-y: scroll;
border: 1px solid #ccc;
padding: 10px;
}
</style>
</head>
<body>
<div id="box"></div>
<button id="blockBtn">Заблокировать UI</button>
<button id="unblockBtn">Разблокировать</button>
<div id="log"></div>
<script>
let blocked = false;
const log = document.getElementById('log');
function addLog(message) {
const entry = document.createElement('div');
entry.textContent = `${new Date().toLocaleTimeString()}: ${message}`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
// Клики по кнопкам
document.getElementById('blockBtn').addEventListener('click', () => {
if (!blocked) {
blocked = true;
addLog('Блокировка начата');
function block() {
if (blocked) {
Promise.resolve().then(block);
}
}
block();
}
});
document.getElementById('unblockBtn').addEventListener('click', () => {
blocked = false;
addLog('Блокировка снята');
});
// События мыши
document.getElementById('box').addEventListener('click', () => {
addLog('Клик по красному квадрату');
});
// Скролл
document.addEventListener('scroll', () => {
addLog('Скролл страницы');
});
// Непрерывное логирование
setInterval(() => {
addLog('Тик интервала');
}, 1000);
</script>
</body>
</html>
Что наблюдается при блокировке:
- ❌ Hover-эффекты не работают
- ❌ Клики не обрабатываются
- ❌ Скролл игнорируется
- ❌ setInterval не срабатывает
- ❌ Анимации замораживаются
- ❌ Ввод в формы невозможен
Техническая причина
// Упрощённая реализация Event Loop
while (true) {
// 1. Выполнить синхронный код
executeSynchronousCode();
// 2. Выполнить ВСЕ микрозадачи
while (microtaskQueue.length > 0) {
const microtask = microtaskQueue.shift();
execute(microtask);
// Новые микрозадачи добавляются в конец очереди
}
// 3. Рендеринг (доходит только если микрозадачи закончились)
if (shouldRender) {
render();
}
// 4. Выполнить ОДНУ макрозадачу
if (macrotaskQueue.length > 0) {
const macrotask = macrotaskQueue.shift();
execute(macrotask);
}
}
Визуализация проблемы
Время →
Микрозадачи: [■■][■■][■■][■■][■■][■■][■■][■■][■■][■■]...
Рендеринг: ─────────────────────────────────────────────── ❌
Макрозадачи: ─────────────────────────────────────────────── ❌
События: ─────────────────────────────────────────────── ❌
Как браузер пытается помочь
Современные браузеры имеют механизмы защиты:
// Chrome может показать предупреждение:
// "Page unresponsive" с предложением убить скрипт
// Но это не всегда срабатывает быстро
function hardBlock() {
Promise.resolve().then(hardBlock);
}
hardBlock();
Правильный подход к тяжёлым задачам
// Используйте Web Workers для вычислений
const worker = new Worker('worker.js');
worker.postMessage({ type: 'heavy-computation', data: largeArray });
worker.onmessage = (event) => {
console.log('Результат:', event.data);
};
// worker.js
self.onmessage = (event) => {
const result = performHeavyComputation(event.data);
self.postMessage(result);
};
Вывод:
Бесконечные микрозадачи полностью блокируют Event Loop на этапе обработки микрозадач, не давая выполниться рендерингу и макрозадачам (включая пользовательские события). Это делает страницу полностью неотзывчивой.
Вопрос 7. Что произойдет с пользовательскими событиями (click, scroll) при блокировке микрозадачами?
Таймкод: 00:09:42
Ответ собеседника: Правильный. Пользовательские события попадают в очередь макрозадач и обрабатываются по одной после завершения микрозадач. Если микрозадачи бесконечны, события не будут обработаны.
Правильный ответ:
Судьба пользовательских событий при блокировке микрозадачами
Все пользовательские события в браузере реализованы как макрозадачи и обрабатываются только после полной очистки очереди микрозадач.
Типы событий и их очереди
// Все эти события — макрозадачи
element.addEventListener('click', handler); // Макрозадача
element.addEventListener('mousedown', handler); // Макрозадача
element.addEventListener('mouseup', handler); // Макрозадача
element.addEventListener('keydown', handler); // Макрозадача
element.addEventListener('keyup', handler); // Макрозадача
element.addEventListener('scroll', handler); // Макрозадача
element.addEventListener('touchstart', handler); // Макрозадача
element.addEventListener('input', handler); // Макрозадача
element.addEventListener('focus', handler); // Макрозадача
element.addEventListener('blur', handler); // Макрозадача
// Сетевые события — тоже макрозадачи
fetch('/api').then(response => { /* ... */ }); // then — микрозадача, но fetch callback — макрозадача
// Таймеры — макрозадачи
setTimeout(handler, 0); // Макрозадача
setInterval(handler, 100); // Макрозадача
Механизм блокировки
// Пользователь кликает по кнопке
button.addEventListener('click', () => {
console.log('Клик обработан');
});
// Но у нас бесконечные микрозадачи
function blockForever() {
Promise.resolve().then(blockForever);
}
blockForever();
// Что происходит:
// 1. Пользователь кликает
// 2. Браузер помещает click в очередь макрозадач
// 3. Event Loop продолжает обрабатывать микрозадачи
// 4. Очередь макрозадач не обрабатывается
// 5. Клик ждёт в очереди... бесконечно
Визуализация очереди событий
Очередь микрозадач: [■■][■■][■■][■■][■■][■■][■■][■■]...
↓
Event Loop застревает здесь
↓
Очередь макрозадач: [click][scroll][keydown][resize]...
↑
События накапливаются, но не обрабатываются
Демонстрация с логированием
const eventLog = [];
function logEvent(name) {
eventLog.push({
event: name,
time: performance.now()
});
console.log(`${name} в ${performance.now().toFixed(2)}мс`);
}
// Подписываемся на события
document.addEventListener('click', () => logEvent('click'));
document.addEventListener('mousemove', () => logEvent('mousemove'));
document.addEventListener('scroll', () => logEvent('scroll'));
document.addEventListener('keydown', () => logEvent('keydown'));
// Блокируем микрозадачами
function block() {
Promise.resolve().then(block);
}
block();
// Пользователь взаимодействует со страницей...
// События добавляются в очередь, но не обрабатываются
// Через 5 секунд разблокируем
setTimeout(() => {
console.log('Разблокировка');
// Теперь все накопленные события обработаются
}, 5000);
Что происходит с событиями при разблокировке
let blocked = true;
function maybeBlock() {
if (blocked) {
Promise.resolve().then(maybeBlock);
}
}
maybeBlock();
// Пользователь кликает 10 раз за 5 секунд
// Все 10 кликов в очереди макрозадач
setTimeout(() => {
blocked = false;
// События начнут обрабатываться одно за другим
// Но НЕ все сразу — по одному за каждый цикл Event Loop
}, 5000);
Особенности обработки событий
// События обрабатываются последовательно
button.addEventListener('click', () => {
console.log('Первый обработчик');
// Это НЕ заблокирует обработку следующего клика
// Потому что setTimeout — макрозадача
setTimeout(() => {
console.log('Отложенная обработка');
}, 0);
});
button.addEventListener('click', () => {
console.log('Второй обработчик');
});
Влияние на разные типы событий
| Тип события | Поведение при блокировке |
|---|---|
| click | Накапливается в очереди |
| scroll | Накапливается (браузер может объединить) |
| keydown/keyup | Накапливается |
| mousemove | Накапливается (браузер может пропускать) |
| input | Накапливается |
| resize | Накапливается (браузер может дросселировать) |
| focus/blur | Накапливается |
Особый случай: scroll
// Браузер может оптимизировать scroll события
window.addEventListener('scroll', () => {
console.log('Scroll event');
});
// Но при блокировке микрозадачами даже оптимизированные
// события не будут обработаны
Практический пример: потеря событий
class EventCounter {
constructor() {
this.clickCount = 0;
this.actualClicks = 0;
// Считаем реальные клики
document.addEventListener('click', () => {
this.actualClicks++;
});
// Считаем обработанные клики
document.addEventListener('click', () => {
this.clickCount++;
this.updateDisplay();
});
}
updateDisplay() {
console.log(`Реальных кликов: ${this.actualClicks}`);
console.log(`Обработано: ${this.clickCount}`);
console.log(`Потеряно: ${this.actualClicks - this.clickCount}`);
}
}
const counter = new EventCounter();
// Блокируем
function block() {
Promise.resolve().then(block);
}
block();
// Пользователь кликает...
// actualClicks растёт, clickCount нет
Как избежать потери событий
// 1. Не блокируйте основной поток
// 2. Используйте Web Workers для тяжёлых вычислений
// 3. Если нужна отложенная обработка, используйте setTimeout
function processWithDelay(callback) {
setTimeout(callback, 0); // Макрозадача, не блокирует
}
// 4. Используйте requestIdleCallback для несрочных задач
function processInIdle(callback) {
requestIdleCallback(callback);
}
Вывод:
Пользовательские события при блокировке микрозадачами накапливаются в очереди макрозадач, но не обрабатываются. Они будут ожидать до тех пор, пока очередь микрозадач не станет пустой. При бесконечной блокировке события теряются или обрабатываются с огромной задержкой.
Вопрос 8. Реализовать класс Event Emitter с возможностью подписки на события, отписки и уведомления всех подписчиков.
Таймкод: 00:10:21
Ответ собеседника: Правильный. Создан класс с объектом для хранения событий и массивов колбеков. Реализованы методы: subscribe (добавляет колбек), unsubscribe (фильтрует колбек из массива), emit (вызывает все колбеки события с аргументами).
Правильный ответ:
Базовая реализация Event Emitter
class EventEmitter {
constructor() {
this.events = new Map();
}
subscribe(event, callback) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(callback);
// Возвращаем функцию для отписки
return () => this.unsubscribe(event, callback);
}
unsubscribe(event, callback) {
if (!this.events.has(event)) {
return;
}
const callbacks = this.events.get(event);
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
}
// Удаляем пустой массив
if (callbacks.length === 0) {
this.events.delete(event);
}
}
emit(event, ...args) {
if (!this.events.has(event)) {
return;
}
// Создаём копию массива на случай изменений во время итерации
const callbacks = [...this.events.get(event)];
for (const callback of callbacks) {
callback(...args);
}
}
}
Расширенная реализация с дополнительными возможностями
class AdvancedEventEmitter {
constructor() {
this.events = new Map();
this.onceEvents = new Map();
this.maxListeners = 10;
}
// Установка максимального количества слушателей
setMaxListeners(n) {
this.maxListeners = n;
return this;
}
// Подписка на событие
on(event, callback) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
const listeners = this.events.get(event);
// Предупреждение о превышении лимита
if (listeners.length >= this.maxListeners) {
console.warn(
`Превышен лимит слушателей для события "${event}". ` +
`Максимум: ${this.maxListeners}, текущее: ${listeners.length + 1}`
);
}
listeners.push(callback);
return this;
}
// Подписка на одноразовое событие
once(event, callback) {
const wrapper = (...args) => {
callback(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
// Отписка от события
off(event, callback) {
if (!this.events.has(event)) {
return this;
}
if (!callback) {
// Удаляем все слушатели события
this.events.delete(event);
return this;
}
const listeners = this.events.get(event);
const index = listeners.indexOf(callback);
if (index !== -1) {
listeners.splice(index, 1);
}
if (listeners.length === 0) {
this.events.delete(event);
}
return this;
}
// Генерация события
emit(event, ...args) {
if (!this.events.has(event)) {
return false;
}
// Копируем массив для безопасной итерации
const listeners = [...this.events.get(event)];
for (const listener of listeners) {
try {
listener(...args);
} catch (error) {
this.handleError(error, event, listener);
}
}
return true;
}
// Обработка ошибок
handleError(error, event, listener) {
if (this.events.has('error')) {
this.emit('error', error);
} else {
console.error(`Ошибка в слушателе события "${event}":`, error);
}
}
// Получение количества слушателей
listenerCount(event) {
return this.events.has(event) ? this.events.get(event).length : 0;
}
// Получение списка событий
eventNames() {
return Array.from(this.events.keys());
}
// Удаление всех слушателей
removeAllListeners(event) {
if (event) {
this.events.delete(event);
} else {
this.events.clear();
}
return this;
}
}
Типизированная версия с TypeScript
type EventCallback<T = any> = (data: T) => void;
class TypedEventEmitter<Events extends Record<string, any>> {
private listeners: {
[K in keyof Events]?: EventCallback<Events[K]>[];
} = {};
on<K extends keyof Events>(event: K, callback: EventCallback<Events[K]>): () => void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(callback);
return () => this.off(event, callback);
}
off<K extends keyof Events>(event: K, callback: EventCallback<Events[K]>): void {
const callbacks = this.listeners[event];
if (!callbacks) return;
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
}
}
emit<K extends keyof Events>(event: K, data: Events[K]): void {
const callbacks = this.listeners[event];
if (!callbacks) return;
for (const callback of [...callbacks]) {
callback(data);
}
}
}
// Использование
interface MyEvents {
userLogin: { userId: string; timestamp: number };
dataUpdate: { key: string; value: any };
error: Error;
}
const emitter = new TypedEventEmitter<MyEvents>();
emitter.on('userLogin', (data) => {
console.log(`Пользователь ${data.userId} вошёл в ${data.timestamp}`);
});
emitter.emit('userLogin', { userId: '123', timestamp: Date.now() });
Пример использования
const emitter = new AdvancedEventEmitter();
// Подписка
const unsubscribe = emitter.on('user:login', (user) => {
console.log(`${user.name} вошёл в систему`);
});
emitter.on('user:login', (user) => {
console.log(`Отправка уведомления для ${user.name}`);
});
// Одноразовая подписка
emitter.once('app:start', () => {
console.log('Приложение запущено (только один раз)');
});
// Генерация событий
emitter.emit('user:login', { name: 'Иван', id: 1 });
// Вывод:
// Иван вошёл в систему
// Отправка уведомления для Иван
emitter.emit('app:start');
// Вывод: Приложение запущено (только один раз)
emitter.emit('app:start');
// Ничего не происходит — слушатель удалён
// Отписка
unsubscribe();
// Или через off
emitter.off('user:login', specificCallback);
// Информация о слушателях
console.log(emitter.listenerCount('user:login')); // 1
console.log(emitter.eventNames()); // ['user:login']
Продвинутые возможности
// Цепочка вызовов (chaining)
emitter
.on('event1', handler1)
.on('event2', handler2)
.emit('event1', data);
// Async emitter
class AsyncEventEmitter extends AdvancedEventEmitter {
async emitAsync(event, ...args) {
if (!this.events.has(event)) {
return [];
}
const listeners = [...this.events.get(event)];
const results = [];
for (const listener of listeners) {
try {
const result = await listener(...args);
results.push(result);
} catch (error) {
this.handleError(error, event, listener);
}
}
return results;
}
}
// Использование
const asyncEmitter = new AsyncEventEmitter();
asyncEmitter.on('fetch', async (url) => {
const response = await fetch(url);
return response.json();
});
const results = await asyncEmitter.emitAsync('fetch', '/api/data');
Вывод:
Event Emitter — это паттерн наблюдатель, который позволяет объектам подписываться на события и получать уведомления. Базовая реализация включает методы on/subscribe, off/unsubscribe и emit. Расширенные версии добавляют поддержку одноразовых слушателей, обработку ошибок, цепочку вызовов и асинхронную генерацию событий.
Вопрос 9. Что произойдет при возникновении ошибки в одном из колбеков Event Emitter и как обеспечить глобальную обработку ошибок без оборачивания каждого вызова в try/catch?
Таймкод: 00:14:30
Ответ собеседника: Неполный. Предложено обернуть вызовы в try/catch или использовать глобальную обработку ошибок. Упомянуто, что в Node.js можно подписаться на события процесса (process.on('uncaughtException')), но в браузере такого функционала нет. В браузере можно использовать window.onerror или обработчик события error.
Правильный ответ:
Проблема ошибок в колбеках Event Emitter
При возникновении ошибки в одном из слушателей, последующие слушатели не будут вызваны, если нет централизованной обработки ошибок.
Что происходит без обработки ошибок
class EventEmitter {
constructor() {
this.events = new Map();
}
on(event, callback) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(callback);
}
emit(event, ...args) {
if (!this.events.has(event)) return;
const callbacks = this.events.get(event);
// ❌ Проблема: ошибка в первом колбеке прерывает цепочку
for (const callback of callbacks) {
callback(...args); // Если здесь ошибка — остальные не вызовутся
}
}
}
const emitter = new EventEmitter();
emitter.on('test', () => {
console.log('Первый обработчик');
});
emitter.on('test', () => {
throw new Error('Ошибка!'); // Прерывает выполнение
});
emitter.on('test', () => {
console.log('Третий обработчик'); // Не выполнится
});
emitter.emit('test');
// Вывод: Первый обработчик
// Затем необработанная ошибка
Решение 1: Встроенная обработка ошибок в Emitter
class SafeEventEmitter {
constructor() {
this.events = new Map();
}
on(event, callback) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(callback);
return this;
}
emit(event, ...args) {
if (!this.events.has(event)) return false;
const callbacks = [...this.events.get(event)];
for (const callback of callbacks) {
try {
callback(...args);
} catch (error) {
// Централизованная обработка ошибки
this.handleError(error, event, callback);
}
}
return true;
}
handleError(error, event, callback) {
// Если есть слушатели события 'error' — делегируем им
if (this.events.has('error')) {
this.emit('error', error, event, callback);
} else {
// Иначе используем глобальную обработку
this.fallbackErrorHandling(error, event, callback);
}
}
fallbackErrorHandling(error, event, callback) {
console.error(`Ошибка в событии "${event}":`, error);
// Отправка в сервис мониторинга
if (typeof window !== 'undefined' && window.Sentry) {
window.Sentry.captureException(error, {
tags: { event },
extra: { callback: callback.toString() }
});
}
}
}
// Использование
const emitter = new SafeEventEmitter();
// Глобальный обработчик ошибок
emitter.on('error', (error, event) => {
console.log(`Ошибка в событии ${event}: ${error.message}`);
// Логирование, уведомления и т.д.
});
emitter.on('test', () => {
console.log('Первый обработчик');
});
emitter.on('test', () => {
throw new Error('Ошибка!');
});
emitter.on('test', () => {
console.log('Третий обработчик'); // Выполнится!
});
emitter.emit('test');
// Вывод:
// Первый обработчик
// Ошибка в событии test: Ошибка!
// Третий обработчик
Решение 2: Глобальная обработка в браузере
// Обработчик непойманных ошибок
window.onerror = function(message, source, lineno, colno, error) {
console.log('Глобальная ошибка:', {
message,
source,
line: lineno,
column: colno,
error
});
// Отправка в аналитику
sendToAnalytics({ type: 'error', message, error });
// Предотвращаем стандартное поведение
return true;
};
// Обработчик непойманных Promise rejection
window.addEventListener('unhandledrejection', function(event) {
console.log('Необработанный Promise rejection:', event.reason);
sendToAnalytics({ type: 'unhandledrejection', reason: event.reason });
event.preventDefault();
});
// Использование с Event Emitter
class BrowserSafeEmitter extends SafeEventEmitter {
fallbackErrorHandling(error, event, callback) {
// Выбрасываем ошибку для глобального обработчика
setTimeout(() => {
throw error;
}, 0);
}
}
Решение 3: Глобальная обработка в Node.js
// Непойманные исключения
process.on('uncaughtException', (error, origin) => {
console.log('Непойманное исключение:', error);
console.log('Источник:', origin);
// Логирование
logger.fatal({ error, origin }, 'Uncaught exception');
// Graceful shutdown
gracefulShutdown(1);
});
// Необработанные Promise rejection
process.on('unhandledRejection', (reason, promise) => {
console.log('Необработанный rejection:', reason);
logger.error({ reason }, 'Unhandled rejection');
});
// Предупреждения
process.on('warning', (warning) => {
console.log('Предупреждение:', warning.name, warning.message);
});
// Использование с Event Emitter
class NodeSafeEmitter extends SafeEventEmitter {
fallbackErrorHandling(error, event, callback) {
// В Node.js лучше использовать domains или async_hooks
// или просто логировать
console.error(`[${event}]`, error);
// Уведомление через process.emitWarning
process.emitWarning(error, 'EventEmitterError');
}
}
Решение 4: Продвинутая система обработки ошибок
class RobustEventEmitter {
constructor(options = {}) {
this.events = new Map();
this.errorHandlers = new Set();
this.options = {
maxRetries: options.maxRetries || 0,
retryDelay: options.retryDelay || 1000,
logErrors: options.logErrors !== false,
...options
};
}
// Добавление обработчика ошибок
onError(handler) {
this.errorHandlers.add(handler);
return () => this.errorHandlers.delete(handler);
}
emit(event, ...args) {
if (!this.events.has(event)) return false;
const callbacks = [...this.events.get(event)];
const errors = [];
for (const callback of callbacks) {
this.executeWithErrorHandling(callback, args, event, errors);
}
// Если были ошибки и нет слушателей 'error'
if (errors.length > 0 && !this.events.has('error')) {
this.notifyErrorHandlers(errors, event);
}
return true;
}
executeWithErrorHandling(callback, args, event, errors) {
try {
callback(...args);
} catch (error) {
errors.push({ error, callback, event });
if (this.options.logErrors) {
console.error(`[${event}]`, error);
}
// Повторная попытка если настроена
if (this.options.maxRetries > 0) {
this.retryExecution(callback, args, event);
}
}
}
retryExecution(callback, args, event, attempt = 1) {
setTimeout(() => {
try {
callback(...args);
} catch (error) {
if (attempt < this.options.maxRetries) {
this.retryExecution(callback, args, event, attempt + 1);
} else {
this.notifyErrorHandlers([{ error, callback, event }], event);
}
}
}, this.options.retryDelay * attempt);
}
notifyErrorHandlers(errors, event) {
for (const handler of this.errorHandlers) {
try {
handler(errors, event);
} catch (handlerError) {
console.error('Ошибка в обработчике ошибок:', handlerError);
}
}
}
}
// Использование
const emitter = new RobustEventEmitter({
maxRetries: 3,
retryDelay: 1000
});
// Глобальный обработчик ошибок
emitter.onError((errors, event) => {
console.log(`В событии "${event}" произошло ${errors.length} ошибок`);
for (const { error, callback } of errors) {
console.log(`- ${error.message} в ${callback.name || 'anonymous'}`);
}
});
Решение 5: Асинхронная обработка ошибок
class AsyncSafeEmitter extends SafeEventEmitter {
async emitAsync(event, ...args) {
if (!this.events.has(event)) return [];
const callbacks = [...this.events.get(event)];
const results = [];
const errors = [];
for (const callback of callbacks) {
try {
const result = await callback(...args);
results.push({ success: true, result });
} catch (error) {
errors.push({ error, callback, event });
results.push({ success: false, error });
}
}
if (errors.length > 0) {
this.handleAsyncErrors(errors, event);
}
return results;
}
handleAsyncErrors(errors, event) {
if (this.events.has('error')) {
for (const { error } of errors) {
this.emit('error', error, event);
}
} else {
// Глобальная обработка
Promise.reject(new AggregateError(errors.map(e => e.error),
`Errors in event ${event}`));
}
}
}
Сравнение подходов
| Подход | Плюсы | Минусы |
|---|---|---|
| try/catch в emit | Простота, контроль | Многословность |
| Событие 'error' | Стандартный паттерн | Нужна подписка |
| window.onerror | Глобальная ловушка | Ограниченная информация |
| process.on | Полный контроль | Только Node.js |
| Продвинутый emitter | Гибкость, retry | Сложность |
Рекомендации
- Всегда добавляйте обработку ошибок в Event Emitter
- Используйте событие 'error' для централизованной обработки
- Настройте глобальные обработчики для непойманных ошибок
- Логируйте ошибки для отладки
- Рассмотрите retry-механизм для критичных операций
Вопрос 10. Какие компоненты будут перерендерены при клике на кнопку в React-приложении и как предотвратить перерендер дочерних компонентов?
Таймкод: 00:20:06
Ответ собеседника: Правильный. При клике вызывается forceUpdate, который меняет state в App, что приводит к перерендеру Parent и Child. Чтобы предотвратить перерендер дочерних компонентов, нужно обернуть Parent в React.memo, так как пропсы не передаются и он не будет перерендериваться при изменении состояния родителя.
Правильный ответ:
Механизм перерендера в React
По умолчанию React перерендеряет компонент и всех его потомков при изменении состояния или пропсов.
Пример проблемы
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
<Parent />
</div>
);
}
function Parent() {
console.log('Parent render');
return (
<div>
<Child />
</div>
);
}
function Child() {
console.log('Child render');
return <div>I am child</div>;
}
// При клике на кнопку вывод:
// Parent render
// Child render
Способы предотвращения перерендера
1. React.memo — мемоизация компонента
// Простое использование
const MemoChild = React.memo(function Child() {
console.log('Child render');
return <div>I am child</div>;
});
// С кастомным сравнением
const MemoChild = React.memo(function Child({ name, age }) {
console.log('Child render');
return <div>{name}: {age}</div>;
}, (prevProps, nextProps) => {
// Возвращаем true чтобы ПРЕДОТВРАТИТЬ перерендер
return prevProps.name === nextProps.name;
});
2. useMemo — мемоизация вычислений
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// Мемоизируем вычисления
const expensiveValue = useMemo(() => {
console.log('Computing expensive value');
return computeExpensive(count);
}, [count]);
// Мемоизируем объекты (важно для пропсов)
const childProps = useMemo(() => ({
value: expensiveValue,
onClick: () => console.log('clicked')
}), [expensiveValue]);
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<Child {...childProps} />
</div>
);
}
3. useCallback — мемоизация функций
function Parent() {
const [count, setCount] = useState(0);
// ❌ Каждый рендер создаёт новую функцию
const handleClick = () => {
console.log('clicked');
};
// ✅ Функция мемоизирована
const memoizedHandleClick = useCallback(() => {
console.log('clicked');
}, []); // или [зависимости]
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoChild onClick={memoizedHandleClick} />
</div>
);
}
const MemoChild = React.memo(function Child({ onClick }) {
console.log('Child render');
return <button onClick={onClick}>Click me</button>;
});
4. Правильное разделение состояния
// ❌ Плохо: состояние в родителе
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveComponent /> {/* Перерендерится при изменении count */}
</div>
);
}
// ✅ Хорошо: состояние ближе к использованию
function App() {
return (
<div>
<Counter /> {/* Изолированное состояние */}
<ExpensiveComponent /> {/* Не перерендерится */}
</div>
);
}
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
5. Context с селекторами
// ❌ Плохо: все потребители перерендерятся
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
// При изменении user ИЛИ theme все потребители перерендерятся
const value = { user, setUser, theme, setTheme };
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
// ✅ Хорошо: разделение контекстов
const UserContext = createContext();
const ThemeContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
return (
<UserContext.Provider value={{ user, setUser }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
</UserContext.Provider>
);
}
// Компонент перерендерится только при изменении user
function UserProfile() {
const { user } = useContext(UserContext);
return <div>{user?.name}</div>;
}
6. State management с селекторами (Redux, Zustand)
// Zustand пример
const useStore = create((set) => ({
count: 0,
text: '',
increment: () => set((state) => ({ count: state.count + 1 })),
setText: (text) => set({ text })
}));
// Компонент перерендерится только при изменении count
function Counter() {
const count = useStore((state) => state.count); // Селектор
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
// Этот компонент НЕ перерендерится при изменении count
function TextDisplay() {
const text = useStore((state) => state.text);
return <div>{text}</div>;
}
Полный пример оптимизации
import React, { useState, useMemo, useCallback, memo } from 'react';
// Мемоизированный дочерний компонент
const ExpensiveChild = memo(function ExpensiveChild({ data, onAction }) {
console.log('ExpensiveChild render');
// Тяжёлые вычисления внутри дочернего компонента
const processedData = useMemo(() => {
return data.map(item => heavyProcessing(item));
}, [data]);
return (
<div>
{processedData.map(item => (
<div key={item.id} onClick={() => onAction(item.id)}>
{item.name}
</div>
))}
</div>
);
});
function Parent() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([]);
const [filter, setFilter] = useState('');
// Мемоизируем фильтрацию
const filteredItems = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
}, [items, filter]);
// Мемоизируем обработчик
const handleAction = useCallback((id) => {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, active: !item.active } : item
));
}, []);
// Мемоизируем пропсы
const childData = useMemo(() => filteredItems, [filteredItems]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Filter..."
/>
<ExpensiveChild
data={childData}
onAction={handleAction}
/>
</div>
);
}
Инструменты для отладки
// React DevTools Profiler
// Показывает какие компоненты перерендерились и почему
// Кастомный хук для логирования
function useRenderCount(componentName) {
const renderCount = useRef(0);
useEffect(() => {
renderCount.current++;
console.log(`${componentName} rendered ${renderCount.current} times`);
});
}
// Использование
function MyComponent() {
useRenderCount('MyComponent');
return <div>Hello</div>;
}
Когда оптимизировать
- Компонент рендерится часто без изменений
- Дочерний компонент выполняет тяжёлые вычисления
- Большое дерево компонентов
- Заметные проблемы с производительностью
Чего избегать
- Преждевременная оптимизация
- Чрезмерное использование memo/useMemo
- Мемоизация простых компонентов
- Забывание о зависимостях в хуках
Вопрос 11. Будет ли перерендериваться компонент Parent при вводе текста в компоненте Child и какие есть причины рендера помимо изменения state?
Таймкод: 00:22:44
Ответ собеседника: Правильный. Да, Parent будет перерендериваться, потому что handleChange создаёт новую ссылку на функцию при каждом вызове, что приводит к изменению пропсов и вызывает перерендер Child. Это дополнительная причина рендера помимо изменения state.
Правильный ответ:
Причины перерендера компонента Parent
Parent перерендерится не из-за изменения собственного state, а из-за того, что React по умолчанию перерендеряет всех потомков при рендере родителя.
Цепочка перерендера
function Parent() {
const [text, setText] = useState('');
// ❌ Новая функция при каждом рендере Parent
const handleChange = (e) => {
setText(e.target.value);
};
console.log('Parent render');
return (
<div>
<Child onChange={handleChange} />
<div>{text}</div>
</div>
);
}
const Child = React.memo(function Child({ onChange }) {
console.log('Child render');
return <input onChange={onChange} />;
});
Почему Child перерендерится несмотря на React.memo:
- Parent вызывает
setText→ перерендер Parent - При рендере Parent создаётся новая функция
handleChange - Проп
onChangeполучает новую ссылку - React.memo видит изменение пропсов → перерендер Child
Все причины рендера в React
1. Изменение состояния (useState, useReducer)
function Component() {
const [count, setCount] = useState(0);
// Вызывает перерендер
const handleClick = () => setCount(c => c + 1);
return <button onClick={handleClick}>{count}</button>;
}
2. Изменение пропсов
function Child({ value }) {
console.log('Child render');
return <div>{value}</div>;
}
function Parent() {
const [count, setCount] = useState(0);
// При изменении count меняется проп value
// Child перерендерится
return <Child value={count} />;
}
3. Перерендер родителя (по умолчанию)
function Parent() {
const [count, setCount] = useState(0);
console.log('Parent render');
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child />
</div>
);
}
// Child перерендерится при каждом клике,
// даже если не получает пропсов
function Child() {
console.log('Child render');
return <div>I am child</div>;
}
4. Изменение контекста
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// При изменении theme все потребители перерендерятся
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
function ThemedComponent() {
const { theme } = useContext(ThemeContext);
// Перерендерится при изменении theme
return <div className={theme}>Content</div>;
}
5. Изменение ref (не вызывает перерендер)
function Component() {
const ref = useRef(0);
// ❌ Это НЕ вызовет перерендер
const handleClick = () => {
ref.current += 1;
};
return <button onClick={handleClick}>Click</button>;
}
6. forceUpdate (классовые компоненты)
class Component extends React.Component {
handleClick = () => {
// Принудительный перерендер
this.forceUpdate();
};
render() {
return <button onClick={this.handleClick}>Force Update</button>;
}
}
7. Хук useSyncExternalStore при изменении внешнего хранилища
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
function Component() {
const count = useStore((state) => state.count);
// Перерендерится при изменении count в store
return <div>{count}</div>;
}
Проблема с функциями в пропсах
// ❌ Плохо: новая функция каждый рендер
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => {
console.log('clicked');
};
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoChild onClick={handleClick} />
</div>
);
}
// ✅ Хорошо: мемоизированная функция
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoChild onClick={handleClick} />
</div>
);
}
const MemoChild = React.memo(function Child({ onClick }) {
console.log('Child render');
return <button onClick={onClick}>Click me</button>;
});
Проблема с объектами в пропсах
// ❌ Плохо: новый объект каждый рендер
function Parent() {
const [count, setCount] = useState(0);
const config = {
color: 'red',
size: 'large'
};
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoChild config={config} />
</div>
);
}
// ✅ Хорошо: мемоизированный объект
function Parent() {
const [count, setCount] = useState(0);
const config = useMemo(() => ({
color: 'red',
size: 'large'
}), []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoChild config={config} />
</div>
);
}
Визуализация причин рендера
┌─────────────────────────────────────────────┐
│ Причины рендера React │
├─────────────────────────────────────────────┤
│ │
│ 1. setState / dispatch │
│ ↓ │
│ 2. Изменение пропсов │
│ ↓ │
│ 3. Перерендер родителя │
│ ↓ │
│ 4. Изменение контекста │
│ ↓ │
│ 5. forceUpdate (class components) │
│ ↓ │
│ 6. Изменение внешнего хранилища │
│ │
│ НЕ вызывает перерендер: │
│ - Изменение useRef │
│ - Изменение переменных вне state │
│ │
└─────────────────────────────────────────────┘
Практический пример: все причины вместе
const ThemeContext = createContext();
function App() {
// Причина 1: собственное состояние
const [count, setCount] = useState(0);
const [theme, setTheme] = useState('light');
// Причина 4: изменение контекста
const contextValue = useMemo(() => ({
theme,
setTheme
}), [theme]);
// Причина 2: новые ссылки на объекты
const config = useMemo(() => ({
count,
timestamp: Date.now()
}), [count]);
// Причина 2: новая функция
const handleAction = useCallback(() => {
console.log('action');
}, []);
return (
<ThemeContext.Provider value={contextValue}>
<Parent
count={count}
config={config}
onAction={handleAction}
onIncrement={() => setCount(c => c + 1)}
/>
</ThemeContext.Provider>
);
}
const Parent = React.memo(function Parent({ count, config, onAction, onIncrement }) {
// Причина 3: перерендер из-за изменения пропсов
console.log('Parent render');
return (
<div>
<button onClick={onIncrement}>Count: {count}</button>
<Child onAction={onAction} />
</div>
);
});
const Child = React.memo(function Child({ onAction }) {
// Причина 3: перерендер из-за перерендера родителя
console.log('Child render');
return <button onClick={onAction}>Action</button>;
});
Вывод:
Parent перерендерится при вводе текста в Child из-за изменения собственного state (setText). Это стандартное поведение React. Дополнительные причины рендера включают изменение пропсов, перерендер родителя, изменение контекста и forceUpdate. useRef и внешние переменные не вызывают перерендер.
Вопрос 12. Для чего нужен второй аргумент (массив зависимостей) в React.useCallback и React.memo, и почему не нужно передавать setState в зависимости?
Таймкод: 00:25:35
Ответ собеседника: Правильный. Массив зависимостей нужен для того, чтобы мемоизированная функция/компонент обновлялись при изменении указанных значений. setState не нужно передавать, потому что React гарантирует, что ссылка на setter остаётся стабильной и не меняется между рендерами.
Правильный ответ:
Назначение массива зависимостей
Массив зависимостей определяет, когда должна быть пересоздана мемоизированная функция или пересчитано значение.
Принцип работы
// Без зависимостей — функция создаётся один раз
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // Пустай массив = никогда не пересоздаётся
// С зависимостями — функция пересоздаётся при изменении
const handleClick = useCallback(() => {
console.log('Count is:', count);
}, [count]); // Пересоздаётся когда count изменится
// Без массива — функция пересоздаётся каждый рендер
const handleClick = useCallback(() => {
console.log('Count is:', count);
}); // Нет массива = бесполезная мемоизация
useCallback с зависимостями
function Component() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// Зависит от count — пересоздаётся при изменении count
const increment = useCallback(() => {
setCount(c => c + 1);
}, []); // Можно оставить пустым, т.к. setCount стабилен
// Зависит от name — пересоздаётся при изменении name
const greet = useCallback(() => {
console.log(`Hello, ${name}!`);
}, [name]); // name должно быть в зависимостях
// Зависит от обоих
const logBoth = useCallback(() => {
console.log({ count, name });
}, [count, name]); // Оба в зависимостях
return (
<div>
<button onClick={increment}>Count: {count}</button>
<button onClick={greet}>Greet</button>
<input value={name} onChange={e => setName(e.target.value)} />
</div>
);
}
React.memo с кастомным сравнением
const MemoChild = React.memo(function Child({ name, age, onClick }) {
console.log('Child render');
return (
<div onClick={onClick}>
{name}: {age}
</div>
);
}, (prevProps, nextProps) => {
// Возвращаем true чтобы ПРЕДОТВРАТИТЬ перерендер
// Возвращаем false чтобы РАЗРЕШИТЬ перерендер
return (
prevProps.name === nextProps.name &&
prevProps.age === nextProps.age
// onClick не сравниваем — игнорируем изменения функции
);
});
Почему setState не нужно в зависимостях
function Component() {
const [count, setCount] = useState(0);
// ✅ Правильно: пустой массив зависимостей
const increment = useCallback(() => {
setCount(c => c + 1);
}, []); // setCount не в зависимостях
// ❌ Избыточно: setCount и так стабилен
const increment2 = useCallback(() => {
setCount(c => c + 1);
}, [setCount]); // Нет смысла
return <button onClick={increment}>{count}</button>;
}
Гарантии React для стабильных ссылок
// React гарантирует что эти ссылки стабильны:
const [state, setState] = useState(initial);
const [state, dispatch] = useReducer(reducer, initial);
const ref = useRef(initial);
const memo = useMemo(() => compute(), []); // При пустых зависимостях
// Эти ссылки МОГУТ меняться:
const value = props.value; // Зависит от родителя
const computed = useMemo(() => compute(a, b), [a, b]); // Зависит от a, b
const callback = useCallback(() => {}, [dep]); // Зависит от dep
Правила ESLint (exhaustive-deps)
// ESLint правило react-hooks/exhaustive-deps проверяет зависимости
function Component({ userId }) {
const [data, setData] = useState(null);
// ❌ ESLint предупредит: userId не в зависимостях
const fetchData = useCallback(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setData);
}, []); // ESLint: React Hook useCallback has a missing dependency: 'userId'
// ✅ Правильно
const fetchData = useCallback(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setData);
}, [userId]); // userId в зависимостях
}
Полный пример с правильными зависимостями
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Зависит от userId — новый запрос при изменении
const fetchUser = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [userId]); // userId в зависимостях
// Зависит от user — обновляется при смене пользователя
const displayName = useMemo(() => {
if (!user) return 'Loading...';
return `${user.firstName} ${user.lastName}`;
}, [user]); // user в зависимостях
// Не зависит от ничего — стабильная ссылка
const handleRefresh = useCallback(() => {
fetchUser();
}, [fetchUser]); // fetchUser в зависимостях
useEffect(() => {
fetchUser();
}, [fetchUser]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h1>{displayName}</h1>
<button onClick={handleRefresh}>Refresh</button>
</div>
);
}
Что должно быть в зависимостях
function Component({ propA, propB }) {
const [stateA, setStateA] = useState(0);
const [stateB, setStateB] = useState('');
// ✅ Должны быть в зависимостях:
// - props (propA, propB)
// - state (stateA, stateB)
// - вычисляемые значения на основе props/state
const computed = useMemo(() => {
return propA + stateA;
}, [propA, stateA]); // Оба в зависимостях
const handler = useCallback(() => {
console.log(propB, stateB);
}, [propB, stateB]); // Оба в зависимостях
// ❌ НЕ должны быть в зависимостях:
// - setState (setStateA, setStateB)
// - dispatch (из useReducer)
// - ref (useRef)
// - стабильные значения из замыкания
}
Ошибки с зависимостями
function Component() {
const [count, setCount] = useState(0);
// ❌ Ошибка: замыкание на устаревшем значении
const handleClick = useCallback(() => {
setTimeout(() => {
console.log(count); // Всегда 0!
}, 1000);
}, []); // count не в зависимостях
// ✅ Правильно: актуальное значение
const handleClick = useCallback(() => {
setTimeout(() => {
console.log(count); // Актуальное значение
}, 1000);
}, [count]); // count в зависимостях
// ✅ Или используем функциональный updater
const increment = useCallback(() => {
setCount(c => c + 1); // Не зависит от count
}, []); // Пустой массив — OK
}
Вывод:
Массив зависимостей контролирует пересоздание мемозированных значений. setState, dispatch и useRef имеют стабильные ссылки, гарантированные React, поэтому их не нужно включать в зависимости. В зависимости включаются только значения, которые могут измениться между рендерами: props, state и вычисляемые на их основе значения.
Вопрос 13. Что представляет собой второй аргумент React.memo (функция сравнения) и почему в задаче он не был указан явно?
Таймкод: 00:27:42
Ответ собеседника: Правильный. Второй аргумент React.memo — это функция предикат для кастомного сравнения пропсов (возвращает true, если обновление не нужно). В задаче не указан, потому что достаточно стандартного поверхностного сравнения (shallow comparison) по ссылкам, которое используется по умолчанию.
Правильный ответ:
Функция сравнения в React.memo
Второй аргумент React.memo — это функция предикат, которая позволяет кастомизировать логику сравнения пропсов.
Сигнатура функции
type ArePropsEqual<P> = (prevProps: P, nextProps: P) => boolean;
React.memo(Component, arePropsEqual);
Важно: Функция возвращает true, если пропсы равны (перерендер не нужен), и false, если пропсы различны (нужен перерендер). Это противоречит логике shouldComponentUpdate.
Стандартное поведение (без второго аргумента)
// React использует встроенную функцию shallowEqual
const MemoChild = React.memo(function Child({ name, age, onClick }) {
console.log('Child render');
return <div onClick={onClick}>{name}: {age}</div>;
});
// Эквивалентно:
const MemoChild = React.memo(Child, (prev, next) => {
// Поверхностное сравнение каждой пары ключ-значение
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) return false;
for (const key of prevKeys) {
if (prev[key] !== next[key]) {
return false; // Нашли различие — нужен перерендер
}
}
return true; // Все пропсы равны — перерендер не нужен
});
Кастомная функция сравнения
const MemoChild = React.memo(function Child({ user, items, config }) {
console.log('Child render');
return (
<div>
<h1>{user.name}</h1>
<ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
</div>
);
}, (prevProps, nextProps) => {
// Сравниваем только нужные поля
// Игнорируем изменения config
// Сравниваем только user.id, а не весь объект
// Сравниваем items по длине и id элементов
if (prevProps.user.id !== nextProps.user.id) return false;
if (prevProps.items.length !== nextProps.items.length) return false;
for (let i = 0; i < prevProps.items.length; i++) {
if (prevProps.items[i].id !== nextProps.items[i].id) {
return false;
}
}
return true; // Пропсы "равны" для наших целей
});
Когда нужна кастомная функция сравнения
// 1. Глубокое сравнение объектов
const MemoComponent = React.memo(function Component({ data }) {
return <div>{JSON.stringify(data)}</div>;
}, (prev, next) => {
return JSON.stringify(prev.data) === JSON.stringify(next.data);
});
// 2. Сравнение по конкретным полям
const MemoUser = React.memo(function User({ user }) {
return <div>{user.name}</div>;
}, (prev, next) => {
return prev.user.id === next.user.id;
});
// 3. Игнорирование определённых пропсов
const MemoChild = React.memo(function Child({ data, onAction }) {
return <div onClick={onAction}>{data}</div>;
}, (prev, next) => {
// Игнорируем изменения onAction
return prev.data === next.data;
});
// 4. Сравнение массивов
const MemoList = React.memo(function List({ items }) {
return <ul>{items.map(item => <li key={item}>{item}</li>)}</ul>;
}, (prev, next) => {
if (prev.items.length !== next.items.length) return false;
return prev.items.every((item, i) => item === next.items[i]);
});
Почему в задаче не нужен кастомный компаратор
// Задача: предотвратить перерендер Child при изменении state в Parent
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoChild />
</div>
);
}
// Стандартного сравнения достаточно!
const MemoChild = React.memo(function Child() {
console.log('Child render');
return <div>I am child</div>;
});
// Child не получает пропсов → стандартное сравнение сразу вернёт true
// Перерендер не произойдёт
Сравнение стандартного и кастомного подхода
// Стандартное сравнение (shallowEqual)
const ShallowMemo = React.memo(function Component({ a, b, c }) {
// Сравнение: prev.a === next.a && prev.b === next.b && prev.c === next.c
return <div>{a} {b} {c}</div>;
});
// Кастомное сравнение
const CustomMemo = React.memo(function Component({ a, b, c }) {
return <div>{a} {b} {c}</div>;
}, (prev, next) => {
// Сравниваем только a и b, игнорируем c
return prev.a === next.a && prev.b === next.b;
});
Продвинутые примеры
// Сравнение с учётом трансформаций
const MemoTransformed = React.memo(function Component({ matrix }) {
return <Canvas matrix={matrix} />;
}, (prev, next) => {
// Сравниваем только определённые элементы матрицы
const keys = ['scaleX', 'scaleY', 'rotation'];
return keys.every(key => prev.matrix[key] === next.matrix[key]);
});
// Сравнение с допуском для чисел
const MemoChart = React.memo(function Chart({ data }) {
return <svg>{/* рендер графика */}</svg>;
}, (prev, next) => {
const EPSILON = 0.001;
if (prev.data.length !== next.data.length) return false;
return prev.data.every((point, i) =>
Math.abs(point.x - next.data[i].x) < EPSILON &&
Math.abs(point.y - next.data[i].y) < EPSILON
);
});
Отличие от shouldComponentUpdate
// shouldComponentUpdate — для классовых компонентов
class MyComponent extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
// Возвращаем true для ОБНОВЛЕНИЯ
return this.props.value !== nextProps.value;
}
}
// React.memo с компаратором — для функциональных
const MyComponent = React.memo(function MyComponent({ value }) {
return <div>{value}</div>;
}, (prevProps, nextProps) => {
// Возвращаем true для ПРЕДОТВРАЩЕНИЯ обновления
return prevProps.value === nextProps.value;
});
Производительность
// ⚠️ Не стоит использовать тяжёлые сравнения
const BadMemo = React.memo(function Component({ data }) {
return <div>{/* ... */}</div>;
}, (prev, next) => {
// Глубокое сравнение может быть медленнее рендера!
return deepEqual(prev.data, next.data);
});
// ✅ Лучше использовать useMemo для стабилизации пропсов
function Parent() {
const data = useMemo(() => computeData(), [deps]);
return <Child data={data} />;
}
Вывод:
Второй аргумент React.memo — это функция сравнения, возвращающая true при равенстве пропсов. В задаче с предотвращением перерендера Child стандартного поверхностного сравнения достаточно, так как Child не получает пропсов и сравнение сразу вернёт true. Кастомный компаратор нужен для специфической логики сравнения.
Вопрос 14. Реализовать React-приложение поиска героев с инпутом, отправкой запроса при вводе текста, отображением списка имён, индикацией загрузки и обработкой ошибок (API-ошибки и сетевые).
Таймкод: 00:31:35
Ответ собеседника: Правильный. Реализовано приложение с input, состоянием для поискового запроса, списка результатов, индикации загрузки и обработки ошибок. Добавлена проверка на пустой инпут (запрос не отправляется), обработка API-ошибок (статус коды не 200) и сетевых (reject fetch). Выводится конкретное сообщение об ошибке. Исправлена семантика вёрстки (элементы списка вынесены в ul/li). Добавлен debounce через lodash для ограничения частоты запросов.
Правильный ответ:
Полная реализация приложения поиска
import React, { useState, useEffect, useCallback, useRef } from 'react';
// Кастомный хук для debounce
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Кастомный хук для запросов с отменой
function useAbortController() {
const abortControllerRef = useRef(null);
const getSignal = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
return abortControllerRef.current.signal;
}, []);
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return getSignal;
}
// Компонент индикатора загрузки
function LoadingSpinner() {
return (
<div className="loading-spinner" role="status" aria-label="Загрузка">
<div className="spinner"></div>
<span>Загрузка...</span>
</div>
);
}
// Компонент ошибки
function ErrorMessage({ error, onRetry }) {
const getErrorMessage = (error) => {
if (error.name === 'AbortError') {
return 'Запрос был отменён';
}
if (error.message.includes('NetworkError') || error.message.includes('fetch')) {
return 'Ошибка сети. Проверьте подключение к интернету.';
}
if (error.status) {
switch (error.status) {
case 400:
return 'Некорректный запрос';
case 401:
return 'Необходима авторизация';
case 403:
return 'Доступ запрещён';
case 404:
return 'Ресурс не найден';
case 429:
return 'Слишком много запросов. Попробуйте позже';
case 500:
return 'Ошибка сервера. Попробуйте позже';
default:
return `Ошибка: ${error.message}`;
}
}
return error.message || 'Произошла неизвестная ошибка';
};
return (
<div className="error-message" role="alert">
<span className="error-icon">⚠️</span>
<p>{getErrorMessage(error)}</p>
{onRetry && (
<button onClick={onRetry} className="retry-button">
Повторить
</button>
)}
</div>
);
}
// Компонент списка героев
function HeroesList({ heroes }) {
if (heroes.length === 0) {
return <p className="no-results">Герои не найдены</p>;
}
return (
<ul className="heroes-list" aria-label="Результаты поиска">
{heroes.map((hero) => (
<li key={hero.id} className="hero-item">
<span className="hero-name">{hero.name}</span>
{hero.power && <span className="hero-power">{hero.power}</span>}
</li>
))}
</ul>
);
}
// Основной компонент поиска
function HeroSearch() {
const [query, setQuery] = useState('');
const [heroes, setHeroes] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [hasSearched, setHasSearched] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const getSignal = useAbortController();
// Функция поиска
const searchHeroes = useCallback(async (searchQuery) => {
if (!searchQuery.trim()) {
setHeroes([]);
setHasSearched(false);
return;
}
setLoading(true);
setError(null);
setHasSearched(true);
try {
const signal = getSignal();
const response = await fetch(
`https://api.example.com/heroes?search=${encodeURIComponent(searchQuery)}`,
{ signal }
);
// Проверка HTTP-ошибок
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw {
status: response.status,
message: errorData?.message || response.statusText
};
}
const data = await response.json();
setHeroes(data.results || []);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
setHeroes([]);
}
} finally {
setLoading(false);
}
}, [getSignal]);
// Эффект для поиска при изменении debounced запроса
useEffect(() => {
searchHeroes(debouncedQuery);
}, [debouncedQuery, searchHeroes]);
// Обработчик ввода
const handleInputChange = (e) => {
setQuery(e.target.value);
};
// Обработчик очистки
const handleClear = () => {
setQuery('');
setHeroes([]);
setError(null);
setHasSearched(false);
};
// Повторный запрос
const handleRetry = () => {
searchHeroes(query);
};
return (
<div className="hero-search">
<h1>Поиск героев</h1>
<div className="search-container">
<input
type="text"
value={query}
onChange={handleInputChange}
placeholder="Введите имя героя..."
className="search-input"
aria-label="Поиск героев"
aria-describedby="search-hint"
/>
{query && (
<button
onClick={handleClear}
className="clear-button"
aria-label="Очистить поиск"
>
✕
</button>
)}
</div>
<p id="search-hint" className="search-hint">
Введите минимум 2 символа для поиска
</p>
<div className="results-container" aria-live="polite">
{loading && <LoadingSpinner />}
{error && !loading && (
<ErrorMessage error={error} onRetry={handleRetry} />
)}
{!loading && !error && hasSearched && (
<HeroesList heroes={heroes} />
)}
{!loading && !error && !hasSearched && query && (
<p className="search-prompt">Начните вводить для поиска</p>
)}
</div>
</div>
);
}
export default HeroSearch;
CSS стили
.hero-search {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.search-container {
position: relative;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
padding: 12px 40px 12px 16px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 8px;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #007bff;
}
.clear-button {
position: absolute;
right: 12px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
}
.clear-button:hover {
color: #333;
}
.search-hint {
font-size: 14px;
color: #666;
margin-top: 8px;
}
.loading-spinner {
display: flex;
align-items: center;
gap: 12px;
padding: 20px;
color: #666;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #ddd;
border-top-color: #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-message {
padding: 16px;
background: #fff3f3;
border: 1px solid #ffcdd2;
border-radius: 8px;
color: #c62828;
}
.error-icon {
margin-right: 8px;
}
.retry-button {
margin-top: 12px;
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.retry-button:hover {
background: #0056b3;
}
.heroes-list {
list-style: none;
padding: 0;
margin: 0;
}
.hero-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #eee;
transition: background 0.2s;
}
.hero-item:hover {
background: #f5f5f5;
}
.hero-name {
font-weight: 500;
}
.hero-power {
font-size: 14px;
color: #666;
background: #f0f0f0;
padding: 4px 8px;
border-radius: 4px;
}
.no-results {
text-align: center;
padding: 40px;
color: #666;
}
Продвинутая версия с кэшированием
// Хук с кэшированием
function useHeroSearch() {
const [query, setQuery] = useState('');
const [heroes, setHeroes] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [hasSearched, setHasSearched] = useState(false);
const cacheRef = useRef(new Map());
const abortControllerRef = useRef(null);
const searchHeroes = useCallback(async (searchQuery) => {
if (!searchQuery.trim()) {
setHeroes([]);
setHasSearched(false);
return;
}
// Проверка кэша
const cached = cacheRef.current.get(searchQuery);
if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
setHeroes(cached.data);
setHasSearched(true);
return;
}
setLoading(true);
setError(null);
setHasSearched(true);
// Отмена предыдущего запроса
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const response = await fetch(
`https://api.example.com/heroes?search=${encodeURIComponent(searchQuery)}`,
{ signal: abortControllerRef.current.signal }
);
if (!response.ok) {
throw { status: response.status, message: response.statusText };
}
const data = await response.json();
const results = data.results || [];
// Сохранение в кэш
cacheRef.current.set(searchQuery, {
data: results,
timestamp: Date.now()
});
setHeroes(results);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
setHeroes([]);
}
} finally {
setLoading(false);
}
}, []);
// Очистка кэша при размонтировании
useEffect(() => {
return () => {
cacheRef.current.clear();
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return {
query,
setQuery,
heroes,
loading,
error,
hasSearched,
searchHeroes
};
}
Ключевые особенности реализации:
- Debounce для ограничения частоты запросов
- AbortController для отмены предыдущих запросов
- Обработка сетевых ошибок и HTTP-ошибок
- Индикация загрузки
- Семантическая вёрстка (ul/li)
- ARIA-атрибуты для доступности
- Кэширование результатов
- Возможность повторного запроса
Вопрос 15. Для чего нужен debounce и как правильно его применить в React-компоненте, чтобы функция не пересоздавалась при каждом рендере?
Таймкод: 00:49:50
Ответ собеседника: Правильный. Debounce нужен для ограничения частоты вызовов функции — выполняется только последний вызов через указанный промежуток времени. Чтобы функция debounce не пересоздавалась при каждом рендере, её нужно вынести за пределы компонента или использовать useCallback с правильными зависимостями.
Правильный ответ:
Назначение debounce
Debounce — это техника, которая откладывает выполнение функции до тех пор, пока не пройдёт определённый период времени без новых вызовов. Это особенно полезно для обработки частых событий.
Проблема без debounce
// ❌ Плохо: запрос на каждый символ
function SearchComponent() {
const [results, setResults] = useState([]);
const handleChange = async (e) => {
const query = e.target.value;
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
setResults(data);
};
return (
<input
onChange={handleChange}
placeholder="Поиск..."
/>
);
}
// Пользователь вводит "React":
// Запрос 1: "R"
// Запрос 2: "Re"
// Запрос 3: "Rea"
// Запрос 4: "Reac"
// Запрос 5: "React"
// Итого: 5 запросов вместо 1
Решение с debounce
// ✅ Хорошо: запрос только после паузы
function SearchComponent() {
const [results, setResults] = useState([]);
const handleChange = debounce(async (e) => {
const query = e.target.value;
if (!query.trim()) {
setResults([]);
return;
}
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
setResults(data);
}, 300);
return (
<input
onChange={handleChange}
placeholder="Поиск..."
/>
);
}
// Пользователь вводит "React":
// Запрос 1: "React" (только после 300ms паузы)
// Итого: 1 запрос
Реализация debounce
// Базовая реализация
function debounce(func, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
// Продвинутая реализация с опциями
function debounce(func, delay, options = {}) {
let timeoutId;
let lastArgs;
let lastThis;
let result;
let lastCallTime;
let lastInvokeTime = 0;
const { leading = false, trailing = true, maxWait } = options;
function invokeFunc(time) {
const args = lastArgs;
const thisArg = lastThis;
lastArgs = lastThis = undefined;
lastInvokeTime = time;
result = func.apply(thisArg, args);
return result;
}
function startTimer(pendingFunc, wait) {
return setTimeout(pendingFunc, wait);
}
function cancelTimer(id) {
clearTimeout(id);
}
function debounced(...args) {
const time = Date.now();
const isInvoking = shouldInvoke(time);
lastArgs = args;
lastThis = this;
lastCallTime = time;
if (isInvoking) {
if (timeoutId === undefined) {
return leadingEdge(time);
}
if (maxWait !== undefined) {
return maxEdge(time);
}
}
if (timeoutId === undefined) {
timeoutId = startTimer(timerExpired, delay);
}
return result;
}
function shouldInvoke(time) {
const timeSinceLastCall = time - lastCallTime;
const timeSinceLastInvoke = time - lastInvokeTime;
return (
lastCallTime === undefined ||
timeSinceLastCall >= delay ||
timeSinceLastCall < 0 ||
(maxWait !== undefined && timeSinceLastInvoke >= maxWait)
);
}
function timerExpired() {
const time = Date.now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
timeoutId = startTimer(timerExpired, remainingWait(time));
}
function remainingWait(time) {
const timeSinceLastCall = time - lastCallTime;
const timeSinceLastInvoke = time - lastInvokeTime;
const timeWaiting = delay - timeSinceLastCall;
return maxWait !== undefined
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting;
}
function leadingEdge(time) {
lastInvokeTime = time;
timeoutId = startTimer(timerExpired, delay);
return leading ? invokeFunc(time) : result;
}
function maxEdge(time) {
timeoutId = undefined;
return trailing ? invokeFunc(time) : result;
}
function trailingEdge(time) {
timeoutId = undefined;
if (trailing && lastArgs) {
return invokeFunc(time);
}
lastArgs = lastThis = undefined;
return result;
}
function cancel() {
if (timeoutId !== undefined) {
cancelTimer(timeoutId);
}
lastInvokeTime = 0;
lastArgs = lastCallTime = lastThis = timeoutId = undefined;
}
function flush() {
if (timeoutId === undefined) {
return result;
}
return trailingEdge(Date.now());
}
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
Правильное использование в React
Способ 1: Вынести за пределы компонента
// ✅ Функция создаётся один раз
const debouncedSearch = debounce(async (query, callback) => {
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
callback(data);
}, 300);
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value, setResults);
};
return (
<div>
<input value={query} onChange={handleChange} />
<ResultsList results={results} />
</div>
);
}
Способ 2: useRef + useCallback
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
// Сохраняем функцию в ref
const debouncedSearchRef = useRef(null);
if (!debouncedSearchRef.current) {
debouncedSearchRef.current = debounce(async (searchQuery) => {
if (!searchQuery.trim()) {
setResults([]);
return;
}
const response = await fetch(`/api/search?q=${searchQuery}`);
const data = await response.json();
setResults(data);
}, 300);
}
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearchRef.current(value);
};
return (
<div>
<input value={query} onChange={handleChange} />
<ResultsList results={results} />
</div>
);
}
Способ 3: Кастомный хук useDebounce
// Хук для debounce значения
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Хук для debounce функции
function useDebouncedCallback(callback, delay, deps = []) {
const callbackRef = useRef(callback);
const timeoutRef = useRef(null);
// Обновляем callback при изменении зависимостей
useEffect(() => {
callbackRef.current = callback;
}, [callback, ...deps]);
// Очистка при размонтировании
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return useCallback((...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}, [delay]);
}
// Использование
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const debouncedSearch = useDebouncedCallback(async (searchQuery) => {
if (!searchQuery.trim()) {
setResults([]);
return;
}
const response = await fetch(`/api/search?q=${searchQuery}`);
const data = await response.json();
setResults(data);
}, 300);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
return (
<div>
<input value={query} onChange={handleChange} />
<ResultsList results={results} />
</div>
);
}
Способ 4: debounce значения через хук
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
// Debounce значения, не функции
const debouncedQuery = useDebounce(query, 300);
// Эффект срабатывает при изменении debounced значения
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([]);
return;
}
const fetchResults = async () => {
try {
const response = await fetch(`/api/search?q=${debouncedQuery}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search error:', error);
}
};
fetchResults();
}, [debouncedQuery]);
const handleChange = (e) => {
setQuery(e.target.value);
};
return (
<div>
<input value={query} onChange={handleChange} />
<ResultsList results={results} />
</div>
);
}
Сравнение подходов
// ❌ Неправильно: новая функция каждый рендер
function Component() {
const handleChange = debounce((e) => {
// ...
}, 300); // Создаётся заново каждый рендер!
}
// ❌ Неправильно: useCallback без стабильного debounce
function Component() {
const handleChange = useCallback(debounce((e) => {
// ...
}, 300), []); // debounce создаётся один раз, но замыкание устаревает
}
// ✅ Правильно: вынесение за компонент
const stableDebounce = debounce(handler, 300);
// ✅ Правильно: useRef
function Component() {
const debouncedRef = useRef(debounce(handler, 300));
}
// ✅ Правильно: кастомный хук
function Component() {
const debouncedFn = useDebouncedCallback(handler, 300, [deps]);
}
// ✅ Правильно: debounce значения
function Component() {
const debouncedValue = useDebounce(value, 300);
useEffect(() => { /* ... */ }, [debouncedValue]);
}
Throttled vs Debounce
// Debounce: выполняется после паузы
// События: ---|---|---|---|---|--->
// Выполнение: |--|
// Throttle: выполняется не чаще указанного интервала
// События: ---|---|---|---|---|--->
// 执行: |--| |--| |--|
function throttle(func, limit) {
let inThrottle;
return function (...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
Практические рекомендации
- Используйте debounce для поиска, автосохранения, валидации
- Типичные задержки: 300-500ms для поиска, 1000ms для автосохранения
- Для мгновенной обратной связи используйте leading edge
- Очищайте таймеры при размонтировании компонента
- Используйте AbortController для отмены запросов в debounced функциях
Вывод:
Debounce ограничивает частоту вызовов, выполняя функцию только после паузы. В React функцию нужно стабилизировать через вынесение за компонент, useRef или кастомный хук useDebouncedCallback. Альтернатива — debounce значения через useDebounce хук с useEffect.
Вопрос 16. Как решить проблему гонки запросов (race condition), когда ответы от API приходят в разном порядке и предыдущий запрос может перезатереть результат последнего?
Таймкод: 00:58:08
Ответ собеседателя: Неполный. Проблема решается с помощью AbortController — при каждом новом запросе предыдущий прерывается через abort(). Кандидат знает о AbortController, понимает что нужно передать signal в fetch. Предложено использовать useRef для хранения контроллера и cleanup-функцию в useEffect для отмены при размонтировании. Однако реализация не заработала — запросы не отменялись, причина так и не была найдена до конца.
Правильный ответ:
Проблема гонки запросов (Race Condition)
Когда пользователь быстро вводит текст, запросы отправляются в разном порядке, но ответы могут прийти в обратном порядке, что приводит к отображению устаревших результатов.
Пример проблемы
// ❌ Проблема: ответ "Re" может прийти после ответа "React"
function SearchComponent() {
const [results, setResults] = useState([]);
const handleChange = async (e) => {
const query = e.target.value;
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
setResults(data); // Может перезаписать актуальные результаты
};
return <input onChange={handleChange} />;
}
// Пользователь вводит "React" быстро:
// 1. Запрос "R" отправлен (t=0ms)
// 2. Запрос "Re" отправлен (t=50ms)
// 3. Запрос "Rea" отправлен (t=100ms)
// 4. Запрос "React" отправлен (t=150ms)
//
// Ответы приходят:
// 5. Ответ "Re" получен (t=200ms) — setResults(data_for_Re)
// 6. Ответ "React" получен (t=250ms) — setResults(data_for_React) ✅
// 7. Ответ "R" получен (t=300ms) — setResults(data_for_R) ❌ УСТАРЕЛО!
// 8. Ответ "Rea" получен (t=350ms) — setResults(data_for_Rea) ❌ УСТАРЕЛО!
Решение 1: AbortController
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
// Не отправляем запрос для пустого запроса
if (!query.trim()) {
setResults([]);
return;
}
// Создаём контроллер для нового запроса
const controller = new AbortController();
const fetchResults = async () => {
setLoading(true);
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal // Передаём signal
});
if (!response.ok) {
throw new Error(response.statusText);
}
const data = await response.json();
setResults(data);
} catch (error) {
// Игнорируем ошибку отмены
if (error.name !== 'AbortError') {
console.error('Search error:', error);
}
} finally {
setLoading(false);
}
};
// Небольшая задержка для debounce
const timeoutId = setTimeout(fetchResults, 300);
// Cleanup: отменяем запрос при новом вводе или размонтировании
return () => {
controller.abort();
clearTimeout(timeoutId);
};
}, [query]);
const handleChange = (e) => {
setQuery(e.target.value);
};
return (
<div>
<input value={query} onChange={handleChange} />
{loading && <div>Загрузка...</div>}
<ResultsList results={results} />
</div>
);
}
Решение 2: useRef с AbortController
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const abortControllerRef = useRef(null);
const handleChange = async (e) => {
const value = e.target.value;
setQuery(value);
// Отменяем предыдущий запрос
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Создаём новый контроллер
abortControllerRef.current = new AbortController();
if (!value.trim()) {
setResults([]);
return;
}
try {
const response = await fetch(`/api/search?q=${value}`, {
signal: abortControllerRef.current.signal
});
if (!response.ok) {
throw new Error(response.statusText);
}
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search error:', error);
}
}
};
// Очистка при размонтировании
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return (
<div>
<input value={query} onChange={handleChange} />
<ResultsList results={results} />
</div>
);
}
Решение 3: Проверка актуальности запроса
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const requestIdRef = useRef(0);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
// Увеличиваем ID запроса
const currentRequestId = ++requestIdRef.current;
const fetchResults = async () => {
try {
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
// Проверяем, что это ответ на последний запрос
if (currentRequestId === requestIdRef.current) {
setResults(data);
}
// Иначе игнорируем устаревший ответ
} catch (error) {
if (currentRequestId === requestIdRef.current) {
console.error('Search error:', error);
}
}
};
const timeoutId = setTimeout(fetchResults, 300);
return () => clearTimeout(timeoutId);
}, [query]);
const handleChange = (e) => {
setQuery(e.target.value);
};
return (
<div>
<input value={query} onChange={handleChange} />
<ResultsList results={results} />
</div>
);
}
Решение 4: Кастомный хук useSafeAsync
function useSafeAsync() {
const abortControllerRef = useRef(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
const safeAsync = useCallback(async (asyncFn) => {
// Отменяем предыдущий запрос
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Создаём новый контроллер
abortControllerRef.current = new AbortController();
try {
const result = await asyncFn(abortControllerRef.current.signal);
// Проверяем, что компонент всё ещё смонтирован
if (mountedRef.current) {
return { data: result, error: null };
}
return { data: null, error: null };
} catch (error) {
if (mountedRef.current) {
return { data: null, error };
}
return { data: null, error: null };
}
}, []);
return safeAsync;
}
// Использование
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const safeAsync = useSafeAsync();
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const fetchResults = async () => {
const { data, error } = await safeAsync(async (signal) => {
const response = await fetch(`/api/search?q=${query}`, { signal });
if (!response.ok) throw new Error(response.statusText);
return response.json();
});
if (data) {
setResults(data);
}
if (error && error.name !== 'AbortError') {
console.error('Search error:', error);
}
};
const timeoutId = setTimeout(fetchResults, 300);
return () => clearTimeout(timeoutId);
}, [query, safeAsync]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList results={results} />
</div>
);
}
Решение 5: Полноценный хук useSearch с debounce и AbortController
function useSearch(delay = 300) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const abortControllerRef = useRef(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
useEffect(() => {
if (!query.trim()) {
setResults([]);
setError(null);
return;
}
const fetchResults = async () => {
// Отменяем предыдущий запрос
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: abortControllerRef.current.signal
});
if (!response.ok) {
throw new Error(response.statusText);
}
const data = await response.json();
if (mountedRef.current) {
setResults(data);
}
} catch (err) {
if (mountedRef.current && err.name !== 'AbortError') {
setError(err);
}
} finally {
if (mountedRef.current) {
setLoading(false);
}
}
};
const timeoutId = setTimeout(fetchResults, delay);
return () => {
clearTimeout(timeoutId);
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [query, delay]);
return {
query,
setQuery,
results,
loading,
error
};
}
// Использование
function SearchComponent() {
const { query, setQuery, results, loading, error } = useSearch(300);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск..."
/>
{loading && <div>Загрузка...</div>}
{error && <div>Ошибка: {error.message}</div>}
<ResultsList results={results} />
</div>
);
}
Сравнение решений
| Подход | Плюсы | Минусы |
|---|---|---|
| AbortController | Отменяет запрос на уровне сети | Сложнее реализовать |
| Request ID | Простая реализация | Запрос всё равно выполняется |
| useSafeAsync | Переиспользуемый | Дополнительная абстракция |
| Кастомный хук | Полный контроль | Больше кода |
Типичные ошибки
// ❌ Ошибка: новый AbortController каждый рендер без cleanup
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal });
// Нет cleanup — предыдущие запросы не отменяются
}, [query]);
// ❌ Ошибка: AbortController в зависимостях useEffect
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal });
return () => controller.abort();
}, [query, controller]); // controller каждый раз новый!
// ✅ Правильно: cleanup возвращается из useEffect
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal });
return () => controller.abort(); // Cleanup функция
}, [query]);
Вывод:
Для решения проблемы гонки запросов используется AbortController для отмены предыдущих запросов. Контроллер сохраняется в useRef или создаётся в useEffect с cleanup-функцией. Альтернатива — проверка актуальности через счётчик запросов. Важно обрабатывать AbortError и проверять смонтированность компонента перед обновлением состояния.
