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

РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ на 300к. SENIOR FRONTEND, Live Coding

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

Сегодня мы разберем собеседование на позицию фронтенд-разработчика, в ходе которого кандидат последовательно решает задачи на 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. Он обеспечивает асинхронное выполнение операций, разделяя задачи на синхронные, микрозадачи и макрозадачи.

Приоритеты выполнения

В порядке убывания приоритета:

  1. Синхронный код — выполняется немедленно, блокируя поток выполнения
  2. Микрозадачи (Microtask Queue) — включают:
    • Promise.then/catch/finally обработчики
    • queueMicrotask()
    • MutationObserver
    • process.nextTick() в Node.js (имеет наивысший приоритет среди микрозадач)
  3. Макрозадачи (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. Синхронный код: 1, 4
  2. Все микрозадачи: 3, 6
  3. Макрозадачи: 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:

  1. Синхронный код завершается
  2. Обрабатывается очередь микрозадач
  3. Каждый обработчик добавляет новую микрозадачу
  4. Очередь микрозадач никогда не становится пустой
  5. 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 может не отвечать

Почему это происходит:

  1. Event Loop обрабатывает ВСЕ микрозадачи до перехода к следующей фазе
  2. Рендеринг происходит ТОЛЬКО после полной очистки очереди микрозадач
  3. Обработка пользовательских событий (click, scroll) — это макрозадачи
  4. Бесконечная цепочка микрозадач не даёт браузеру выполнить рендер

Рекурсия через setTimeout — UI остаётся отзывчивым

function nonBlockUI() {
setTimeout(nonBlockUI, 0);
}

nonBlockUI();

// Результат:
// - Страница остаётся интерактивной
// - Можно кликать по кнопкам
// - Работает скролл
// - Анимации продолжаются
// - Но производительность снижена

Почему это работает:

  1. Каждый setTimeout — отдельная макрозадача
  2. Между макрозадачами браузер выполняет:
    • 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.resolvesetTimeoutrequestAnimationFrame
Блокировка 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 застревает здесь:
// [микрозадача] → [микрозадача] → [микрозадача] → ...
// Рендеринг никогда не наступает

Последовательность событий:

  1. Синхронный код завершается
  2. Event Loop переходит к обработке микрозадач
  3. Каждая микрозадача порождает новую
  4. Очередь никогда не становится пустой
  5. Event Loop не переходит к рендерингу
  6. Макрозадачи (включая события) не обрабатываются

Что происходит с пользовательскими событиями

Все пользовательские события — это макрозадачи:

// Все эти события попадают в очередь макрозадач:
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:

  1. Parent вызывает setText → перерендер Parent
  2. При рендере Parent создаётся новая функция handleChange
  3. Проп onChange получает новую ссылку
  4. 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 и проверять смонтированность компонента перед обновлением состояния.