В этой статье разберём класс Promise и асинхронность в JavaScript чуть глубже, чем это принято. Возможно, материал будет интересен не только новичкам, но и тем, кто уже какое-то время использует промисы, но не до конца разобрался, как они устроены внутри.
Когда я изучал класс Promise, у меня возникали наивные, но при этом очевидные вопросы, на которые никто не мог дать нормального ответа. Вот их список — а в следующих статьях мы попробуем на них ответить.
Почему статусы промиса называются fulfilled и rejected, а в конструкторе мы используем функции resolve и reject? Создаётся впечатление, что люди полностью игнорируют английский язык и, рассказывая теорию, делают вид, что всё нормально. Давайте разберёмся, почему из reject получается rejected, а из resolve — fulfilled.
Как catch понимает, что произошло в начале цепочки, если сам он стоит в конце? Есть вечно повторяющаяся мантра про то, что catch ловит ошибку в цепочке промисов, но никто не объясняет, как технически это работает. Мы где-то бросаем ошибку, а потом она по какой-то причине приходит в catch.
Что будет, если catch и finally окажутся в середине цепочки, а за ними ещё then? Мы привыкли ставить catch/finally в конце цепочки, но на самом деле они не обязаны быть в конце. На собеседовании этот вопрос часто ставит людей в тупик.
Зачем нам понадобились микротаски, если уже были обычные таски? Когда говорят про промисы, используют понятие микротасков: сначала выполняется синхронный код, потом микротаски, потом макротаски. Но на вопрос, зачем вообще нужны микротаски, ответить мало кто может. Интересно разобраться, почему так исторически сложилось.
Как реализован класс Promise и можно ли реализовать его самому? У класса Promise есть репутация чего-то низкоуровневого и фундаментального. Но в отличие от классов вроде Array или Map, которые мы действительно не можем реализовать сами, класс Promise реализуется достаточно легко — и мы это сделаем.
Очень важный момент: практика важнее теории. Можно сколько угодно смотреть лекции, читать MDN или книги, но по-настоящему понять что-то получится, только решая задачи. По теме асинхронного программирования есть большой список задач, разбитый на темы:
then, статусы промисов; методы catch и finally; полифил Promise, async/await.all, allSettled, race, any; Promise Pool / Crawler / Time Limit.requestAnimationFrame.Часто промисы объясняют на примере сетевых запросов к бэкенду. Но рассмотрим другой пример — обработку нажатия клавиши на клавиатуре. Событие называется 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, вспомним, как вообще описываются классы в ООП. Я смотрю на это с трёх сторон:
Тавтология здесь намеренная: интерфейс может быть только внешним, а реализация — только внутренней. Но важно подчеркнуть, что это две принципиально разные вещи.
Интерфейс промиса состоит из:
thencatch и finallyПро catch и finally отдельно поговорим позже — на самом деле это достаточно простые методы, которые можно реализовать самостоятельно, если есть метод then. Статические методы — это обычные функции, которые принимают промис и возвращают промис; это типовая задача, которую тоже стоит решить самому. А вот самое интересное и фундаментальное в промисе — это конструктор и метод then.
Если заглянуть в спецификацию, мы увидим таблицу внутренних полей. В терминологии спецификации они называются 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.