Идея написать собственную реализацию Promise возникла в процессе подготовки к интервью, поскольку необходимость не просто разобраться в инструменте, а воссоздать его более менее точное подобие, требует куда более глубокого погружения в тему. Исходный код с тестами доступен по ссылке, данная статья - возможность для автора еще лучше консолидировать полученный в процессе опыт и, возможно, открыть что - то новое для читателя, который регулярно использует промисы на практике.

Глоссарий

  • Экземпляр, инстанс, промис, обещание - созданный new PromiseImplementation(...) объект.

  • Consumer, подписчик - любой публичный метод экземпляра, .then | .catch | .finally.

  • Satisfyer - приватный метод resolve или reject экземпляра, вызов которого эквивалентен выполнению обещания.

  • Выполнение обещания - изменение state экземпляра с ожидания на "выполнено" или "отклонено", запись аргумента satisfyer в result экземпляра.

Types

Из того, что хорошо известно всем, кто хоть раз использовал new Promise(...) на практике - обещание имеет всего 3 публичных метода: .then, .catch и .finally и, по меньшей мере, 2 приватных свойства, state и result

Последние можно сразу определить в классе: изначальный state экземпляра, это всегда ожидание, "pending", которое, в зависимости от течения жизненного цикла объекта, может 1 раз измениться на 'fulfilled' или 'rejected', результат же может быть любым.

export type PromiseState = 'pending' | 'fulfilled' | 'rejected';
export type PromiseResult = any;
import { PromiseState, PromiseResult } from './types';

export default class PromiseImplementation {
    private state: PromiseState = 'pending';
    private result: PromiseResult;
}

Constructor

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

export type ExecutorCallback = (argument?: any) => void;
export type PromiseExecutor = (resolve: ExecutorCallback, reject: ExecutorCallback) => any;

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

export default class PromiseImplementation {
    private state: PromiseState = 'pending';
    private result: PromiseResult;

    constructor(executor?: PromiseExecutor) {
        if (typeof executor !== 'function') {
            throw new Error('Invalid executor is provided.');
        }

        try {
            executor(this.resolve.bind(this), this.reject.bind(this));
        } catch (err) {
            this.reject(err);
        }
    }
}

Конструкция обернута в try..catch для того, чтобы даже в случае ошибки в executor, был создан полноценный экземпляр. Это позволит конечному пользователю самому обработать исключение в .catch, а не приведет к краху всей цепочки подписчиков. 

Исключение только одно, отсутствие executor в принципе, в этом случае экземпляр не создается.

Satisfyers

Реализация этих методов подразумевает следующее:

  • Вызов метода влечёт изменение state & result экземпляра, то есть выполнение обещания. Такая возможность должна предоставляться только один раз.

  • Если за обещанием следуют consumer - ы, их аргументы должны быть вызваны после выполнения обещания. 

В примере (1)

const promise = new PromiseImplementation((resolve) => {
	setTimeout(() => resolve(1), 1000);
});

promise.then((x) => {
	console.log(x);
});

promise.then(
 (x) => {
	console.log(x * 2);
 },
 (err) => {
	console.log(err);
 }
);

promise.then();

вызов resolve через 1 секунду должен изменить promise['state'] на 'fulfilled', promise['result'] на 1 и привести к вызову коллбеков

(x) => { console.log(x); } и (x) => { console.log(x * 2); }

c promise['result'] в качестве x

Сами подписчики .then на строчках 5, 9, 18, естественно, не дожидаются отложенного выполнения resolve, это обычные методы, каждый из которых будет последовательно вызван сразу после создания экземпляра.

На этом этапе намечаются первые наброски по будущей реализации .thenи его связке с resolve & reject:

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

  • В resolve & rejectпосле модификации состояния экземпляра, необходимо запустить консъюмеры в том же порядке, в котором их расположил пользователь, пробросив в них сохраненные коллбеки.

Определим поле для хранения:

export type ConsumerCallback = (argument?: any) => any;
export default class PromiseImplementation {
    private state: PromiseState = 'pending';
    private result: PromiseResult;

    constructor(executor?: PromiseExecutor) {/* ... */}
  
  	private consumersArgs: ConsumerCallback[][] = [];
}

Для примера (1), promise['consumersArgs'] после строчки 18 должно будет выглядеть так:

[
  [handleSuccess, undefined], 
  [handleSuccess, handleError], 
  [undefined, undefined]
] 

Коллбеки будут сохраняться в том же порядке, в котором происходит выполнение подписчиков, и так же последовательно вызываться после выполнения обещания, через цикл:

export default class PromiseImplementation {
    private state: PromiseState = 'pending';
    private result: PromiseResult;

    constructor(executor?: PromiseExecutor) {/* ... */}
  
  	private consumersArgs: ConsumerCallback[][] = [];
  
   	private resolve(value?: any) {
        if (this.state === 'pending') {
            this.state = 'fulfilled';
            this.result = value;
          
            if (this.consumersArgs.length) {
                for (let consumerArgs of this.consumersArgs) {
                    this.then(...consumerArgs);
                }
            }
        }
    }
}

Код выше полностью бы отвечал требованиям, если бы не 1 нечастый кейс, когда resolve вызывается с экземпляром обещания в качестве аргумента.

const original = new PromiseImplementation(resolve => setTimeout(() => resolve('originalResult'), 1000));
const cast = new PromiseImplementation((resolve, reject) => resolve(original));

cast.then((x) => {
    console.log(x); // 'originalResult'
});

Обещание cast выполнится только, когда выполнится обещание original, и выполнится со значением original['result'], а не самим original, как может показаться на первый взгляд. С учетом этого, изменение состояния экземпляра и вызов его подписчиков имеет смысл вынести в отдельный метод, а конечному пользователю отдавать обертку с дополнительными проверками:

export default class PromiseImplementation {
    private state: PromiseState = 'pending';
    private result: PromiseResult;

    constructor(executor?: PromiseExecutor) {/* ... */}
  
  	private consumersArgs: ConsumerCallback[][] = [];
  
   	private applyResolveMainLogic(value?: any) {
        if (this.state === 'pending') {
            this.state = 'fulfilled';
            this.result = value;
          
            if (this.consumersArgs.length) {
                for (let consumerArgs of this.consumersArgs) {
                    this.then(...consumerArgs);
                }
            }
        }
    }
  
  	private resolveCallsCount = 0;
  	private resolve(value?: any) {
        if (this.resolveCallsCount > 0 || this.rejectCallsCount > 0) {
            this.resolveCallsCount += 1;
            return;
        }

        this.resolveCallsCount += 1;

        if (value instanceof PromiseImplementation) {
            value.then(
                (result) => this.applyResolveMainLogic(result),
                (err) => this.applyRejectMainLogic(err)
            );

            return;
        }

        this.applyResolveMainLogic(value);
    }
}

Итоговый метод может быть вызван только 1 раз (строчка 24). Если в качестве аргумента методу передается обещание, только после его выполнения (строчка 32) и с его же результатом будет вызван applyMainLogic оригинального экземпляра.

Метод reject выглядит несколько проще, поскольку его вызов это всегда изменение state на 'rejected' и result на значение аргумента, независимо от его типа:

export default class PromiseImplementation {
    private state: PromiseState = 'pending';
    private result: PromiseResult;

    constructor(executor?: PromiseExecutor) {/* ... */}
  
  	private consumersArgs: ConsumerCallback[][] = [];
  
   	private applyResolveMainLogic(value?: any) {/* ... */}
  
  	private resolveCallsCount = 0;
  	private resolve(value?: any) {/* ... */}
  
  	private applyRejectMainLogic(error?: any) {
        if (this.state === 'pending') {
            this.state = 'rejected';
            this.result = error;

            if (this.consumersArgs.length) {
                for (let consumerArgs of this.consumersArgs) {
                    this.then(...consumerArgs);
                }
            }
        }
    }

    private rejectCallsCount = 0;
    private reject(error?: any) {
        if (this.rejectCallsCount > 0 || this.resolveCallsCount > 0) {
            this.rejectCallsCount += 1;
            return;
        }

        this.rejectCallsCount += 1;

        this.applyRejectMainLogic(error);
    }
}

Consumers

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

Так же, сразу можно выделить еще два ключевых момента:

  • Передаваемые в .then | .catch | .finally коллбеки всегда вызываются в асинхронном режиме. С практической точки зрения это означает, что вызов коллбеков и некоторая вспомогательная логика будут обернуты в setTimeout с нулевой задержкой.

  • Возможность построения цепочек вызовов означает, что каждый подписчик, практически всегда должен возвращать новый экземпляр обещания.

Сразу можно выделить проблему, вытекающую из последнего пункта. Пример (2):

/* экземпляр_0 */
new PromiseImplementation(resolve => {
    setTimeout(() => {
        resolve(1);
    }, 1000);
})
/* then_0, вызывается на экземпляр_0, возвращает экземпляр_1 */
.then(x => {
    return x * 2;
})
/* then_1, вызывается на экземпляр_1, возвращает экземпляр_2 */
.then((x) => {
    return x * 4;
})

Организация такой цепочки вызовов приводит к созданию трёх обещаний. Причем явно декларировано выполнение только одного, экземпляры 1 и 2 не модифицируют собственное состояние сами по себе.

Логику метода .then необходимо реализовать таким образом, чтобы:

  • .then_0, когда обещание 0 выполнено, вызвал пользовательский хендлер
    x => { return x * 2; } и выполнил обещание 1 с результатом этого хендлера.

  • then_1, когда обещание 1 выполнено, вызвал пользовательский хендлер
    x => { return x * 4; } и выполнил обещание 2 с результатом этого хендлера.

  • ...И так до конца любой цепочки.

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

Добавим в класс несколько новых полей:

export default class PromiseImplementation {
  	/* Вспомогательные структуры */
  	private currentConsumerIndex = 0;
    private consumersArgs: ConsumerCallback[][] = [];
    private consumerShouldReturnInstance = true;
    private consumersInstanceSettlers: {
        resolvers: ExecutorCallback[];
        rejecters: ExecutorCallback[];
    } = {
        resolvers: [],
        rejecters: [],
    };
}

ПолеconsumersInstanceSettlers будет содержать satisfayer-ы инстансов, которые вернули подписчики.

На одном экземпляре может быть вызвано несколько подписчиков. После выполнения обещания, они последовательно вызываются в цикле, в качестве аргументов передаются сохраненные коллбеки, но не индекс итерации. ПолеcurrentConsumerIndex необходимо именно по этой причине, чтобы ориентироваться в структуре consumersInstanceSettlers.

ФлагconsumerShouldReturnInstance необходим, чтобы не возвращать инстанс из подписчика, если он был вызван в resolve или reject, то есть программно, а не пользователем. В противном случае данные в consumersInstanceSettlersбудут перезаписаны.

export default class {
	private applyMainLogic(argument?: any) {
        if (this.state === 'pending') {
            this.state = /* fulfilled | rejected */;
            this.result = argument;

            if (this.consumersArgs.length) {
              	/* Не возвращать экземпляр, если .then вызывается программно */
                this.consumerShouldReturnInstance = false;

                for (let consumerArgs of this.consumersArgs) {
                    this.then(...consumerArgs);
                }
            }
        }
    }
}

Теперь можно реализовать публичный метод .then:

export default class PromiseImplementation {
  	then = (handleSuccess?: ConsumerCallback, handleError?: ConsumerCallback) => {
      /* Вызов без аргументов - возвращаем этот же экземляр */  
      if (!handleSuccess && !handleError) {
            return this;
        }

        const { state, result } = this;
        const isSettled = state !== 'pending' && 'result' in this;

        if (isSettled) {
          	/* 
            	Если обещание выполнено - ставим таймер 
            	с нулевой задержкой на выполнение пользовательских хендлеров
            */
            setTimeout(() => {
                if (handleSuccess === handleError) {
                    this.handleFinally(result, handleSuccess || handleError, state);

                    return;
                }

                if (state === 'fulfilled') {
                    this.handleResult(result, handleSuccess, state);
                }

                if (state === 'rejected') {
                    this.handleResult(result, handleError, state);
                }
            }, 0);
        }

        if (this.consumerShouldReturnInstance) {
            if (!isSettled) {
                /* 
                	Сохраняем пользовательские обработчики до востребования 
                */
                this.consumersArgs.push([handleSuccess, handleError]);
            }

          	/* 
                По умолчанию возвращаем новый инстанс, и сохраняем его
                satisfayer - ы в текущий. 
            */
            return new PromiseImplementation((resolve, reject) => {
                this.consumersInstanceSettlers.resolvers.push(resolve);
                this.consumersInstanceSettlers.rejecters.push(reject);
            });
        }
    };
}

Вызов пользовательских обработчиков и обработка их результатов будет происходить в handleResult и handleFinally. В них же будет происходить выполнение обещания, которое вернул подписчик. Логику получения resolve & reject этого обещания, имеет смысл вынести в отдельный метод:

export default class PromiseImplementation { 
  	/* Получить satisfier - ы инстанса, который вернул подписчик */
  	private getConsumerInstanceSettlers() {
        const index = this.currentConsumerIndex++;

        const resolveNext = this.consumersInstanceSettlers.resolvers[index];
        const rejectNext = this.consumersInstanceSettlers.rejecters[index];

        return {
            resolveNext,
            rejectNext,
        };
    }
}

Приватный метод handleResult:

export default class PromiseImplementation {
  	private handleResult(result: any, handler?: ConsumerCallback, state?: PromiseState) {
        /* Получение resolve & reject экземпляра, который вернул .then или .catch */
				const { resolveNext, rejectNext } = this.getConsumerInstanceSettlers();

      	/* В случае ошибки в handler, будет вызван rejectNext */
        try {
            const handlerResult = handler ? handler(result) : result;

          	/* Если handler вернул обещание, это обрабатывается особым образом */
            if (handlerResult instanceof PromiseImplementation) {
                const resolve = (result) => resolveNext(result);
                const reject = (err) => rejectNext(err);

                handlerResult.then(resolve, reject);

                return;
            }

          	/* 
            	Нет обработчика ошибки - идем по цепочка дальше, пока этот
                обработчик не встретим. 
            */
            if (state === 'rejected' && !handler) {
                rejectNext(result);

                return;
            }

          	/* Вызов resolveNext с результатом, который вернул handler */
            resolveNext(handlerResult);
        } catch (err) {
            rejectNext(err);
        }
    }
  
  	then = (handleSuccess?: ConsumerCallback, handleError?: ConsumerCallback) => {/*...*/};
}

В случае ошибки в пользовательском хендлере, возвращаемое подписчиком обещание выполняется с этой ошибкой. В случае, если обработчик отработал корректно, возвращаемое подписчиком обещание выполнится с результатом, который вернул этот обработчик. Разумеется, необходимо обрабатывать результат особым образом, если результат вызова сохраненного коллбека - экземпляр обещания. В этом случае, необходимо дождаться его выполнения, и только после этого вызвать resolve | reject экземпляра, который возвратил подписчик.

Такая логика справедлива для консъюмеров .then и .catch, но не для .finally. Поэтому необходим приватный метод handleFinally, работающий похожим образом. Его основное отличие в том, что пользовательский обработчик вызывается без аргументов. Результат его вызова так же не учитывается, только если это не обещание, поэтому метод почти не оказывает воздействия на цепочку.

Приватный метод handleFinally:

export default class PromiseImplementation {
	private handleFinally(result: any, handler?: ConsumerCallback, state?: PromiseState) {
        const { resolveNext, rejectNext } = this.getConsumerInstanceSettlers();

        try {
          	/* Пользовательский коллбек вызывается без аргументов. */
            const handlerResult = handler && handler();

            if (handlerResult instanceof PromiseImplementation) {
				/* 
                    Если результат коллбека - обещание,
                    код просто дождется его выполнения, но 
                    результат обещания будет проигнорирован.
                */
                const resolve = () => resolveNext(result);
                const reject = (err) => rejectNext(err);

                handlerResult.then(resolve, reject);

                return;
            }

            if (state === 'rejected') {
                rejectNext(result);

                return;
            }

            resolveNext(result);
        } catch (err) {
            rejectNext(err);
        }
    }
}

Полученный метод .then универсален. Публичные методы .catch и .finally реализуются как простейшие обертки, передающие разные наборы аргументов.

catch = (handleError?: ConsumerCallback) => {
    return this.then(undefined, handleError);
};
finally = (callback?: ConsumerCallback) => {
    return this.then(callback, callback);
};

Вместо заключения

  • Проверка instanceof в коде это упрощение, под капотом нативных промисов проверяется, что сущность представляет собой Thenable объект.

  • Это же можно сказать про setTimeout, у нативных промисов есть своя внутренняя очередь.

  • Последнее умышленное упрощение - использование .finally как обертки над .then, вызываемого с двумя одинаковыми коллбеками.

    Для примера ниже, текущая реализация отработает неверно, .then на строчке 7 будет обрабатываться, как .finally. По субъективному опыту, так никто не пишет, поэтому кейс не брался во внимание.

const thenCallback = (x) => {
    console.log(x);
};

new PromiseImplementation((resolve) => {
    resolve(1);
}).then(thenCallback, thenCallback);

Благодарю читателя, который добрался досюда. Понять все с первого раза может быть сложно. Построчный разбор всех процессов, происходящих даже при небольшой цепочке, раздул бы материал до неприличия, поэтому на всякий случай оставляю ссылку на исходники в начале статьи. Буду рад любой обратной связи по тому, как сделать текст понятнее, или если вы увидите недочёты в реализации.

Комментарии (3)


  1. SergeyPeskov
    02.09.2022 19:48
    +2

    1) Думаю вам стоит добавить типы(generic) для метода then, не очень удобно, что сейчас у ConsumerCallback параметр argument всегда имеет тип any.

    2) А почему вы не взяли queueMicrotask вместо setTimeout?


    1. yangirekun Автор
      03.09.2022 09:24

      Здравствуйте!

      1) Тоже думал об этом, но позже пришел к выводу, что типизация подписчиков для конечного пользователя через дженерики не имеет особого смысла. Конструкции на скриншоте полностью идентичны, впрочем, это самый простой кейс, поправьте меня, если я что - то упустил или мы говорим о разном.

      2) В целях сделать текст чуть проще, setTimeout это основа основ, которая интуитивно понятна любому разработчику, в то время как сравнительно недавно появившийся queueMicrotask, на практике, используется совсем редко.


      1. SergeyPeskov
        04.09.2022 19:06

        Добрый день!

        Я немного про другое.
        - Можно было бы добавить тип для класса(PromiseImplementation<T>) и пробросить его в resolve, чтобы было не (argument?: any) => void , а (argument?: T) => void и тогда этот тип можно использовать для then (argument?: T) => any;

        - Когда у нас есть цепочка промисов, можно добавить получение типа для then на основе возвращаемого значения.