От callback hell к промисам
Вопрос звучит просто: «Что такое callback hell и как от него уйти?» За ним обычно прячется проверка того, понимаете ли вы соглашения Node, а не только синтаксис.
Error-first callback — историческое соглашение Node: первым аргументом колбэка всегда идёт ошибка (или
null), вторым — результат. На нём построена вся стандартная библиотека до появления промисов.
Что проверяет интервьюер
Три вещи. Знаете ли вы соглашение error-first (это код, который до сих пор живёт в любом легаси-проекте). Понимаете ли, что «вложенность» — не единственная и даже не главная проблема колбэков. И умеете ли аккуратно превращать старое API в промис, не теряя ошибки.
Как выглядит проблема
const fs = require('node:fs');
fs.readFile('config.json', 'utf8', (err, raw) => {
if (err) return console.error('не прочитали конфиг', err);
db.getUser(JSON.parse(raw).userId, (err, user) => {
if (err) return console.error('не нашли пользователя', err);
db.getOrders(user.id, (err, orders) => {
if (err) return console.error('не нашли заказы', err);
sendReport(user, orders, (err) => {
if (err) return console.error('не отправили отчёт', err);
console.log('готово');
});
});
});
});
Плохо здесь не только то, что код уезжает вправо. Куда хуже другое:
- Обработка ошибок дублируется на каждом уровне, и достаточно один раз забыть
if (err), чтобы ошибка исчезла бесследно. - try/catch бесполезен: колбэк вызывается позже, когда исходного стека уже нет, и внешний
tryего не поймает. - Стектрейс обрывается — по нему не видно, откуда пришёл запрос.
- Ошибка, брошенная внутри колбэка, никем не перехватывается и роняет процесс.
Ловушка: колбэк не обязан быть асинхронным
Это отдельный вопрос, который любят на middle-позициях: «Гарантирует ли наличие колбэка асинхронность?» Ответ — нет, и это опасно. Запустите пример:
const cache = {};
function getUser(id, cb) {
if (cache[id]) {
return cb(null, cache[id]); // синхронный путь: значение уже есть
}
cache[id] = { id, name: 'user' + id };
cb(null, cache[id]); // и этот путь тоже синхронный
}
console.log('до вызова');
getUser(1, (err, user) => console.log('колбэк:', user.name));
console.log('после вызова');
Вывод:
до вызова
колбэк: user1
после вызова
Колбэк выполнился между двумя синхронными строками. Функция, которая иногда вызывает колбэк синхронно, а иногда асинхронно, — источник трудноуловимых багов: порядок выполнения зависит от того, попали вы в кэш или нет. В сообществе такое поведение называют «выпустить Zalgo». Лечение — либо всегда откладывать вызов через process.nextTick/setImmediate, либо (лучше) вернуть промис: промис всегда вызывает .then асинхронно, даже если значение уже готово.
Промисификация
Ручной вариант — понимать его обязательно, потому что именно его просят написать у доски:
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
В реальном коде руками так не пишут. В Node есть готовые инструменты:
const { promisify } = require('node:util');
const fs = require('node:fs');
const fsp = require('node:fs/promises'); // современный путь
const readFile = promisify(fs.readFile); // для старых API с error-first
async function main() {
const raw = await fsp.readFile('config.json', 'utf8');
const user = await db.getUser(JSON.parse(raw).userId);
const orders = await db.getOrders(user.id);
await sendReport(user, orders);
console.log('готово');
}
Тот же сценарий, что и в примере с пирамидой, но плоский, с одной точкой обработки ошибок и с сохранённым стектрейсом. Обратите внимание на префикс node: в импортах — современный стиль, который явно отличает встроенный модуль от пакета из npm.
Три поколения асинхронного API
| Подход | Ошибки | Где встречается |
| error-first callback | первым аргументом, вручную на каждом уровне | fs, старые драйверы БД, легаси |
| Promise | .catch() на всю цепочку | fs/promises, большинство современных пакетов |
| async/await | обычный try/catch | прикладной код сегодня |
Типичные ошибки кандидатов
- Сводят проблему к «некрасивой лесенке» и не говорят про потерю ошибок — а это главное.
- В промисификации забывают
rejectили вызываютresolveдважды. - Уверены, что
try/catchвокруг вызова с колбэком поймает асинхронную ошибку. - Не знают про
util.promisifyиfs/promises, пишут обёртки руками в новом коде.
Как ответить кратко
Callback hell — это не про отступы, а про то, что ошибки обрабатываются вручную на каждом уровне и легко теряются, а
try/catchих не ловит. Уход — промисы иasync/await: линейный код, одна точка обработки ошибок, нормальный стектрейс. Старые error-first API оборачиваются черезutil.promisify, а в стандартной библиотеке уже есть промисные варианты вродеfs/promises. Отдельно стоит помнить, что колбэк сам по себе не гарантирует асинхронность, а промис — гарантирует.