Велосипедим Promise на TypeScript
Идея написать собственную реализацию 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);
Благодарю читателя, который добрался досюда. Понять все с первого раза может быть сложно. Построчный разбор всех процессов, происходящих даже при небольшой цепочке, раздул бы материал до неприличия, поэтому на всякий случай оставляю ссылку на исходники в начале статьи. Буду рад любой обратной связи по тому, как сделать текст понятнее, или если вы увидите недочёты в реализации.