Адаптированный перевод статьи Zell Liew «How to deal with nested callbacks and avoid callback hell».
В JavaScript есть странные вещи. Одна из них — обратный вызов, который находится в обратном вызове, который находится в обратном вызове. На английском языке это называется callback hell или ад обратных вызовов. Статья поможет справиться с этой проблемой и писать код понятнее.
Обратный вызов или коллбэк (англ. callback) — функция, которая выполняется после выполнения другой функции. Если вы не понимаете, о чем речь, прочитайте статью об обратных вызовах, а потом возвращайтесь к этому материалу.
Вложенные обратные вызовы выглядят так:
firstFunction(args, function() {
secondFunction(args, function() {
thirdFunction(args, function() {
// И так далее...
});
});
});
За такие конструкции мы любим недолюбливаем JavaScript. Вложенные коллбэки буквально сбивают с толку и взрывают мозг. Но эта проблема решается.
Сначала просто посмотрим на варианты решения проблемы без углубления в детали. Вот четыре способа вырваться из ада вложенных коллбэков:
Но прежде чем разбираться с каждым способом, давайте создадим свой callback hell. Это нужно, чтобы почувствовать боль вложенных обратных вызовов.
Представьте, что готовите гамбургер. Вот алгоритм приготовления:
Давайте опишем приготовление гамбургера с помощью JavaScript:
const makeBurger = () => {
const beef = getBeef();
const patty = cookBeef(beef);
const buns = getBuns();
const burger = putBeefBetweenBuns(buns, patty);
return burger;
};
const burger = makeBurger();
serve(burger);
Теперь представьте, что учите ребенка делать гамбургер. Вы инструктируете маленького помощника, то есть объясняете ему каждый шаг алгоритма. После каждой инструкции вы ждете, пока маленький повар выполнит её. Только после этого вы переходите к следующему шагу.
В JavaScript ожидание выражается с помощью коллбэков. Чтобы приготовить гамбургер, сначала нам надо отварить говядину. Но мы сможем положить мясо в кастрюлю только после того, как купим его в магазине.
const makeBurger = () => {
getBeef((beef) => {
// Приготовить говядину можно после того, как мы её купили.
});
};
Теперь смешиваем русский язык и JavaScript. Чтобы приготовить говядину, нужно передать аргумент beef
в функцию cookBeef
. Если не сделать этого, функции cookBeef
будет нечего готовить.
Когда говядина готова, можем достать булочки.
const makeBurger = () => {
getBeef((beef) => {
cookBeef(beef, (cookedBeef) => {
getBuns((buns) => {
// Достаем булочки
});
});
});
};
Когда это сделано, можем положить мясо и остальную начинку между булочками.
const makeBurger = () => {
getBeef((beef) => {
cookBeef(beef, (cookedBeef) => {
getBuns((buns) => {
putBeefBetweenBuns(buns, cookedBeef, (burger) => {
// Добавляем начинку
});
});
});
});
};
Наконец гамбургер готов. Но мы пока не можем вернуть burger
из makeBurger
, так как функция асинхронная. Чтобы подать блюдо на стол, нужно использовать обратный вызов.
const makeBurger = (nextStep) => {
getBeef((beef) => {
cookBeef(beef, (cookedBeef) => {
getBuns((buns) => {
putBeefBetweenBuns(buns, cookedBeef, (burger) => {
nextStep(burger);
});
});
});
});
};
// Делаем и подаем на стол гамбургер
makeBurger((burger) => {
serve(burger);
});
Ну что, еще хотите гамбургер, или вложенные коллбэки испортили аппетит и настроение? Давайте посмотрим на способы решения проблемы, может, это вас тонизирует.
Функцию makeBurger
из примера выше легко понять, так как это достаточно простой случай. Эта функция просто не очень элегантная. Но новичка она может заставить задуматься. Если вы недавно изучаете программирование и сразу не понимаете, что происходит в коде, постарайтесь объяснить происходящее с помощью комментариев.
// Функция описывает алгоритм приготовления гамбургера
// makeBurger состоит из пяти шагов:
// 1. Покупка ингредиентов
// 2. Приготовление говядины
// 3. Приготовление булочек
// 4. Добавление начинки в булочки
// 5. Подачи гамбургера (обратный вызов)
// Каждый шаг алгоритма асинхронный, поэтому нужно использовать коллбэки.
// Мы ждем, пока маленький помощник выполнит текущий шаг,
// после этого приступаем к следующему шагу
const makeBurger = (nextStep) => {
getBeef((beef) => {
cookBeef(beef, (cookedBeef) => {
getBuns((buns) => {
putBeefBetweenBuns(buns, cookedBeef, (burger) => {
nextStep(burger);
});
});
});
});
};
Комментарии объясняют вам или другому разработчику, что происходит в коде. Кто-то благодаря этим пояснениям поймет функцию и не бросит программирование.
В функции makeBurger
из нашего примера этот подход уже реализован. Разберем этот момент подробнее.
Чтобы выполнить getBeef
, первый коллбэк, вам надо сходить за говядиной в магазин. Ну, или пойти на кухню и достать мясо из холодильника. Представьте, что на кухне два холодильника. Нужно выбрать правильный — тот, в котором лежит говядина. Выразим это с помощью кода.
const getBeef = (nextStep) => {
const fridge = leftFridge;
const beef = getBeefFromFridge(fridge);
nextStep(beef);
};
Чтобы приготовить говядину, нужно положить её в духовку, установить температуру 200 °C и включить таймер на 20 минут. Вот код:
const cookBeef = (beef, nextStep) => {
const workInProgress = putBeefinOven(beef);
setTimeout(() => {
nextStep(workInProgress);
}, 1000 * 60 * 20);
};
Теперь представьте, что эти шаги описываются в функции makeBurger
. У вас не просто пропадет аппетит. Вы возненавидите гамбургеры и JavaScript.
Запомните этот принцип: функции с вложенными коллбэками надо разделять на несколько маленьких функций.
Если вы не изучали промисы, это можно сделать в курсе «Асинхронное программирование».
Промисы решают проблему вложенных обратных вызовов. Посмотрите на код:
const makeBurger = () => getBeef()
.then(beef => cookBeef(beef))
.then(cookedBeef => getBuns(cookedBeef))
.then(bunsAndBeef => putBeefBetweenBuns(bunsAndBeef));
// Делаем и подаем гамбургер
makeBurger().then(burger => serve(burger));
Алгоритм приготовления гамбургера можно выразить ещё проще, если использовать промисы с одним аргументом:
const makeBurger = () => getBeef()
.then(cookBeef)
.then(getBuns)
.then(putBeefBetweenBuns);
// Make and serve burger
makeBurger().then(serve);
Код стал понятнее. Давайте посмотрим, как превратить функцию с вложенными коллбэками в промисы.
Чтобы решить задачу, нужно вместо каждого обратного вызова записать промис. Когда коллбэк успешно завершается, промис выполняется с результатом resolve
. Если коллбэк не выполняется, получаем ошибку reject
.
const getBeefPromise = (_) => {
const fridge = leftFridge;
const beef = getBeefFromFridge(fridge);
return new Promise((resolve, reject) => {
if (beef) {
resolve(beef);
} else {
reject(new Error('Говядина больше не нужна!'));
}
});
};
const cookBeefPromise = (beef) => {
const workInProgress = putBeefinOven(beef);
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(workInProgress);
}, 1000 * 60 * 20);
});
};
Если вы используете Node, все функции с обратными вызовами имеют одинаковый синтаксис:
// Определение функции
const functionName = (arg1, arg2, callback) => {
// Какое-то действие
callback(err, stuff);
};
// Использование функции
functionName(arg1, arg2, (err, stuff) => {
if (err) {
console.error(err);
}
// Какое-то действие
});
Одинаковый синтаксис коллбэков позволяет использовать библиотеки типа ES6 Promisify или Denodeify. Если вы пользуетесь версией Node 8.0 и выше, можно применять библиотеку util.promisify. Эти инструменты конвертируют коллбэки в промисы.
Чтобы использовать четвертое решение, нужно понимать следующие вещи:
Давайте посмотрим на асинхронную функцию makeBurger
.
const makeBurger = async () => {
const beef = await getBeef();
const cookedBeef = await cookBeef(beef);
const buns = await getBuns();
const burger = await putBeefBetweenBuns(cookedBeef, buns);
return burger;
};
// Готовим и подаем гамбургер
makeBurger().then(serve);
Этот код можно улучшить. Представьте, что у вас два маленьких помощника. Один из них выполняет действие getBuns
, то есть достает булочки. Второй выполняет getBeef
, то есть достает из холодильника говядину. Вы ждете (await
) каждого помощника (Promise.all
).
const makeBurger = async () => {
const [beef, buns] = await Promise.all(getBeef, getBuns);
const cookedBeef = await cookBeef(beef);
const burger = await putBeefBetweenBuns(cookedBeef, buns);
return burger;
};
// Make and serve burger
makeBurger().then(serve);
Да, то же самое можно записать с помощью промисов. Но синтаксис async/await более понятный.
Вот эти решения:
Оригинал публикации: How to deal with nested callbacks and avoid callback hell. Мнение автора оригинальной статьи может не совпадать с позицией редакции.