Skip to main content

Функції зворотного виклику та пекло зворотних викликів у JavaScript

Callback (функція зворотного виклику) - функція, яку передають як аргумент до іншої функції, щоб та могла викликати її пізніше, зазвичай після завершення якоїсь роботи.

Теорія

TL;DR

  • Уяви, що залишаєш номер телефону в ресторані. Вони передзвонять, коли столик буде готовий, замість того щоб ти стояв у черзі. Ось як працює callback.
  • Callback-и бувають двох типів: синхронні (.map(), .filter()) виконуються одразу всередині методу; асинхронні (setTimeout, fs.readFile) виконуються після того, як поточний стек викликів звільниться.
  • Callback hell виникає, коли ланцюжок з 3+ async callback-ів утворює піраміду вкладеності. Обробка помилок повторюється на кожному рівні, а додавання кроку означає ще один рівень відступів.
  • Правило вибору: один async крок → callback підходить. Два і більше пов'язаних кроків → Promises або async/await.

Швидкий приклад

javascript
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 забере його звідти тільки після того, як поточний стек очиститься.

Це питання часто з'являється на співбесідах:

javascript
setTimeout(() => console.log('A'), 0); console.log('B'); // Виведе: "B", потім "A" // Затримка 0мс не означає "виконати зараз". Означає "поставити в чергу".

Патерн помилка-першою

У Node.js є конвенція: перший аргумент будь-якого callback-у завжди є помилкою. Якщо помилки немає - передають null. Це стандартизує обробку помилок по всіх async API.

javascript
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, або піраміда загибелі.

javascript
// Ось як виглядав реальний код до появи 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 розгортають ланцюжок у читабельну послідовність:

javascript
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 робить код схожим на синхронний:

javascript
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-и назовні:

javascript
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) на кожному рівні.

Типові помилки

Ігнорування параметра помилки:

javascript
// Неправильно - 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-у:

javascript
let user; fetchUser(1, (err, result) => { user = result; // встановлюється через ~500мс }); console.log(user); // undefined - цей рядок виконується до callback-у

Якщо потрібні дані - використовуй їх всередині callback-у або поверни Promise.

Хибне уявлення що setTimeout(..., 0) виконується синхронно:

javascript
setTimeout(() => console.log('першим'), 0); console.log('другим'); // Виведе: "другим", потім "першим"

Нуль мілісекунд означає "поставити в чергу після очищення стеку", а не "виконати зараз". Це питання регулярно з'являється на співбесідах.

Рекурсивний polling без умови зупинки:

javascript
// Виконується нескінченно 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

javascript
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 з асинхронним читанням файлу

javascript
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

javascript
// 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 одне місце для обробки помилок і зрозумілий порядок читання зверху вниз.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?