Всем привет.
Недавно я решил разобраться, как устроена внутренняя логика Promise в JavaScript, и как она описана в спецификации. Для этого я реализовал собственный Promise. В процессе стало понятно, что такое упражнение может быть полезно не только мне, поэтому я решил оформить свое исследование в виде статьи.
Статья рассчитана на разработчиков, которые уже используют Promise, но хотят понять, как они устроены внутри. Вы можете использовать этот текст как практическое руководство и пройти тот же путь вместе со мной. Лучший способ получить пользу от материала - писать код параллельно. Скорее всего, такой подход займёт несколько часов. Простое чтение в лучшем случае даст лишь поверхностное понимание.
В статье рассматривается упрощённая реализация Promise - ориентируемся на понимание базовой модели. Мы не стремимся полностью воспроизвести алгоритмы ECMAScript и сознательно откладываем работу с thenable-объектами и внутренними Job Records.
Источники
-
спецификация Promises/A+ - https://promisesaplus.com/
Описывает базовую модель Promise, как абстрактный контракт поведения. Определяет, как должен работать
then, как передаются значения и ошибки по цепочке. Не привязана к JavaScript и не описывает встроенные механизмы языка. -
описание Promise в ECMAScript - https://tc39.es/ecma262/#sec-promise-objects
Описывает встроенный Promise как часть языка JavaScript и задает конкретную реализацию: конструктор, executor, внутренние состояния и операции, правила асинхронного выполнения обработчиков.
-
MDN - https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Promise
MDN не является нормативной спецификацией, но полезен для нас как справочник по публичному API и примерам использования.
Содержание
Опишем общую структуру.
Реализуем
resolveиrejectиз конструктора.Реализуем
thenи встретимся с проблемами наивной реализации.Исправим проблемы отсутствия асинхронности в нашем коде.
Добавим обработку, когда колбэк, переданный в
then- не функция.Добавим обработку случая, когда колбэк, переданный в
thenвозвращает промис.Добавим обработчик для колбэка
onRejected.Подведем итоги: готовый конструктор и функция
then.
Что известно о Promise
-
Промис всегда находится в одном из трёх состояний:
pending,fulfilled,rejected. -
В реализации Promise в ECMAScript executor-функция вызывается синхронно в момент создания объекта Promise.
-
Значение
valueили причина ошибкиreasonсохраняются во внутреннем состоянии промиса и используются при вызове обработчиков, подписанных черезthen. -
Метод
thenвсегда возвращает новый Promise.
Исходя из этого, начнём с общей структуры.
Общая структура Promise
class MyPromise { constructor(executor) { /* Promise принимает executor-функцию */ this.state = "pending"; /* начальное состояние */ this.value = undefined; /* значение для fulfilled */ this.reason = undefined; /* причина для rejected */ const resolve = (value) => {}; /* реализуем дальше */ const reject = (reason) => {}; /* реализуем дальше */ executor(resolve, reject); } then(onFulfilled, onRejected) {} catch(onRejected) {} finally(onFinalized) {} }
Реализация resolve и reject
Начнём с простой реализации resolve. Она должна:
Перевести промис в состояние
fulfilled.Сохранить результат.
const resolve = (value) => { this.state = "fulfilled"; this.value = value; };
Но после перехода в состояние fulfilled или rejected значение value или reason не должны изменяться. В реализации это выражается тем, что, если состояние не равно pending, то повторные вызовы resolve/reject игнорируются: https://promisesaplus.com/#point-21
Чтобы это реализовать, добавим проверку:
const resolve = (value) => { if (this.state !== "pending") { return; } this.state = "fulfilled"; this.value = value; };
Аналогично реализуем reject:
const reject = (reason) => { if (this.state !== "pending") { return; } this.state = "rejected"; this.reason = reason; };
Оговорка про состояние в нативных промисах
В нативной реализации Promise внутреннее состояние не является частью публичного API. Спецификация определяет состояния pending, fulfilled и rejected как внутренние слоты ([[PromiseState]]). Доступ к ним имеет только движок JavaScript: https://tc39.es/ecma262/#table-internal-slots-of-promise-instances .
В нашей реализации мы храним состояние в открытых свойствах объекта для наглядности и удобной отладки.
На этом этапе у нас есть базовый и корректный с точки зрения состояний Promise.
Проверка базовой логики
/* Promise с resolve */ const myPromiseResolve = new MyPromise((resolve, reject) => { resolve("hello promise"); }); console.log(myPromiseResolve.state); // fulfilled console.log(myPromiseResolve.value); // hello promise /* Promise с reject */ const myPromiseReject = new MyPromise((resolve, reject) => { reject("error in promise"); }); console.log(myPromiseReject.state); // rejected console.log(myPromiseReject.reason); // error in promise
Реализация then
По спецификации then:
принимает в качестве аргументов два колбэка:
onFulfilledиonRejectedhttps://promisesaplus.com/#point-22всегда возвращает новый Promise https://promisesaplus.com/#point-40
Простейшая реализация может выглядеть так:
class MyPromise { /* ... констркутор итд */ then(onFulfilled, onRejected) { /* then возвращает новый промис */ return new MyPromise((resolve, reject) => { if (onFulfilled) { /* вызываем callback со значением исходного промиса */ const result = onFulfilled(this.value); resolve(result); } if (onRejected) { /* сделаем позже */ } }); } }
На первый взгляд выглядит логично, но есть как минимум две проблемы.
Проблема №1: then выполняется синхронно
По спецификации обработчики onFulfilled и onRejected не должны вызываться синхронно. В Promises/A+ это сформулировано как требование, что обработчики могут быть вызваны только после того, как текущий стек выполнения будет полностью очищен: https://promisesaplus.com/#point-34
В ECMAScript описана реализация этого требования. Алгоритм PerformPromiseThen описывает, что обработчики не вызываются напрямую, а регистрируются как задания (jobs), которые попадают потом в очередь микрозадач: https://tc39.es/ecma262/#sec-performpromisethen
Проверим поведение нативного промиса:
new Promise(resolve => resolve(10)).then(console.log); console.log("end");
Фактический вывод:
end 10
Попробуйте запустить то же самое в нашей реализации, и посмотреть, какой будет вывод.
Проблема №2: обработчики then могут выполняться раньше resolve
Проверим поведение нативного промиса:
new Promise(resolve => { setTimeout(() => { console.log("1 - вызываем resolve"); resolve(100); }, 1000); }) .then(value => console.log("2 - then получил", value));
Фактический вывод:
1 - вызываем resolve 2 - then получил 100
Попробуйте запустить это в нашей реализации, и посмотреть, какой будет вывод.
Вывод в нашей реализации
Спустя 1 секунду вывод: then получил - undefined 1 - вызываем resolve
Так происходит, потому что мы вызываем обработчик сразу же, когда его добавили, а не когда промис завершился.
Попробуем исправить эти проблемы.
Асинхронный запуск обработчиков
Как мы выяснили выше, обработчики Promise onFulfilled и onRejected должны выполняться асинхронно: они добавляются в очередь и выполняются как микрозадачи. Реализуем это. Для простоты используем queueMicrotask: https://developer.mozilla.org/en-US/docs/Web/API/Window/queueMicrotask
Создадим вспомогательную функцию, которая будет помещать колбэки в очередь:
function runAsync(fn) { /* принимает на вход другую фунцию */ queueMicrotask(fn); /* и кладет ее в очередь микро-задач */ }
Обновляем then
Для удобства добавим отдельную функцию-обработчик для колбэка onFulfilled.
class MyPromise { /* констркутор итд */ then(onFulfilled, onRejected) { return new MyPromise((resolve, reject) => { /* создадим функцию-обработчик для колбэка onFulfilled */ const handleFulfilled = () => { if (onFulfilled) { const result = onFulfilled(this.value); resolve(result); } } /* далее решаем, что делать с этой функцией в зависимости от состояния промиса */ if (this.state === "fulfilled") { // если промис выполнен успешно // то вызываем обработчик колбэка, но асинхронно runAsync(handleFulfilled); } if (this.state === "pending") { /* ЧТО ДЕЛАТЬ ЗДЕСЬ? */ } if (this.state === "rejected") { /* напишем позже, когда напишем обработчик для колбэка onRejected */ } if (onRejected) {/* сделаем позже*/} }); } }
Теперь доработаем конструктор.
Хранилища обработчиков в конструкторе
Когда Promise находится в состоянии pending, мы не можем выполнить обработчики, поэтому их надо где-то хранить. Для этого создадим два массива: fulfilledHandlers и rejectHandlers. А при вызове resolve и reject выполним все накопленные обработчики в этих массивах асинхронно. Под обработчиками здесь мы понимаем функции, которые будут вызваны при переходе промиса в fulfilled или rejected.
Оговорка про разделение fulfilledHandlers и rejectHandlers
В нативной реализации Promise обработчики, переданные в then, не хранятся в виде отдельных списков для успешного и ошибочного завершения. В нашей реализации мы сознательно используем два отдельных массива fulfilledHandlers и rejectHandlers, чтобы сделать код более наглядным, и отдельно показать обработку успешного и ошибочного завершения промиса.
constructor(executor) { this.state = "pending"; this.value = undefined; this.reason = undefined; /* добавим массивы для хранения обработчиков fulfilled и reject */ this.fulfilledHandlers = []; this.rejectHandlers = []; const resolve = (value) => { if (this.state !== "pending") return; this.state = "fulfilled"; this.value = value; /* выполняем асихронно все колбэки */ runAsync(() => this.fulfilledHandlers.forEach(callback => callback())); } const reject = (reason) => { if (this.state !== "pending") return; this.state = "rejected"; this.reason = reason; /* выполняем асихронно все колбэки */ runAsync(() => this.rejectHandlers.forEach(callback => callback())); } /* сделаем более безопасным вызов executor - функции */ try { executor(resolve, reject); } catch(e) { reject(e); } }
Теперь then может просто сохранять обработчик:
if (this.state === "pending") { this.fulfilledHandlers.push(handleFulfilled); }
Проверка асинхронности и цепочек
new MyPromise((resolve) => { console.log("promise start"); setTimeout(() => { console.log("1 - вызываем resolve"); resolve(1); }, 1000); }) .then(value => { console.log("then 1"); console.log(value); return value + 1; }) .then(value => { console.log("then 2"); console.log(value); }); console.log("sync");
Вывод в консоль
promise start sync 1 - вызываем resolve then 1 1 then 2 2
Мы устранили две проблемы, о которых писали выше, теперь поведение соответствует нативному Promise. Попробуйте самостоятельно придумать и протестировать любую другую цепочку.
Рассмотрим еще два популярных случая, связанных с колбэком onFulfilled.
№1: когда onFulfilled - не функция
В спецификации указано, если onFulfilled не функция, то обработчик игнорируется, а исходное значение пробрасывается дальше без изменений: https://promisesaplus.com/#point-23.
const handleFulfilled = () => { /* добавим проверку, функция ли пришла в качестве колбэка */ if (typeof onFulfilled !== "function") { /* и если нет, то просто зарезолвим value */ resolve(this.value); return; } /* заодно сделаем вызов более безопасным */ try { const result = onFulfilled(this.value); resolve(result); } catch (e) { reject(e); } }
new MyPromise((resolve, reject) => resolve(1)) /* передана не функция, 1 пробрасывается дальше без изменений */ .then(2) .then(value => { console.log("then"); console.log(value); // вывод 1 });
№2: когда onFulfilled возвращает другой Promise
Рассмотрим цепочку:
getUser() .then(user => getOrders(user.id)) // возвращается Promise .then(orders => console.log(orders));
Здесь функция, переданная в первый then, возвращает Promise. В этом случае цепочка должна работать так:
следующий
thenждёт, пока этот Promise завершится;он должен получить не сам Promise, а его результат;
если вложенный Promise завершился с ошибкой, ошибка передаётся дальше по цепочке.
Если этого не сделать, цепочки промисов не будут работать.
Уточнение о разрешении значения Promise
Значение промиса не может быть другим промисом или thenable-объектом, и должно быть разрешено до конечного значения. В статье мы отклоняемся от нативного поведения и не реализуем эту логику в resolve в конструкторе. Обрабатываем Promise только на уровне then.
В спецификации Promise/A+ указано: если onFulfilled или onRejected возвращает Promise, то Promise, возвращаемый методом then, не должен завершаться сразу. Он обязан принять состояние возвращённого Promise и завершиться тем же образом: https://promisesaplus.com/#point-44
Другими словами:
fulfilled Promise превращает цепочку в fulfilled с тем же значением;
rejected Promise превращает цепочку в rejected с той же причиной;
до этого момента выполнение цепочки приостанавливается.
Такое поведение обеспечивает корректную работу цепочек then.
Текущий then
Напомним, как теперь выглядит наш then:
then(onFulfilled, onRejected) { return new MyPromise((resolve, reject) => { const handleFulfilled = () => { if (typeof onFulfilled !== "function") { resolve(this.value); return; } try { const result = onFulfilled(this.value); if (result instanceof MyPromise) { /* что делать в этом случае? */ } else { resolve(result); } } catch (e) { reject(e); } }; if (this.state === "fulfilled") { runAsync(handleFulfilled); } if (this.state === "pending") { this.fulfilledHandlers.push(handleFulfilled); } if (this.state === "rejected") { /* обработаем позже */ } }); }
Оговорка про использование instanceof
В реальной спецификации Promise/A+ проверка устроена по-другому. Спецификация не проверяет принадлежность к классу Promise. Вместо этого используется процедура разрешения промиса, работающая с thenable-объектами - любыми объектами или функциями, у которых есть метод then: https://promisesaplus.com/#the-promise-resolution-procedure.
В нашей статье мы сознательно ограничиваемся проверкой instanceof MyPromise.
Если then вернул Promise
Если result - это Promise, то мы просто вызываем then на нем:
result.then(resolve, reject);
когда
resultвыполнится - мы вызываемresolve(value)нового Promise;если
resultупадёт - вызываемreject(reason)нового Promise.
То есть по сути мы подписываемся на результат другого Promise и передаём его состояние дальше.
Обновим код:
const result = onFulfilled(this.value); if (result instanceof MyPromise) { result.then(resolve, reject); return; } resolve(result);
Проверим
const p = new MyPromise((resolve) => { resolve(1); }); /* первый then возвращает другой Promise */ p.then(value => { return new MyPromise((resolve) => { setTimeout(() => { resolve(value + 1); }, 100); }); }) /* второй then ждёт, пока этот Promise завершится */ .then(result => { /* и получает значение, а не сам объект Promise */ console.log(result); // ожидаем 2 });
Мы обработали сложный и популярный кейс в промисах.
Реализация обработки onRejected
Теперь добавим обработчик ошибок. Он реализуется практически также, как и handleFulfilled.
провряем, является ли колбэк функцией;
запускаем колбэк, потом проверяем, не является ли результат промисом;
вызываем
resolveс полученным результатом.
then(onFulfilled, onRejected) { /* .... другой код*/ const handleReject = () => { if (typeof onRejected !== "function") { reject(this.reason); return; } try { const result = onRejected(this.reason); if (result instanceof MyPromise) { result.then(resolve, reject); return; } // важно, именно resolve resolve(result); } catch (e) { reject(e); } } }
И подключаем его:
if (this.state === "rejected") { runAsync(handleReject); } if (this.state === "pending") { this.fulfilledHandlers.push(handleFulfilled); // уже было this.rejectHandlers.push(handleReject); }
Почему resolve, а не reject?
Важно обратить внимание, что когда onRejected успешно обработал ошибку и не выбросил исключение, то цепочка должна стать fulfilled. Для этого нужно вызвать resolve с полученным result.
Пример нативного поведения:
Promise.reject("error") .then(null, err => { console.log("handled:", err); return "ok"; }) .then(console.log);
Вывод:
handled: error ok
Ошибка обработана, выполнение продолжается нормально. Посмотрим, как это работает в нашем случае.
onRejected возвращает Promise (fulfilled):
new MyPromise((resolve, reject) => { reject("error"); }) .then(null, (err) => { /* фиксим ошибку и возвращаем Promise */ return new MyPromise((resolve) => { resolve("fixed"); }); }) .then(value => console.log("OK:", value)); // ожидаем OK fixed;
onRejected возвращает Promise (rejected):
new MyPromise((resolve, reject) => { reject("error"); }) .then(null, (err) => { /* обработчик тоже возвращает Promise, но он rejected */ return new MyPromise((resolve, reject) => { reject("not fixed"); }); }) .then( value => console.log("OK:", value), err => console.log("ERR:", err) // ожидаем: ERR: not fixed );
Финальная версия промиса
function runAsync(fn) { queueMicrotask(fn); } class MyPromise { constructor(executor) { this.state = "pending"; this.value = undefined; this.reason = undefined; this.fulfilledHandlers = []; this.rejectHandlers = []; const resolve = (value) => { if (this.state !== "pending") return; this.state = "fulfilled"; this.value = value; runAsync(() => this.fulfilledHandlers.forEach(callback => callback())); } const reject = (reason) => { if (this.state !== "pending") return; this.state = "rejected"; this.reason = reason; runAsync(() => this.rejectHandlers.forEach(callback => callback())); } try { executor(resolve, reject); } catch(e) { reject(e); } } then(onFulfilled, onRejected) { return new MyPromise((resolve, reject) => { const handleFulfilled = () => { if (typeof onFulfilled !== "function") { resolve(this.value); return; } try { const result = onFulfilled(this.value); if (result instanceof MyPromise) { result.then(resolve, reject); return; } resolve(result); } catch (e) { reject(e); } } const handleReject = () => { if (typeof onRejected !== "function") { reject(this.reason); return; } try { const result = onRejected(this.reason); if (result instanceof MyPromise) { result.then(resolve, reject); return; } resolve(result); } catch(e) { reject(e); } } if (this.state === "fulfilled") { runAsync(handleFulfilled); } if (this.state === "pending") { this.fulfilledHandlers.push(handleFulfilled); this.rejectHandlers.push(handleReject); } if (this.state === "rejected") { runAsync(handleReject); } }); } catch(onRejected) { // TODO } finally(onFinally) { // TODO } }
Итоги
У нас есть собственная реализация Promise, которая повторяет базовую модель работы нативных промисов:
асинхронное выполнение обработчиков;
цепочки then;
корректную передачу значений и ошибок;
поддержку вложенных Promise.
Мы сознательно упростили ряд моментов: не реализовывали работу с thenable-объектами в общем виде, не рассматривали внутренние Job Records и не стремились полностью воспроизвести алгоритмы ECMAScript. Цель была разобраться в базовой логике.
Надеюсь, что вы дошли до этого места и писали код параллельно, и теперь воспринимаете Promise как вполне конкретный механизм с понятными правилами.
Что можно сделать самостоятельно
Написать тесты для проверки цепочек.
Реализовать
catch.Реализовать
finally.Попробовать заменить
instanceof MyPromiseна более универсальную проверку thenable-объектов.
Ссылка на полную реализацию промиса c готовыми catch и finally: https://codepen.io/zheleznikov/pen/dPMjmBM
Комментарии (14)

Beholder
04.02.2026 15:45теперь поведение соответствует нативному Promise
Это довольно сомнительно, так как нативные микротаски обрабатываются раньше нативных промисов. Получилась разве что "игрушечная" имитация для учебных целей. Вряд ли можно написать полноценную замену по спецификации если нет нативной поддержки.

polwen Автор
04.02.2026 15:45Согласен с вами.
Я пишу в статье не полноценную замену нативного Promise, а скорее воспроизвожу его модель поведения. Цель была скорее разобраться и показать, как именно работает промис через попытку его повторить.

Alexandroppolus
04.02.2026 15:45нативные микротаски обрабатываются раньше нативных промисов
Не совсем понял этот поинт. Вот здесь будет вывод 0 1 2, и в ноде, и в браузере:
queueMicrotask(() => console.log(0)) Promise.resolve().then(() => console.log(1)); queueMicrotask(() => console.log(2))второй queueMicrotask не вылез вперед then

alexchizik
04.02.2026 15:45И всё-таки это модельно корректная реализация, которая нужна для общего понимания как это работает "под капотом". Это примерно то же самое, что надо бы, что бы разработчики знали как работают формы в браузере, применять на практике это скорее всего не придётся, но кто знает)

InveterateCoder
04.02.2026 15:45Это скорее некорректно. Нативные промисы и есть микротаски, и написать свои промисы вполне возможно используя queueMicrotask(), и делается довольно часто, если поискать cancelable promises, в npm репе.

Alexandroppolus
04.02.2026 15:45Проверку
instanceof MyPromise(точнее, проверку на thenable, о чем упоминается в конце статьи) так же надо делать в конструкторе промиса, в функцииresolve
polwen Автор
04.02.2026 15:45Спасибо, что обратили на это внимание.
Я упростил этот момент и обрабатываю thenable только в
then, что не смешивать. Главное было найти баланс между тем, чтобы было похоже на нативный промис и при этом не переусложнить описание.
Alexandroppolus
04.02.2026 15:45Я упростил этот момент и обрабатываю thenable только в
then, что не смешиватьНу тут вы радикально упростили ) Значением промиса не может быть другой промис (thenable), это ключевой момент, оно резолвится "до упора".
Логику resolve даже надо не добавить, а перенести в конструктор, тогда она не понадобится внутри then

polwen Автор
04.02.2026 15:45Согласен. Спасибо, что подсветили этот момент. Потому что мне бы не хотелось, чтобы были грубые нарушения. Переписывать реализацию - это наверное чересчур. Поэтому добавил в текст оговорку на эту тему.

winkyBrain
04.02.2026 15:45Подход "написать своё, чтобы понять, как работает уже существующее" - очень классный и рабочий! Когда учился, на одном из курсов по React на ютубе, прежде чем переходить к использованию Redux(само собой после объяснений, для чего вообще нужен глобальный стор), автор предложил вместе написать свой. И этот шаг позволил начинающим изучать впоследствии настоящий Redux с хотя бы примерным пониманием того, как оно в целом там всё устроено. За это лайк)
А вот от строковых литералов повсюду, особенно ключевых состояний промиса, кровь из глаз. Енамы нам не завезли, так как тут вроде голый JS, но можно же было например словарь какой-нибудь слепить или просто три строковых константы сделать в крайнем случае, и к ним уже везде обращаться.

polwen Автор
04.02.2026 15:45Рад, что такой подход вам близок :)
По поводу строковых литералов, согласен. Можно было бы сделать объект для хранения значений, но я решил так не делать, чтобы примеры в тексте читались проще. И читателю не нужно было держать в голове дополнительные переменные, которые не видны в самом фрагменте.
yarkov
Осталось понять для чего нам это и будет счастье ))
polwen Автор
:)
Это описание реализации, чтобы разобраться как устроен промис через попытку создать его поведение. В таком виде он еще сыроват для использования.