Callback hell
Callback hell це стан коду, коли кілька асинхронних операцій виконуються вкладено одна в одну, бо кожна наступна залежить від результату попередньої, і програма перетворюється на «ліс» з вкладених функцій. Такий код важко читати й налагоджувати, у ньому складно обробляти помилки і його важко змінювати або перевикористовувати.
Теорія
TL;DR
- Причина: послідовні асинхронні кроки, де кожен наступний потребує результату попереднього.
- Симптом: код зсувається вправо «драбинкою», рівень вкладеності росте з кожним кроком.
- Наслідки: погана читабельність, дублювання обробки помилок, неможливість перевикористати окремий крок.
- Проблема не в колбеках як таких, а в глибокій вкладеності і некерованому потоці виконання.
- Лікування: Promise і
async/await, які записують ту саму послідовність лінійно.
Швидкий приклад
getUser(id, user => {
getPosts(user.id, posts => {
getComments(posts[0].id, comments => {
saveToFile(comments, () => {
console.log('Готово!');
});
});
});
});Чотири кроки, і вже чотири рівні вкладеності. Додайте п'ятий, і читати це стане ще важче.
Чому з'являється вкладеність
Callback не повертає значення викликачу: результат приходить пізніше, всередину переданої функції. Тому єдиний спосіб використати цей результат у наступному кроці, це написати наступний виклик усередині колбека. Кожна залежність між кроками додає рівень вкладеності, і послідовність з N операцій дає N рівнів відступів.
Так лінійна за змістом логіка («візьми користувача, потім його пости, потім коментарі, потім збережи») перетворюється на пірамідальну за формою. Цю форму і називають pyramid of doom.
Чим це шкідливо
- Читабельність. Щоб зрозуміти порядок кроків, треба читати структуру вкладеності, а не послідовність рядків. Кінець кожного кроку губиться серед закриваючих дужок.
- Обробка помилок. Немає спільного місця для збоїв: у стилі error-first кожен рівень має власний параметр
err, і перевірку доводиться повторювати знову й знову. - Зміни і перевикористання. Вирізати один крок або переставити кроки місцями важко: вони фізично вкладені один в одний, а не стоять поруч.
getUser(id, (err, user) => {
if (err) return handle(err);
getPosts(user.id, (err, posts) => {
if (err) return handle(err);
getComments(posts[0].id, (err, comments) => {
if (err) return handle(err);
// корисна робота ховається під трьома перевірками
});
});
});Як розплутати: Promise і async/await
Promise повертає значення викликачу, тому наступний крок можна поставити поруч, а не всередині. Ланцюжок .then уже робить код пласким, а async/await дає майже синхронний вигляд:
const user = await getUser(id);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
await saveToFile(comments);Той самий код з обробкою помилок в одному місці:
async function run(id) {
try {
const user = await getUser(id);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
await saveToFile(comments);
console.log('Готово!');
} catch (err) {
console.error(err.message);
}
}Якщо кроки незалежні, їх не треба вишиковувати послідовно: Promise.all запускає їх паралельно і чекає всі одразу.
const [user, settings] = await Promise.all([getUser(id), getSettings(id)]);Коли API старе і вміє тільки колбеки, його загортають у Promise один раз (util.promisify у Node.js або власна обгортка), і далі весь код пишеться лінійно.
Типові помилки
- Вважати, що колбеки самі по собі погані. Погана саме глибока вкладеність;
map,forEachі обробники подій це теж колбеки, і з ними все гаразд. - Обробляти помилку тільки на найглибшому рівні: збій на першому кроці просто загубиться.
- «Виправляти» вкладеність винесенням кожного колбека в іменовану функцію: рівнів стає менше візуально, але потік виконання все одно розсипаний по файлу.
- Ставити
awaitу циклі для незалежних операцій: послідовність стає повільнішою там, де мав бутиPromise.all. - Забувати, що всередині колбека
returnповертає значення не з зовнішньої функції, а з самого колбека.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.