В этой статье поговорим про состояния промиса и о том, как точнее описывать то, что с ним происходит. Поля state (состояние) недостаточно: есть ещё слова, которые не являются формальными состояниями, но позволяют точнее сказать, что происходит с промисом.
Состояние промиса — это одно из трёх значений: pending, fulfilled, rejected. Сформулируем правила.
pending в fulfilled или из pending в rejected. Из fulfilled в rejected (или наоборот, из rejected в pending) перейти нельзя — есть только два направления.pending в fulfilled, а может ноль раз поменять состояние и остаться pending навсегда. Или быть изначально rejected и навсегда таким остаться.Если считать, что промис — это класс, то можно представить, что там есть просто поле state со строкой:
class Promise { #state = "PENDING"; // ... }
Поле #state приватное, поэтому, чтобы описать состояние внешнему наблюдателю, нужно сказать, как его определять через внешний интерфейс. Правило такое: берём промис, вызываем p.then(f1, f2) с двумя функциями.
then отправляет в очередь задачу на выполнение функции f1 — промис fulfilled.then отправляет в очередь задачу на выполнение функции f2 — промис rejected.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не вызывается, и работают другие механизмы.
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"
В целом такое нужно делать довольно редко.
Вызвать можно сколько угодно раз, но только первый вызов повлияет на статус:
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 отличается от fulfilledconst p1 = new Promise((resolve, reject) => { resolve("maxcode"); }); // p1 станет fulfilled со значением "maxcode"
Ничего удивительного.
const p2 = new Promise((resolve, reject) => { reject("Ooops"); }); // p2 станет rejected по причине "Ooops"
Тоже всё понятно.
Теперь начнём делать странное. Обычно мы режектимся каким-то значением (строкой, объектом), но давайте зареджектимся промисом:
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:
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.
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-объект — это объект, у которого реализован метод 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.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.
Изобразим все статусы в виде схемы. Добавляется ещё слово settled. На верхнем уровне промисы делятся на две категории — unresolved и resolved:
unresolved (мы не вызывали ни resolve, ни reject) — он находится в состоянии pending.resolve или reject — он resolved, и уже нельзя повторно вызвать resolve/reject или бросить ошибку. Промис обрёл свою судьбу.Есть статья от одного из авторов спецификации промисов — States and Fates. В ней описано:
pending, fulfilled, rejected — это state (состояние) промиса.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 и про то, как с его помощью управлять состояниями промисов в цепочках.