Функції зворотного виклику та пекло зворотних викликів у JavaScript
Callback (функція зворотного виклику) - функція, яку передають як аргумент до іншої функції, щоб та могла викликати її пізніше, зазвичай після завершення якоїсь роботи.
Теорія
TL;DR
- Уяви, що залишаєш номер телефону в ресторані. Вони передзвонять, коли столик буде готовий, замість того щоб ти стояв у черзі. Ось як працює callback.
- Callback-и бувають двох типів: синхронні (
.map(),.filter()) виконуються одразу всередині методу; асинхронні (setTimeout,fs.readFile) виконуються після того, як поточний стек викликів звільниться. - Callback hell виникає, коли ланцюжок з 3+ async callback-ів утворює піраміду вкладеності. Обробка помилок повторюється на кожному рівні, а додавання кроку означає ще один рівень відступів.
- Правило вибору: один async крок → callback підходить. Два і більше пов'язаних кроків → Promises або async/await.
Швидкий приклад
function fetchUser(userId, callback) {
setTimeout(() => {
const user = { id: userId, name: 'Alice' };
callback(null, user); // null = помилки немає (error-first патерн)
}, 1000);
}
fetchUser(123, (err, user) => {
if (err) return console.error(err);
console.log(user.name); // "Alice" - виводить через 1 секунду
});
console.log('Це виконається першим'); // синхронний код не чекаєЗовнішній console.log спрацює одразу. Callback виконається через секунду: event loop поставить його в чергу і запустить тільки після того, як стек викликів звільниться.
Синхронні та асинхронні callback-и
Не всі callback-и є асинхронними. .map() і .filter() викликають твою функцію синхронно, рядок за рядком всередині методу. setTimeout і fs.readFile інші: Node або браузер передають операцію фоновому API, а callback потрапляє в task queue. Event loop забере його звідти тільки після того, як поточний стек очиститься.
Це питання часто з'являється на співбесідах:
setTimeout(() => console.log('A'), 0);
console.log('B');
// Виведе: "B", потім "A"
// Затримка 0мс не означає "виконати зараз". Означає "поставити в чергу".Патерн помилка-першою
У Node.js є конвенція: перший аргумент будь-якого callback-у завжди є помилкою. Якщо помилки немає - передають null. Це стандартизує обробку помилок по всіх async API.
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('Файл не знайдено:', err.message);
return; // завжди повертайся при помилці
}
const config = JSON.parse(data);
console.log(config);
});Найчастіша помилка в Node.js коді, яку я бачив - пропущена перевірка err. Отримуєш undefined у data без жодного натяку на те, що пішло не так.
Callback hell
Коли кожен async крок потребує результату попереднього, починається вкладеність. Після трьох рівнів код повзе вправо. Це і є callback hell, або піраміда загибелі.
// Ось як виглядав реальний код до появи Promises
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetails(orders[0].id, (err, details) => {
if (err) return handleError(err);
getShippingInfo(details.shipId, (err, shipping) => {
if (err) return handleError(err);
console.log(shipping);
});
});
});
});Проблема не тільки у форматуванні. Обробка помилок дублюється на кожному рівні. Додати крок означає додати рівень вкладеності. Рефакторити будь-яку частину цього ланцюжка - повільно і ризиковано.
Як вирішити callback hell
Є три підходи. Кожен має своє місце.
Promises розгортають ланцюжок у читабельну послідовність:
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => getOrderDetails(orders[0].id))
.then(details => getShippingInfo(details.shipId))
.then(shipping => console.log(shipping))
.catch(handleError); // один обробник для всього ланцюжкаasync/await робить код схожим на синхронний:
async function getShipping(userId) {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
return getShippingInfo(details.shipId);
}Іменовані функції - швидке рішення без переходу на Promises. Виносиш callback-и назовні:
function onUser(err, user) {
if (err) return handleError(err);
getOrders(user.id, onOrders);
}
function onOrders(err, orders) {
if (err) return handleError(err);
getOrderDetails(orders[0].id, onDetails);
}
getUser(userId, onUser); // плоска структура, та сама поведінкаКоли використовувати callback-и
- Одна async операція, наприклад один
addEventListenerабо одинsetTimeout: callback підходить. - Ітерація масивів через
.map(),.filter(),.reduce(): синхронні callback-и, проблем з вкладеністю немає. - Два і більше пов'язаних async кроків: переходь на Promises або async/await.
- Async операції в Node.js з можливими помилками: Promises дають один
.catch()замість перевіркиif (err)на кожному рівні.
Типові помилки
Ігнорування параметра помилки:
// Неправильно - data буде undefined, якщо файл відсутній, без жодної помилки
fs.readFile('data.txt', (data) => console.log(data));
// Правильно
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) return console.error(err);
console.log(data);
});Використання змінної до виконання callback-у:
let user;
fetchUser(1, (err, result) => {
user = result; // встановлюється через ~500мс
});
console.log(user); // undefined - цей рядок виконується до callback-уЯкщо потрібні дані - використовуй їх всередині callback-у або поверни Promise.
Хибне уявлення що setTimeout(..., 0) виконується синхронно:
setTimeout(() => console.log('першим'), 0);
console.log('другим');
// Виведе: "другим", потім "першим"Нуль мілісекунд означає "поставити в чергу після очищення стеку", а не "виконати зараз". Це питання регулярно з'являється на співбесідах.
Рекурсивний polling без умови зупинки:
// Виконується нескінченно
function poll(cb) {
setTimeout(() => poll(cb), 1000);
}
// Правильно: додай лічильник
function poll(cb, count = 0) {
if (count >= 10) return cb();
setTimeout(() => poll(cb, count + 1), 1000);
}Де зустрічається
- Node.js/Express:
app.get('/users', (req, res) => { fs.readFile(..., callback) }) - Браузерні події:
button.addEventListener('click', handler) - Методи масивів у будь-якому JS коді:
.map(),.filter(),.forEach() - Застарілий jQuery AJAX:
$.get('/api/data', {}, callback)- досі живий в старих проектах setTimeoutтаsetIntervalдля таймерів і polling
Питання на співбесіді
Q: Що таке callback-функція?
A: Функція, яку передають як аргумент і яка викликається пізніше після завершення якоїсь роботи. [1, 2, 3].forEach(n => console.log(n)) - найпростіший приклад.
Q: Що таке callback hell і чому це проблема?
A: Вкладеність з 3+ async callback-ів утворює піраміду. Обробка помилок дублюється на кожному рівні, додавання кроку означає новий рівень відступів, і код стає важким для рефакторингу.
Q: Що таке патерн помилка-першою в Node.js?
A: Конвенція, де перший аргумент callback-у завжди є помилкою (або null при успіху). Стандартизує обробку помилок - завжди перевіряй err перед тим, як використовувати дані.
Q: В чому різниця між синхронними та асинхронними callback-ами?
A: Синхронні (.map(), .filter()) виконуються одразу всередині функції, яка їх отримала. Асинхронні (setTimeout, fs.readFile) ставляться в чергу event loop-ом і виконуються після очищення поточного стеку.
Q: Як event loop обробляє async callback-и?
A: Node або браузер передають async роботу фоновому API. Після завершення callback потрапляє в task queue. Event loop переміщує його до стеку викликів тільки коли стек порожній, тому setTimeout(..., 0) все одно виконується після синхронного коду.
Q: Чому синхронна рекурсія може викликати stack overflow, а async polling - ні?
A: Синхронна рекурсія додає фрейм до стеку викликів з кожним викликом. Node обмежує стек приблизно до 10k фреймів. Async polling через setTimeout виносить кожну ітерацію в task queue, тому стек не росте. Кожен poll починається з чистого аркуша.
Приклади
Базовий async callback
function fetchUser(userId, callback) {
setTimeout(() => {
const user = { id: userId, name: 'Alice' };
callback(null, user); // null = помилки немає
}, 1000);
}
fetchUser(123, (err, user) => {
if (err) return console.error(err);
console.log(user.name); // "Alice" - через 1 секунду
});Маршрут Express.js з асинхронним читанням файлу
const fs = require('fs');
const express = require('express');
const app = express();
app.get('/user/:id', (req, res) => {
fs.readFile(`users/${req.params.id}.json`, 'utf8', (err, data) => {
if (err) return res.status(500).json({ error: 'Користувача не знайдено' });
res.json(JSON.parse(data)); // { "name": "Bob" }
});
});
// GET /user/1 зчитує файл асинхронно і відповідає через ~10мсСам обробник маршруту - callback. fs.readFile callback - другий callback всередині. Два рівні ще читабельні. Ось де варто зупинитися.
Callback hell проти async/await
// Callback hell: 3 пов'язаних async операції
getUser(123, (err, user) => {
if (err) return handleError(err);
getPosts(user.id, (err, posts) => {
if (err) return handleError(err);
getComments(posts[0].id, (err, comments) => {
if (err) return handleError(err);
console.log(comments);
});
});
});
// async/await: та сама логіка, без вкладеності
async function loadComments(userId) {
const user = await getUser(userId);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
return comments; // один try/catch охоплює весь ланцюжок
}Поведінка однакова. У версії з async/await одне місце для обробки помилок і зрозумілий порядок читання зверху вниз.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.