← назад к разделу

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
  });
  1. Вернули значение — следующий then получит его.
  2. Вернули промис — цепочка дождётся его и возьмёт его результат (поэтому цепочки плоские, а не вложенные).
  3. Бросили исключение — цепочка переключается в 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.