Класс Promise внутри

В этой статье разберём класс Promise и асинхронность в JavaScript чуть глубже, чем это принято. Возможно, материал будет интересен не только новичкам, но и тем, кто уже какое-то время использует промисы, но не до конца разобрался, как они устроены внутри.

Вопросы, на которые редко дают внятный ответ

Когда я изучал класс Promise, у меня возникали наивные, но при этом очевидные вопросы, на которые никто не мог дать нормального ответа. Вот их список — а в следующих статьях мы попробуем на них ответить.

  1. Почему статусы промиса называются fulfilled и rejected, а в конструкторе мы используем функции resolve и reject? Создаётся впечатление, что люди полностью игнорируют английский язык и, рассказывая теорию, делают вид, что всё нормально. Давайте разберёмся, почему из reject получается rejected, а из resolvefulfilled.

  2. Как catch понимает, что произошло в начале цепочки, если сам он стоит в конце? Есть вечно повторяющаяся мантра про то, что catch ловит ошибку в цепочке промисов, но никто не объясняет, как технически это работает. Мы где-то бросаем ошибку, а потом она по какой-то причине приходит в catch.

  3. Что будет, если catch и finally окажутся в середине цепочки, а за ними ещё then? Мы привыкли ставить catch/finally в конце цепочки, но на самом деле они не обязаны быть в конце. На собеседовании этот вопрос часто ставит людей в тупик.

  4. Зачем нам понадобились микротаски, если уже были обычные таски? Когда говорят про промисы, используют понятие микротасков: сначала выполняется синхронный код, потом микротаски, потом макротаски. Но на вопрос, зачем вообще нужны микротаски, ответить мало кто может. Интересно разобраться, почему так исторически сложилось.

  5. Как реализован класс Promise и можно ли реализовать его самому? У класса Promise есть репутация чего-то низкоуровневого и фундаментального. Но в отличие от классов вроде Array или Map, которые мы действительно не можем реализовать сами, класс Promise реализуется достаточно легко — и мы это сделаем.

Практика важнее теории

Очень важный момент: практика важнее теории. Можно сколько угодно смотреть лекции, читать MDN или книги, но по-настоящему понять что-то получится, только решая задачи. По теме асинхронного программирования есть большой список задач, разбитый на темы:

  • Класс Promise: цепочки then, статусы промисов; методы catch и finally; полифил Promise, async/await.
  • Конкурентное выполнение: полифилы all, allSettled, race, any; Promise Pool / Crawler / Time Limit.
  • Таймеры: Debounce / Throttle; Rate Limiter; Time Limit Cache.
  • Event Loop: микротаски и макротаски; requestAnimationFrame.
  • async-функции: последовательное выполнение; Polling / Retry / Batching / Compose; асинхронная очередь.
  • Разное: колбэки / промисификация; рекурсивные структуры; Mutex / Semaphore.

Мотивация: какую задачу решают промисы

Часто промисы объясняют на примере сетевых запросов к бэкенду. Но рассмотрим другой пример — обработку нажатия клавиши на клавиатуре. Событие называется keydown, и когда мы нажимаем на клавишу, мы хотим, чтобы выполнился колбэк, который добавляет на страницу нажатую клавишу.

window.addEventListener( "keydown", (e) => { document.body.innerHTML += `<h1>${e.key}</h1>`; }, );

Примечание: здесь e — сокращение от event. Так лучше не делать, потому что e может означать и error, и event, и что угодно ещё. Сокращение использовано только ради того, чтобы код уместился в одну строку.

Что плохого в этом коде? В нём смешан служебный (инфраструктурный) код и код бизнес-логики:

  • addEventListener, передача колбэка — это технические ограничения, которые накладывает на нас сам JavaScript.
  • Прибавление к body.innerHTML нового HTML — это уже бизнес-логика нашего приложения.

Очень хотелось бы отделить одно от другого. Если на эту логику начать накручивать обработку других событий или ещё какую-то асинхронность, код станет совсем нечитаемым.

Наивная попытка

Первое, что приходит в голову — завести переменную value, в обработчике записать в неё e.key, а затем использовать value:

let value; window.addEventListener( "keydown", (e) => { value = e.key; }, ); document.body.innerHTML += `<h1>${value}</h1>`;

Есть только одна проблема: в момент, когда мы прибавляем value к innerHTML, там всё ещё undefined. Код выполняется не сверху вниз: присваивание value = e.key произойдёт когда-нибудь потом, а может быть, и никогда. Поэтому просто объявить переменную и сразу использовать её не получится.

Решение через промис

Промисы решают эту задачу так: код оборачивается в конструктор Promise. Внутри конструктора есть функция, принимающая resolve. Когда появляется значение, которое мы хотим положить в промис, мы вызываем resolve:

const keyPromise = new Promise(resolve => { window.addEventListener( "keydown", (e) => { resolve(e.key); }, ); }); keyPromise.then(value => { document.body.innerHTML += `<h1>${value}</h1>`; });

Чтобы потом достать значение из промиса, мы используем метод then, в который передаём колбэк — в него и придёт значение.

Промис — это обёртка над значением

Промис — это обёртка над значением, которое может появиться в любое время.

Например, я нажал на букву M — и в обёртке появилась строка "M". Под «обёрткой» я не имею в виду что-то суперсложное — это просто конкретный объект, внутри которого есть какое-то поле:

const keyPromise = { value: "M" };

Если быть точнее, это value напрямую нам недоступно. Чтобы это подчеркнуть, можно оформить промис в виде класса с приватным полем:

class Promise { #value = "M" } const keyPromise = new Promise();

Доступ к этому приватному значению есть только через метод then.

Подписка на появление значения

Здесь я использую не самый популярный в русскоязычном пространстве термин — подписка (по-английски subscribe). Но по сути именно это мы и делаем: берём объект промиса, вызываем then и передаём функцию — то есть подписываемся на событие появления значения в этом промисе.

keyPromise.then(key => { console.log(key); // "M" });

В тот момент, когда значение внутри промиса появилось, мы хотим быть об этом уведомлены, поэтому заранее передаём функцию. Когда значение появляется, эта функция выполняется.

Здесь есть два интересных момента:

  • Значения ещё нет. Чаще всего так и бывает: сначала подписываемся, потом событие происходит. Тогда колбэк выполнится, когда значение появится.
  • Значение уже есть. Промис — это в первую очередь структура данных для работы с конкретным значением. Может оказаться, что в момент подписки значение уже внутри. Тогда мы узнаем о нём практически сразу.

Если значения ещё нет, то функция в then выполнится, когда оно появится. Если значение уже внутри обёртки — мы узнаем об этом сразу.

Подписчиков может быть несколько

Может быть не очень очевидно, но подписаться на одно и то же событие можно несколькими подписчиками — сколько угодно. Гарантируется одно: когда событие произойдёт, все подписчики выполнятся в том порядке, в котором мы их добавляли.

keyPromise.then(key => console.log("🦊", key)); keyPromise.then(key => console.log("🐢", key)); keyPromise.then(key => console.log("🐙", key));

Как описать любой класс

Прежде чем разбирать устройство Promise, вспомним, как вообще описываются классы в ООП. Я смотрю на это с трёх сторон:

  1. Внешний интерфейс — то, как класс выглядит снаружи.
  2. Внутренняя реализация — то, как он устроен внутри.
  3. Семантика — контракты, гарантии, инварианты: как работают методы, что означают поля.

Тавтология здесь намеренная: интерфейс может быть только внешним, а реализация — только внутренней. Но важно подчеркнуть, что это две принципиально разные вещи.

Интерфейс Promise

Интерфейс промиса состоит из:

  1. Конструктора
  2. Метода then
  3. Методов catch и finally
  4. Статических методов

Про catch и finally отдельно поговорим позже — на самом деле это достаточно простые методы, которые можно реализовать самостоятельно, если есть метод then. Статические методы — это обычные функции, которые принимают промис и возвращают промис; это типовая задача, которую тоже стоит решить самому. А вот самое интересное и фундаментальное в промисе — это конструктор и метод then.

Реализация Promise: внутренние поля

Если заглянуть в спецификацию, мы увидим таблицу внутренних полей. В терминологии спецификации они называются internal slots, но для нас это просто приватные поля.

[[PromiseState]] — состояние

Это строка, в которой может содержаться одно из трёх значений: pending, fulfilled или rejected.

class Promise { #state = "PENDING"; }

По цветам и значениям английских слов смысл понятен:

  • pending — промис ещё не обрёл значение.
  • fulfilled — промис обрёл какое-то значение.
  • rejected — промис точно не обретёт значение, и есть причина, по которой он его не получит.

Подробнее про состояния — в следующей статье.

[[PromiseResult]] — результат

Это поле, в котором хранится значение, оказавшееся внутри промиса.

class Promise { #state = "PENDING"; #result; } const p = new Promise(resolve => resolve("maxcode")); // p.#result === "maxcode" // p.#state === "FULFILLED"

В момент вызова resolve("maxcode") промис сразу становится fulfilled со значением "maxcode".

[[PromiseFulfillReactions]] — колбэки на исполнение

Это массив, в котором лежат функции-подписчики. После создания промиса мы можем через then передать в него колбэки, и они собираются в этот массив:

class Promise { #state = "PENDING"; #result; #fulfillCallbacks = []; } const p = new Promise(resolve => /* ... */); p.then(cb1); p.then(cb2); // p.#fulfillCallbacks === [cb1, cb2]

Это те колбэки, которые будут вызваны, когда промис получит своё значение.

[[PromiseRejectReactions]] — колбэки на отклонение

Ещё один массив. Сюда колбэки попадают, если мы передаём их вторым аргументом метода then:

class Promise { #state = "PENDING"; #result; #fulfillCallbacks = []; #rejectCallbacks = []; } const p = new Promise(resolve => /* ... */); p.then(cb1); p.then(cb2, cb3); // p.#rejectCallbacks === [cb3]

То есть cb1 и cb2 попали в #fulfillCallbacks, а cb3 — в #rejectCallbacks. Эти колбэки будут вызваны, если промис перейдёт в состояние rejected.

[[PromiseIsHandled]] — был ли обработчик

class Promise { #state = "PENDING"; #result; #fulfillCallbacks = []; #rejectCallbacks = []; #isHandled = false; }

Рассмотрим ситуацию: промис режектится через какое-то время, и мы подписались на него с обработчиком реджекта.

const p = new Promise((resolve, reject) => { setTimeout(() => reject("Ooops"), 200); }); p.then( value => console.log("value", value), reason => console.log("reason", reason), ); // reason Ooops

Когда промис станет rejected, выполнится колбэк с аргументом reason, и в консоли мы увидим reason Ooops.

А что будет, если на промис не подписаться (ни через then, ни через catch, ни через finally)?

const p = new Promise((resolve, reject) => { setTimeout(() => reject("Ooops"), 200); }); // Нет подписки с помощью then, catch или finally // Unhandled Promise Rejection: Ooops

Мы получим событие Unhandled Promise Rejection. Этот механизм подталкивает нас к тому, что если промис может зареджектиться, у него хорошо бы иметь подписчика.

Что дальше

В следующей статье мы поговорим про состояния промисов и о том, что слова pending/fulfilled/rejected не исчерпывают всю картину — хорошо бы знать и другие слова: resolved, unresolved, locked-in, settled.