Раздел 5 · Асинхронность

Promise, thenable и async/await

middle~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Promise, thenable и async/await

Урок про самый используемый асинхронный контракт в JavaScript. Начинаем с того, что Promise<T>, это контейнер для одного значения “потом”, и доходим до тонких вещей: thenable, return await, multiple resolve, история Bluebird и почему её всё ещё стоит знать.

Если предыдущий урок про event loop остался не освоенным, лучше сначала вернуться к 02-callbacks-and-event-loop: большинство сюрпризов с промисами, на самом деле сюрпризы про microtask-очередь.

1. Контракт Promise

Промис, это объект с тремя возможными состояниями:

  • pending, ещё не известно.
  • fulfilled, известно значение.
  • rejected, известна ошибка.

Из pending можно перейти ровно один раз. Уже settled-промис не меняется. Это и есть его сила: один и тот же Promise<T> можно await-ить из разных мест, ответ будет одинаковый.

const p = new Promise<number>((resolve, reject) => {
  setTimeout(() => resolve(42), 100);
});

const a = await p;
const b = await p;
console.log(a, b); // 42 42

Конструктор new Promise(executor) запускает executor синхронно. Это часто упускают. Если внутри throw, промис отказывается с этой ошибкой. Если resolve(value) синхронный, промис уже fulfilled на следующей строке (но then всё равно сработает асинхронно, через microtask).

2. then, catch, finally

Три метода, которые формируют цепочки.

fetch('/api/users')
  .then((res) => res.json())
  .then((users) => render(users))
  .catch((err) => console.error(err))
  .finally(() => hideLoader());

Что важно знать про каждый:

  • then(onFulfilled, onRejected), оба колбека опциональные. Возвращает новый промис. Если колбек вернёт значение, новый промис разрешится этим значением. Если колбек вернёт промис, новый промис будет ждать его (это называется adoption).
  • catch(onRejected), синтаксический сахар над then(undefined, onRejected). Ловит rejection из любого предыдущего шага в цепочке.
  • finally(onFinally), вызывается без аргумента, но получает цепочку: значение или ошибка предыдущего шага пробрасывается дальше. Подходит для hideLoader, не для логики.

Подводный камень then(success, error) против then(success).catch(error): во втором варианте error ловит и ошибки самой success. В первом, нет.

// плохо: throw из success утечёт
p.then(success, error);

// хорошо: и в success, и в error будут пойманы
p.then(success).catch(error);

3. Статические методы: all, allSettled, race, any

МетодКогда settledЧто возвращает
Promise.all(ps)все fulfilled или хотя бы один rejectedмассив значений или первая ошибка
Promise.allSettled(ps)все settled (любое состояние)массив { status, value } или { status, reason }
Promise.race(ps)первый settled (любое состояние)первое значение или первая ошибка
Promise.any(ps)первый fulfilled или все rejectedпервое значение или AggregateError

Когда какой выбирать:

  • all, когда нужны все, и одна ошибка делает всё бессмысленным.
  • allSettled, когда нужны все, и каждый результат обрабатывается независимо (например, ленты с разных источников).
  • race, когда нужен “первый из двух”: fastest-of-N или гонка с таймаутом.
  • any, когда нужен “первый успешный”: fallback-цепочки.

Один полезный приём, реализация таймаута через race:

const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> => {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`timeout ${ms}ms`)), ms),
  );
  return Promise.race([p, timeout]);
};

Серьёзный недостаток этого withTimeout, он не отменяет исходный запрос. Через 5 секунд p всё равно завершится и потратит ресурсы. Корректная версия, через AbortController, рассмотрим в уроке про события и сигналы.

Таблицу из четырёх статик легко прочитать и так же легко забыть. Ниже редактор с задачей: собрать Promise.any своими руками, вместе с порядком причин в AggregateError. Тесты гоняются прямо в браузере, ставить ничего не нужно.

4. Thenable

В спецификации then не привязан к классу Promise. Любой объект с методом then(onFulfilled, onRejected), это thenable. Промисы и await понимают thenable наравне с настоящими промисами.

class LazyValue<T> {
  constructor(private compute: () => T) {}

  then<U>(
    onFulfilled?: (value: T) => U | PromiseLike<U>,
    onRejected?: (reason: unknown) => U | PromiseLike<U>,
  ): Promise<U> {
    try {
      return Promise.resolve(this.compute()).then(onFulfilled, onRejected);
    } catch (err) {
      return Promise.reject(err).catch(onRejected);
    }
  }
}

const lazy = new LazyValue(() => 42);
console.log(await lazy); // 42

LazyValue, это не настоящий промис, но await его принимает. Вот так работает Promise.resolve(thenable): оно вызывает thenable.then(...) и оборачивает в нативный промис.

Подводный камень. Если случайно сделать объект с методом then (не зная про этот контракт), любой await его развернёт. Самый известный пример, ESM-модуль с export function then или export const then. Vite при динамическом импорте такого модуля зависает или возвращает странный результат. Это записано в памяти проекта: не называть экспортируемые функции then, если только это не настоящий thenable.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.