javascript

Реализуем собственный Promise в JavaScript

  • четверг, 5 февраля 2026 г. в 00:00:02
https://habr.com/ru/articles/990688/

Всем привет.

Недавно я решил разобраться, как устроена внутренняя логика 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 и примерам использования.

Содержание

  1. Опишем общую структуру.

  2. Реализуем resolve и reject из конструктора.

  3. Реализуем then и встретимся с проблемами наивной реализации.

  4. Исправим проблемы отсутствия асинхронности в нашем коде.

  5. Добавим обработку, когда колбэк, переданный в then - не функция.

  6. Добавим обработку случая, когда колбэк, переданный в then возвращает промис.

  7. Добавим обработчик для колбэка onRejected.

  8. Подведем итоги: готовый конструктор и функция then.

Что известно о Promise

  1. Промис всегда находится в одном из трёх состояний: pending, fulfilled, rejected.

    https://promisesaplus.com/#promise-states

  2. В реализации Promise в ECMAScript executor-функция вызывается синхронно в момент создания объекта Promise.

    https://tc39.es/ecma262/#sec-promise-executor

  3. Значение value или причина ошибки reason сохраняются во внутреннем состоянии промиса и используются при вызове обработчиков, подписанных через then.

    https://tc39.es/ecma262/#sec-performpromisethen

  4. Метод then всегда возвращает новый Promise.

    https://promisesaplus.com/#point-40

Исходя из этого, начнём с общей структуры.

Общая структура 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. Она должна:

  1. Перевести промис в состояние fulfilled.

  2. Сохранить результат.

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:

Простейшая реализация может выглядеть так:

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/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 как вполне конкретный механизм с понятными правилами.

Что можно сделать самостоятельно

  1. Написать тесты для проверки цепочек.

  2. Реализовать catch .

  3. Реализовать finally.

  4. Попробовать заменить instanceof MyPromise на более универсальную проверку thenable-объектов.

Ссылка на полную реализацию промиса c готовыми catch и finally: https://codepen.io/zheleznikov/pen/dPMjmBM