Состояния промиса и конструктор

В этой статье поговорим про состояния промиса и о том, как точнее описывать то, что с ним происходит. Поля state (состояние) недостаточно: есть ещё слова, которые не являются формальными состояниями, но позволяют точнее сказать, что происходит с промисом.

Состояние (State): три значения

Состояние промиса — это одно из трёх значений: pending, fulfilled, rejected. Сформулируем правила.

  1. Изначально промис может находиться в любом из этих состояний.
  2. Можно перейти из pending в fulfilled или из pending в rejected. Из fulfilled в rejected (или наоборот, из rejected в pending) перейти нельзя — есть только два направления.
  3. Поменять статус можно не более одного раза. «Не более одного» означает ноль или один: промис может один раз перейти из pending в fulfilled, а может ноль раз поменять состояние и остаться pending навсегда. Или быть изначально rejected и навсегда таким остаться.

Если считать, что промис — это класс, то можно представить, что там есть просто поле state со строкой:

class Promise { #state = "PENDING"; // ... }

Формальное определение состояний

Поле #state приватное, поэтому, чтобы описать состояние внешнему наблюдателю, нужно сказать, как его определять через внешний интерфейс. Правило такое: берём промис, вызываем p.then(f1, f2) с двумя функциями.

  1. Если then отправляет в очередь задачу на выполнение функции f1 — промис fulfilled.
  2. Если then отправляет в очередь задачу на выполнение функции f2 — промис rejected.
  3. Если не отправил в очередь ни одну из задач (не fulfilled и не rejected) — промис pending.

Это формальное определение из спецификации. На практике мы часто сами контролируем промис и понимаем, в каком он состоянии.

Конструктор

Конструктор промиса принимает функцию, в которую приходят два аргумента — resolve и reject. Так как это произвольная функция, которую мы пишем сами, аргументы можно называть как угодно. Но вот что подозрительно: если функция reject переводит промис в состояние rejected, то почему первый аргумент называется resolve, а статус — fulfilled? Кажется, это не соответствует нормам английского языка, и обычно этот факт игнорируют. Разберёмся.

Что принимает конструктор

Иногда говорят, что конструктор принимает «resolve, reject». Будем точнее: конструктор принимает функцию. Её обычно называют executor (исполнитель). И уже эта функция-executor принимает два аргумента, каждый из которых — функция.

const executor = (resolve, reject) => { resolve("maxcode"); }; new Promise(executor);

Как это работает внутри

Набросаем примитивную версию класса. В конструктор приходит executor, и он сразу выполняется с двумя колбэками, которые передаёт сам класс — назовём их innerResolve и innerReject. Это методы класса Promise, которые мы не контролируем:

class Promise { constructor(executor) { executor(this.#innerResolve, this.#innerReject); } #innerResolve = (value) => { this.#fulfillCallbacks.forEach(cb => cb(value)); } then(cb) { this.#fulfillCallbacks.push(cb); } }

Идея такая: в конструкторе запускаем executor, он когда-нибудь вызовет resolve (то есть this.#innerResolve), в неё передастся value, и все колбэки из массива #fulfillCallbacks выполнятся с этим значением. Колбэки туда заранее добавляются методом then.

Это очень упрощённый взгляд. Если мы подписались уже после того, как промис получил значение, то innerResolve не вызывается, и работают другие механизмы.

Поведение executor: частые вопросы

Можно ли вернуть значение?

new Promise((resolve, reject) => { return "maxcode"; });

Вернуть можно, но промис останется pending — значение будет проигнорировано. Это работает так же, как forEach:

[1, 2, 3].forEach(x => { return "maxcode"; });

Метод forEach возвращает undefined и просто совершает side-эффекты — возвращаемое значение ни на что не влияет.

Можно ли бросить ошибку?

В JavaScript можно бросать не только ошибки, но и любое значение. И это влияет на промис:

new Promise((resolve, reject) => { throw "Ooops"; }); // Промис станет rejected по причине "Ooops"

В целом такое нужно делать довольно редко.

Можно ли вызвать и resolve, и reject?

Вызвать можно сколько угодно раз, но только первый вызов повлияет на статус:

new Promise((resolve, reject) => { resolve(1); reject(2); }); // Промис станет fulfilled со значением 1

Точнее, здесь конкурируют между собой throw, resolve и reject — кто первый, тот и определяет судьбу:

new Promise((resolve, reject) => { throw 0; resolve(1); reject(2); }); // Промис станет rejected со значением 0

Кто первый — тот и определяет статус.

Чем resolve отличается от fulfilled

resolve с обычным значением

const p1 = new Promise((resolve, reject) => { resolve("maxcode"); }); // p1 станет fulfilled со значением "maxcode"

Ничего удивительного.

reject со строкой

const p2 = new Promise((resolve, reject) => { reject("Ooops"); }); // p2 станет rejected по причине "Ooops"

Тоже всё понятно.

reject промисом

Теперь начнём делать странное. Обычно мы режектимся каким-то значением (строкой, объектом), но давайте зареджектимся промисом:

const p1 = new Promise((resolve, reject) => { resolve("maxcode"); }); const p3 = new Promise((resolve, reject) => { reject(p1); }); p3.then(null, reason => console.log(reason === p1)); // true // p3 станет rejected по причине Promise{"maxcode"}

Подписываемся на p3 и проверяем причину реджекта: она буквально равна промису p1. Чем зареджектили — тем он и зареджектился. Ничего удивительного.

resolve промисом

А теперь то же самое, но через resolve:

const p1 = new Promise((resolve, reject) => { resolve("maxcode"); }); const p4 = new Promise((resolve, reject) => { resolve(p1); }); p4.then(value => console.log(value === p1)); // false // p4 станет fulfilled со значением "maxcode"

Сравниваем пришедшее value с p1 — получаем false! Почему? Потому что p4 станет fulfilled со значением "maxcode", а не с промисом p1.

Невозможно зарезолвить промис другим промисом так, чтобы внутри значения лежал ещё один промис. Вызвать resolve с промисом можно, но «развернуть» его система не даст — и это удобно, мы будем этим многократно пользоваться. Именно поэтому функция называется resolve, а не fulfill: вызов resolve не означает, что промис станет fulfilled.

А что, если внутри передаваемого промиса был реджект?

const p2 = new Promise((resolve, reject) => { reject("Ooops"); }); const p5 = new Promise((resolve, reject) => { resolve(p2); }); p5.then(value => console.log(value === p2)); // false // p5 станет rejected по причине "Ooops"

Несмотря на то что вызывается resolve, промис p5 становится rejected со строкой из p2.

Выводы про resolve и reject

  • Вызов reject всегда переводит в rejected.
  • Вызов resolve не всегда переводит в fulfilled — поэтому он и называется resolve.
    • Если передать обычное значение — будет fulfilled:
      new Promise(resolve => resolve("maxcode")); // fulfilled
    • Если передать промис — состояние будет таким же, как у этого промиса:
      const p = new Promise(resolve => resolve("maxcode")); new Promise(resolve => resolve(p)); // fulfilled const p = new Promise((_, reject) => reject("Ooops")); new Promise(resolve => resolve(p)); // rejected

Thenable-объекты

Расскажу всю правду. Есть понятие thenable-объект — это объект, у которого реализован метод then (и он является функцией). Других требований нет.

Если зарезолвить промис thenable-объектом, то в значение промиса попадёт не сам объект, а то, с чем будет вызвана функция resolve (первый аргумент then):

const p = { then(resolve) { resolve("maxcode"); }, }; new Promise(resolve => resolve(p)); // fulfilled

То же самое с реджектом — через второй аргумент then:

const p = { then(_, reject) { reject("Ooops"); }, }; new Promise(resolve => resolve(p)); // rejected

Зачем это нужно? Чтобы поддерживать другие реализации промисов — legacy-библиотеки или альтернативные реализации. Поэтому везде, где мы работаем с промисами, на самом деле ожидается не Promise, а thenable-объект.

Забегая вперёд: оператор await тоже распаковывает thenable так же, как промис:

const p = { then(resolve, reject) { resolve("maxcode"); }, }; const value = await p; // value === "maxcode"

Иллюстрация со временем

Рассмотрим пример с таймером:

const p1 = new Promise((resolve, reject) => setTimeout( () => Math.random() < 0.5 ? resolve("Yes") : reject("No"), 500));

Внутри setTimeout через ~500 мс происходит «бросок монетки»: с вероятностью 50% вызывается resolve("Yes") или reject("No").

Как ведёт себя p1:

  • В момент времени 0 (создание) он pending.
  • До ~500 мс он остаётся PENDING.
  • После — либо REJECTED, либо FULFILLED (в зависимости от монетки). Произойдёт это не раньше 500 мс, потому что так работает setTimeout.

С другой стороны: если мы вызвали resolve, то промис находится в состоянии RESOLVED, а до этого — UNRESOLVED.

На этом примере не очень понятно, зачем различать pending/resolved и fulfilled/resolved. Посмотрим на второй промис.

const p2 = new Promise((resolve, reject) => setTimeout( () => resolve(p1), 200));

p2 через 200 мс вызывает resolve(p1). На графике появляется отметка 200. Состояние p2:

  • Так как p2 зависит от p1 и не может стать fulfilled со значением p1, он станет fulfilled (значением "Yes") или rejected (значением "No") не раньше 500 мс. До этого он PENDING — как и p1.
  • Но функцию resolve мы вызвали в момент 200. Значит, начиная с 200 мс, p2 находится в состоянии RESOLVED, а до этого — UNRESOLVED.

И вот здесь видно, что есть момент времени, когда промис уже resolved, но ещё pending. Такой момент называется LOCKED-IN.

Схема состояний: state, fate и settled

Изобразим все статусы в виде схемы. Добавляется ещё слово settled. На верхнем уровне промисы делятся на две категории — unresolved и resolved:

  • Если промис unresolved (мы не вызывали ни resolve, ни reject) — он находится в состоянии pending.
  • Если в промисе вызвали resolve или reject — он resolved, и уже нельзя повторно вызвать resolve/reject или бросить ошибку. Промис обрёл свою судьбу.

Есть статья от одного из авторов спецификации промисов — States and Fates. В ней описано:

  • pending, fulfilled, rejected — это state (состояние) промиса.
  • Но есть ещё fate (рок, судьба). Когда мы вызываем resolve с другим промисом, наш промис оказывается locked-in — заблокирован на другой промис. Мы ещё не знаем, станет он fulfilled, rejected или останется навсегда pending (если тот промис всегда pending). Промис ещё в состоянии pending, но управлять его состоянием мы уже не можем — это его судьба.

Итого:

  • unresolved → промис pending.
  • locked-in → промис pending и зависит от другого промиса.
  • settled → промис уже rejected или fulfilled, то есть обрёл своё терминальное значение.
unresolved  →  locked-in  →  settled
 (pending)     (pending)    (fulfilled / rejected)

Что дальше

Дальше поговорим про метод then и про то, как с его помощью управлять состояниями промисов в цепочках.