Антон Ларичев

Введение
Асинхронный JavaScript — это фундамент современной веб-разработки. Браузер и Node.js работают в одном потоке, поэтому без асинхронных механизмов любая долгая операция (запрос к серверу, чтение файла, таймер) блокировала бы интерфейс. В этой статье разберём три подхода к работе с асинхронностью в порядке их появления: callbacks, Promise и async/await. Поймём, зачем каждый из них появился и какие проблемы решал.
Callbacks: с чего всё началось
Коллбэк — это функция, которую мы передаём в другую функцию как аргумент, чтобы её вызвали позже, когда асинхронная операция завершится. Это самый старый способ работы с асинхронным кодом в JavaScript.
// Имитация запроса к серверу с коллбэком
function loadUser(id, callback) {
setTimeout(() => {
// Возвращаем пользователя через 1 секунду
callback(null, { id, name: 'Анна' });
}, 1000);
}
loadUser(1, (error, user) => {
if (error) {
console.error('Ошибка загрузки:', error);
return;
}
console.log('Пользователь:', user);
});
Первым параметром коллбэка по соглашению передают ошибку (error-first callback), вторым — результат. Это позволяет единообразно обрабатывать сбои.
Callback hell
Когда нужно выполнить несколько асинхронных операций последовательно, код быстро превращается в «лестницу» из вложенных функций:
loadUser(1, (err, user) => {
if (err) return console.error(err);
loadPosts(user.id, (err, posts) => {
if (err) return console.error(err);
loadComments(posts[0].id, (err, comments) => {
if (err) return console.error(err);
// Глубина вложенности растёт с каждой операцией
console.log(comments);
});
});
});
Такой код сложно читать, отлаживать и поддерживать. Именно эта проблема породила следующий подход.
Promise: контракт на будущее значение
Promise — это объект, представляющий результат асинхронной операции, которая ещё не завершилась. Появился в стандарте ES2015. У промиса три состояния: pending (ожидание), fulfilled (выполнен) и rejected (отклонён).
function loadUser(id) {
// Возвращаем промис вместо приёма коллбэка
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error('Неверный идентификатор'));
return;
}
resolve({ id, name: 'Анна' });
}, 1000);
});
}
loadUser(1)
.then((user) => console.log('Пользователь:', user))
.catch((error) => console.error('Ошибка:', error));
Главное преимущество — цепочки .then(). Каждый then возвращает новый промис, поэтому асинхронные операции выстраиваются в плоскую последовательность:
loadUser(1)
.then((user) => loadPosts(user.id))
.then((posts) => loadComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error('Где-то в цепочке ошибка:', error));
Один .catch() в конце ловит ошибки со всех шагов — это намного удобнее проверки if (err) в каждом коллбэке.
Параллельный запуск
Promise.all позволяет дождаться выполнения нескольких независимых операций параллельно:
// Запускаем три запроса одновременно, ждём все
Promise.all([loadUser(1), loadUser(2), loadUser(3)])
.then((users) => console.log('Все загружены:', users))
.catch((error) => console.error(error));
Есть также Promise.allSettled (ждать все, даже отклонённые), Promise.race (первый завершившийся) и Promise.any (первый успешный).
async/await: синтаксический сахар над Promise
Конструкция async/await появилась в ES2017 и сделала асинхронный код визуально похожим на синхронный. Под капотом это всё те же промисы.
// Функция, помеченная async, всегда возвращает промис
async function showUserData(id) {
try {
const user = await loadUser(id);
const posts = await loadPosts(user.id);
const comments = await loadComments(posts[0].id);
console.log(comments);
} catch (error) {
// Ловим ошибки любого из await обычным try/catch
console.error('Ошибка:', error);
}
}
showUserData(1);
Ключевое слово await приостанавливает выполнение функции до завершения промиса и возвращает его результат. Использовать await можно только внутри async-функций или на верхнем уровне модуля.
Параллельность в async/await
Частая ошибка — забывать о параллельности. Этот код выполняется последовательно и медленно:
// Плохо: каждый запрос ждёт предыдущий
const user1 = await loadUser(1);
const user2 = await loadUser(2);
Правильно — запустить промисы одновременно и дождаться обоих:
// Хорошо: запросы идут параллельно
const [user1, user2] = await Promise.all([loadUser(1), loadUser(2)]);
Частые ошибки
Забытый return в then. Если внутри .then() не вернуть промис, следующий шаг цепочки не дождётся его завершения и получит undefined.
Смешивание подходов. Не оборачивайте промисы в коллбэки и не создавайте new Promise вокруг уже существующего промиса — это антипаттерн, известный как «explicit promise construction».
Отсутствие обработки ошибок. Промис без .catch() или await без try/catch приведёт к unhandled rejection. В современных средах это уже считается ошибкой.
await в цикле forEach. Метод forEach не понимает асинхронности — используйте обычный for...of, если нужна последовательность, или Promise.all с map, если порядок не важен.
Игнорирование возвращаемого промиса. Вызов async-функции без await запускает её, но не ждёт результата. Иногда это нужно, но чаще — источник скрытых багов.
Заключение
Коллбэки, промисы и async/await — это не конкурирующие технологии, а эволюция одной идеи. Промисы решили проблему callback hell, а async/await сделал работу с промисами максимально близкой к синхронному коду. На практике в современных проектах вы будете писать преимущественно async/await, но понимать промисы под капотом необходимо: без этого сложно отлаживать ошибки и грамотно использовать Promise.all, Promise.race и другие комбинаторы. Коллбэки же по-прежнему встречаются в старых API и низкоуровневых модулях Node.js — знать их полезно, чтобы при необходимости легко обернуть в промис через util.promisify или вручную.






Комментарии
0