От 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. Отдельно стоит помнить, что колбэк сам по себе не гарантирует асинхронность, а промис — гарантирует.

Проверьте себя
1. Что означает соглашение error-first callback?
AКолбэк вызывается только при ошибке
BПервый аргумент колбэка — ошибка или null, второй — результат
CОшибки бросаются через throw внутри колбэка
DПервым аргументом идёт результат, а ошибка — последним
2. Почему try/catch вокруг вызова функции с колбэком не поймает асинхронную ошибку?
AПотому что колбэк выполняется позже, когда исходный стек уже свёрнут
BПотому что Node запрещает throw внутри колбэков
CПотому что ошибка уходит в очередь микрозадач и теряется
DПотому что try/catch работает только с промисами
3. Чем опасна функция, которая вызывает колбэк синхронно при попадании в кэш и асинхронно при промахе?
AНичем, это оптимизация производительности
BПорядок выполнения кода становится непредсказуемым и зависит от состояния кэша
CNode выбросит ошибку ERR_SYNC_CALLBACK
DКолбэк будет вызван дважды