В этой статье разберём метод then. Расскажу две вещи: что then принимает (какие колбэки и как они выполняются) и что происходит, когда мы в этих колбэках возвращаем разные значения или бросаем ошибки — как это влияет на статус промиса, возвращаемого из then.
Когда мы описываем функцию, есть два пункта:
Применительно к then:
then принимает два аргумента-функции.then возвращает новый промис.Но всё не так просто. У этих двух пунктов есть три сноски:
Promise (детали — в материалах про ООП).p.then(arg1, arg2);
onFulfilled и onRejected.fulfilled).p.then(onFulfilled, onRejected);
p fulfilled на момент подписки, то onFulfilled всё равно будет вызван, но его вызовет не сам метод then, а очередь задач — когда колбэк выйдет из очереди.p pending, то onFulfilled вызовется после перехода промиса в fulfilled (тоже через очередь).Я буду использовать термины «микротаски», «очередь микротасков», но нужно понимать, что такого слова в спецификации нет. В спецификации есть понятие Promise Job Queue, а host (окружение, внутри которого запускается JavaScript — браузер, Node.js) обеспечивает нас всеми очередями: и очередью микротасков, и очередью обычных тасков (макротасков).
Ключевой момент: даже если промис уже fulfilled, колбэк onFulfilled не вызывается синхронно. Метод then не вызывает его сам, а отправляет в очередь, и потом, когда появится возможность, колбэк достаётся из очереди и выполняется. Аналогично работает onRejected.
const p = new Promise( resolve => resolve("maxcode") ); p.then(value => console.log(value)); console.log("hello");
Здесь resolve вызывается синхронно, поэтому на момент присваивания p промис уже fulfilled. Несмотря на это, колбэк с console.log(value) не выполнится синхронно. В момент вызова then колбэк отправляется в очередь микротасков, но не выполняется. Сначала выполнится синхронный console.log("hello") — мы увидим hello. И только потом из очереди достанется колбэк со строкой maxcode.
Вывод: сначала hello, потом maxcode.
Это определяет статус промиса p:
p fulfilled — вызывается onFulfilled.p rejected — вызывается onRejected.Статус промиса
pвлияет только на то, какой из колбэков-аргументов будет вызван. Он не влияет на статус возвращаемого изthenпромиса.
Метод then возвращает следующий промис — это основная идея промисов, позволяющая строить цепочки. Многие думают, что новый промис получает статус в зависимости от статуса p. Это неверно.
const p2 = p.then(onFulfilled, onRejected);
Нужно понимать три вещи:
p2 — это новый промис. then возвращает не this, а новый промис.p2 изначально находится в состоянии pending — всегда в момент создания. (Через конструктор можно создать промис в любом из трёх состояний, но через then — всегда сначала pending.)p2 зависит исключительно от того, как завершится вызванный колбэк (onFulfilled или onRejected — смотря какой был вызван).const p2 = p.then(onFulfilled, onRejected);
p.p.then(fn) второй аргумент undefined.p2.Разберём примеры.
const p2 = p.then(value => { return "A"; }); // p2 — fulfilled со значением "A"
const p2 = p.then(value => { return undefined; }); // Ничего не вернули (вернули undefined) // p2 — fulfilled со значением undefined
const p2 = p.then(value => { throw "B"; }); // Синхронно бросили ошибку (или что угодно) // p2 — rejected по причине "B"
const p2 = p.then(value => { return new Promise(resolve => resolve("C")); }); // Вернули промис, который уже fulfilled (или станет fulfilled, а сейчас pending) // p2 — fulfilled со значением "C"
В тот момент, когда возвращённый промис становится fulfilled, p2 тоже становится fulfilled с тем же значением.
const p2 = p.then(value => { return new Promise((_, reject) => reject("D")); }); // Вернули промис, который уже rejected (или станет rejected, а сейчас pending) // p2 — rejected со значением "D"
const p2 = p.then(value => { return new Promise(() => {}); }); // Вернули промис, который всегда pending // p2 — pending
Если внутри промиса не вызвать ни resolve, ни reject, он навсегда останется pending — и p2 тоже.
Глобально есть три ситуации:
fulfilled-промис) → p2 становится fulfilled.rejected-промис) → p2 становится rejected.p2 остаётся pending.Любимый вопрос на собеседованиях. Но сначала разберёмся, что вообще такое цепочка.
До промисов вы сталкивались с записью, где через точку вызывается несколько методов. Например, Set:
new Set() .add("M") .add("A") .add("X");
Метод add позволяет выстраивать цепочку. Это сделано для удобства. Раскроем цепочку:
const s = new Set(); const sm = s.add("M"); const sma = sm.add("A"); const smax = sma.add("X");
Кстати, добавить несколько значений в
Set.addчерез запятую нельзя — в отличие отArray.push, который принимает произвольное число аргументов. Сpushпосле появления spread-синтаксиса стали получать переполнение стека, если передать очень много аргументов. Поэтому при созданииSetрешили, чтоaddбудет принимать один аргумент, но возвращать самSet— чтобы можно было строить цепочку.
На самом деле это ложная цепочка: s, sm, sma — это всё одна и та же переменная, равная исходному Set. Поэтому можно записать и так:
new Set() .add("M") .add("A") .add("X"); const s = new Set(); s.add("M"); s.add("A"); s.add("X");
Это method chaining, который просто прокидывает дальше тот же самый объект. Но в промисах происходит не это.
То, что происходит в промисах, гораздо ближе к методам массива. Например, map:
[1, 2, 3, 4] .map(x => x * 2) .map(x => x * 3); const a1 = [1, 2, 3, 4]; const a2 = a1.map(x => x * 2); const a3 = a2.map(x => x * 3);
Здесь реально создаётся три массива. Так же и в промисах: каждый раз, когда вы вызываете then, catch или finally, создаётся новый промис.
new Promise((_, reject) => reject(1)) .then(x => x * 2, x => x * 3) .then(x => x * 5, x => x * 7) .then(console.log, console.log);
Эта цепочка превращается в четыре отдельных промиса. Все три then подписаны не на первый промис: первый then подписан на промис из конструктора, второй then — на промис, который вернулся из первого then, и так далее.
const p1 = new Promise((_, reject) => reject(1)); const p2 = p1.then(x => x * 2, x => x * 3); const p3 = p2.then(x => x * 5, x => x * 7); const p4 = p3.then(console.log, console.log);
Чтобы определить, что куда выведется, я рекомендую подписывать состояние каждого промиса и его значение. Обозначения:
R — rejected, F — fulfilled, P — pending.Идём по шагам:
p1 — мы синхронно вызвали reject(1), поэтому сразу R<1>.p2, p3, p4 — изначально P (метод then всегда возвращает pending-промис).p1 в состоянии rejected → второй колбэк (x => x * 3) уходит в очередь и вызывается со значением 1. 1 * 3 = 3. Колбэк вернул значение, значит p2 становится F<3>. (Заметьте: p1 был rejected, но p2 стал fulfilled — статус p1 определяет только то, в какой колбэк попадём.)p2 — fulfilled, попадаем в первый колбэк p3: 3 * 5 = 15. p3 становится F<15>.p3 — fulfilled, попадаем в первый колбэк p4: console.log(15). Функция console.log возвращает undefined. p4 становится F<undefined>.const p1 = new Promise((_, reject) => reject(1)); // R<1> const p2 = p1.then(x => x * 2, x => x * 3); // F<3> const p3 = p2.then(x => x * 5, x => x * 7); // F<15> const p4 = p3.then(console.log, console.log); // F<und>
Типичная ситуация: промис режектится, и у then нет второго аргумента.
new Promise((_, reject) => reject(1)) .then(x => x * 2) .then(x => x * 5) .then(console.log, console.log);
Раскроем в четыре промиса:
const p1 = new Promise((_, reject) => reject(1)); const p2 = p1.then(x => x * 2); const p3 = p2.then(x => x * 5); const p4 = p3.then(console.log, console.log);
p1 сразу R<1>. Дальше мы должны попасть во второй колбэк, но его нет. Вот ключевое правило:
Если нет второго колбэка, вместо него подставляется функция
x => { throw x; }— то есть функция, которая принимает значение и бросает его.
Тогда:
p1 — R<1>. Вместо отсутствующего второго колбэка подставляется x => { throw x; }. В x попадает 1, мы бросаем 1. Раз в колбэке бросили значение — p2 становится R<1>.p3 ситуация повторяется: p2 — rejected, второй колбэк отсутствует → x => { throw x; } → p3 становится R<1>.p3 — rejected, попадаем во второй console.log. Он возвращает undefined → p4 становится F<undefined>.const p1 = new Promise((_, reject) => reject(1)); // R<1> const p2 = p1.then(x => x * 2, x => { throw x; }); // R<1> const p3 = p2.then(x => x * 5, x => { throw x; }); // R<1> const p4 = p3.then(console.log, console.log); // F<und>
Возможно, вы встречали метод catch и думаете, что это специальный метод, который волшебным образом ловит ошибку где-то в цепочке промисов:
new Promise((_, reject) => reject(1)) .then(x => x * 2) .then(x => x * 5) .catch(console.log);
Часто говорят, что «catch ловит ошибку в цепочке промисов». В целом так говорить можно, но позиционирование немного другое. Правильнее:
catchпозволяет указать толькоonRejected-колбэк.
Мне кажется обманом, когда говорят, что catch ловит ошибку в цепочке, потому что из этого следует, будто catch обязан стоять в конце цепочки, и непонятно, что должно происходить после него. А главное — теряется адекватное понимание того, что catch из себя представляет внутри.
Метод catch — это метод конкретного промиса, такой же, как then. Когда мы пишем then, мы же не говорим, что он обрабатывает значение из какого-то другого промиса пятью уровнями выше. Мы вызываем then на том промисе, на котором вызвали. То же с catch: колбэк, переданный в catch, выполнится, когда тот промис, на котором мы подписаны, станет rejected.
new Promise((_, reject) => reject(1)) // R<1> .then(x => x * 2) // R<1> .then(x => x * 5) // R<1> .catch(console.log); // >> 1
catch вызывается на последнем промисе, который вернулся из последнего then. Колбэк catch выполняется для значения из последнего R<1> — он ничего не знает о начале цепочки.
Чуть подробнее про замену на x => { throw x; }. На самом деле это не вся правда. Честнее так:
Если второй аргумент
then— не функция, вместо него подставляетсяx => { throw x; }.
const p1 = new Promise((_, reject) => reject(1)); // R<1> const p2 = p1.then(x => x * 2, "maxcode"); // R<1> const p2 = p1.then(x => x * 2, ["A", "B", "C"]); // R<1> const p2 = p1.then(x => x * 2, null); // R<1> const p2 = p1.then(x => x * 2, undefined); // R<1> const p2 = p1.then(x => x * 2); // R<1>
Во всех случаях второй аргумент — не функция (строка, массив, null, undefined или вообще отсутствует), поэтому он заменяется на x => { throw x; }, и p2 становится rejected тем же значением, что и p1.
С первым аргументом работает похожий механизм:
Если первый аргумент
then— не функция, вместо него подставляетсяx => x.
const p1 = new Promise(resolve => resolve(1)); // F<1> const p2 = p1.then(undefined, x => x * 2); // F<1>
Если p1 — fulfilled, то вместо undefined (или любого не-функционального значения) подставляется x => x: принимается 1, возвращается 1, и p2 становится F<1>.
Эквивалентная запись:
const p1 = new Promise(resolve => resolve(1)); // F<1> const p2 = p1.then(x => x, x => x * 2); // F<1>
Именно это и делает catch: по сути это then с первым аргументом undefined, который потом заменяется на x => x:
const p1 = new Promise(resolve => resolve(1)); // F<1> const p2 = p1.catch(x => x * 2); // F<1>
Метод
catchпозволяет подписаться на промис так, чтобы обрабатывать только случай реджекта. Это буквально синтаксический сахар для случая, когда мы не хотим писать первый аргументthen.
Про метод catch подробно — в следующей статье: как именно он «ловит ошибку в цепочке», чем отличается then с двумя аргументами от then + catch, и при чём тут микротаски.