async/await сделал асинхронный код похожим на обычный — настолько, что многие пишут его, не понимая промисов под ним. Работает — пока не встретится незавершающийся await, молча проглоченная ошибка или последовательная загрузка там, где нужна параллельная. Разберём механизм.
Автомат из трёх состояний
Промис — объект-обещание будущего результата. Состояний три, переход однократный:
pending— ожидание;fulfilled— успех со значением;rejected— отказ с причиной.
Перешёл в fulfilled или rejected (вместе — settled) — застыл навсегда: повторные resolve/reject игнорируются. Подписка на результат — then(onFulfilled, onRejected), и подписаться можно когда угодно, даже спустя час после завершения: колбэк всё равно получит результат. Промис — не «процесс», а «ячейка с будущим значением».
Колбэки промисов выполняются как микрозадачи — после текущего синхронного кода, но раньше таймеров. Порядок «синхронный код → микрозадачи → setTimeout» разобран в статье про event loop.
Цепочки: then всегда возвращает новый промис
Ключ к чтению цепочек — три правила о возврате из then:
fetch("/api/user")
.then((res) => res.json()) // вернули ПРОМИС → цепочка ждёт его
.then((user) => user.name) // вернули ЗНАЧЕНИЕ → полетит дальше
.then((name) => {
console.log(name); // ничего не вернули → дальше undefined
});
- Вернули значение — следующий
thenполучит его. - Вернули промис — цепочка дождётся его и возьмёт его результат (поэтому цепочки плоские, а не вложенные).
- Бросили исключение — цепочка переключается в rejected.
Классическая ошибка новичка — «вложить» вместо «вернуть»:
// Плохо: pyramid of doom, ошибки внутренней цепочки не видны внешней
getUser().then((user) => {
getOrders(user.id).then((orders) => { ... });
});
// Хорошо: вернули промис — цепочка плоская, ошибки ловятся одним catch
getUser()
.then((user) => getOrders(user.id))
.then((orders) => { ... })
.catch(handle);
Ошибки: летят по цепочке до ближайшего catch
reject и брошенное исключение ведут себя одинаково: пролетают все then без обработчика ошибок и попадают в ближайший catch:
fetch("/api")
.then(parse) // пропущен при ошибке
.then(render) // пропущен
.catch(showError) // ← ошибка ловится здесь
.then(cleanup); // а это выполнится: catch «починил» цепочку
Две важные детали. Первая: catch возвращает обычный промис — после него цепочка снова «здорова», если из catch не бросить заново. Вторая: промис без единого catch при отказе даёт unhandled rejection — ошибку, которая всплывает в консоль глобально и в Node может уронить процесс. Правило: у каждой цепочки, которую вы никому не возвращаете, должен быть catch.
Отдельная граната — fetch: он не отклоняется на HTTP-ошибках. 404 и 500 — это fulfilled с res.ok === false; reject у fetch только на сетевых сбоях. Проверку if (!res.ok) throw ... пишут руками.
async/await: сахар над теми же цепочками
async-функция всегда возвращает промис; await — это «подпишись и подожди» для любого промиса; try/catch вокруг await — тот же .catch:
async function load() {
try {
const res = await fetch("/api/user");
if (!res.ok) throw new Error(res.status);
return await res.json(); // вернётся промис с этим значением
} catch (e) {
showError(e);
}
}
Главные грабли await — ненужная последовательность:
// 6 секунд: второй запрос ждёт первый, хотя не зависит от него
const user = await getUser(); // 3 сек
const news = await getNews(); // 3 сек
// 3 секунды: стартуем оба, ждём вместе
const [user, news] = await Promise.all([getUser(), getNews()]);
await в цикле for — та же беда в профиль: N запросов выполняются по одному. Если элементы независимы — Promise.all(items.map(...)).
Комбинаторы: all, allSettled, race
Promise.all— все или ничего: fulfilled с массивом результатов, но первый же reject валит всё. Для «страница не имеет смысла без всех данных».Promise.allSettled— дождаться всех и получить по каждому{status, value|reason}. Для «покажи что загрузилось, об остальном сообщи».Promise.race— первый завершившийся (неважно как). Классика — таймаут запросу.Promise.any— первый успешный. Для запросов к зеркалам/резервам.
Коротко
- Три состояния, переход однократный; then можно вызывать и после завершения. Колбэки — микрозадачи.
- then возвращает новый промис: значение летит дальше, возвращённый промис ожидается, исключение переводит в reject. Возвращайте, не вкладывайте.
- Ошибка летит до ближайшего catch; после catch цепочка снова здорова. Цепочка без catch = unhandled rejection. fetch не reject-ится на 404/500.
- async-функция всегда возвращает промис; try/catch вокруг await = .catch. Независимые запросы — Promise.all, не последовательные await.
- all — всё или ничего; allSettled — итог по каждому; race — первый любой; any — первый успешный.
Что почитать дальше
- Event loop — где именно выполняются колбэки промисов.
- Асинхронность и event loop в TypeScript — типизация асинхронного кода.
- Итераторы и генераторы — финальная тема цикла, включая for await..of.