Реализуем собственный Promise в JavaScript
- четверг, 5 февраля 2026 г. в 00:00:02
Всем привет.
Недавно я решил разобраться, как устроена внутренняя логика 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.
Промис всегда находится в одном из трёх состояний: pending, fulfilled, rejected.
В реализации Promise в ECMAScript executor-функция вызывается синхронно в момент создания объекта Promise.
Значение value или причина ошибки reason сохраняются во внутреннем состоянии промиса и используются при вызове обработчиков, подписанных через then.
Метод then всегда возвращает новый 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. Она должна:
Перевести промис в состояние 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:
принимает в качестве аргументов два колбэка: onFulfilled и onRejected https://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) { /* сделаем позже */ } }); } }
На первый взгляд выглядит логично, но есть как минимум две проблемы.
По спецификации обработчики 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
Попробуйте запустить то же самое в нашей реализации, и посмотреть, какой будет вывод.
Проверим поведение нативного промиса:
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); /* и кладет ее в очередь микро-задач */ }
Для удобства добавим отдельную функцию-обработчик для колбэка 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.
В нативной реализации 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.
В спецификации указано, если 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 });
Рассмотрим цепочку:
getUser() .then(user => getOrders(user.id)) // возвращается Promise .then(orders => console.log(orders));
Здесь функция, переданная в первый then, возвращает Promise. В этом случае цепочка должна работать так:
следующий then ждёт, пока этот Promise завершится;
он должен получить не сам Promise, а его результат;
если вложенный Promise завершился с ошибкой, ошибка передаётся дальше по цепочке.
Если этого не сделать, цепочки промисов не будут работать.
В спецификации Promise/A+ указано: если onFulfilled или onRejected возвращает Promise, то Promise, возвращаемый методом then, не должен завершаться сразу. Он обязан принять состояние возвращённого Promise и завершиться тем же образом: https://promisesaplus.com/#point-44
Другими словами:
fulfilled Promise превращает цепочку в fulfilled с тем же значением;
rejected Promise превращает цепочку в rejected с той же причиной;
до этого момента выполнение цепочки приостанавливается.
Такое поведение обеспечивает корректную работу цепочек 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") { /* обработаем позже */ } }); }
В реальной спецификации Promise/A+ проверка устроена по-другому. Спецификация не проверяет принадлежность к классу Promise. Вместо этого используется процедура разрешения промиса, работающая с thenable-объектами - любыми объектами или функциями, у которых есть метод then: https://promisesaplus.com/#the-promise-resolution-procedure.
В нашей статье мы сознательно ограничиваемся проверкой instanceof MyPromise.
Если 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 });
Мы обработали сложный и популярный кейс в промисах.
Теперь добавим обработчик ошибок. Он реализуется практически также, как и 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); }
Важно обратить внимание, что когда 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