Promise implementation
Promise is a general concept to handle the asynchronize development in javascript. It's not hard to use, but when it comes with some other concepts, like react's hook, its internal chain etc. It always takes me some time to think through it. Especially when check the code of some open-source libraries, find the way they use Promise is quite fancy and also hard to understand, which reminders me that I am still stay on the level of using, far away from deep understanding.
Inspired by this blog, I think it's a good idea to write promise by myself. It's not only because it's not that hard and complex, it can also help me in the future work when I meet it again. Thesedays, function programing is quite popular in js development, but object-orient coding is still used in lots of scenarioes. So I would like to try to implement it with both function and class, even though javascript's prototype is almost equal to class concept, which was introduced in ES2015. It can also train these basic skills again. I would deploy them in my github and write blogs to record it.
Thanks to promise/A+, we get the requirement analysis, clear logic and library to test the solution.
Steps
According to the Promise/A+'s requirements, I think it would be four steps:
- Basic
then
function -- 1.1, 1.2 - Fulfill
Promise
andthen
parameters -- from 2.1 to 2.2.5 - Return a
Promise
inthen
-- 2.2.6, 2.2.7 resolve
function -- 2.3
Basic thenable
According to the Terminology, the first two are promise
and thenable
. The former should take a function (an executor
) as parameter which would take two functions (resolve
and reject
) as parameters as well. Check Higher-order functions. These resolve
and reject
would be used in the executor
and defined in the then
's parameters. Let's simply implement this thenable
first.
1function Promise(executor) {
2 let self = this;
3 self.executor = executor;
4}
5
6Promise.prototype.then = function (onFulfilled, onRejected) {
7 let self = this;
8 self.executor(onFulfilled, onRejected);
9};
As we see, we just delay the execution of executor
from Promise
to then
and the fulfill and reject functions are used in executor
while defined or passed in then
.
We can use below code to test it. NB: do not use arrow function in definition, because arrow function does not own this
. If we use this
inside of it, it would refer to the outer function. Check here.
1const a = new Promise((resolve) => setTimeout(() => resolve("result"), 100));
2
3a.then((data) => console.log("Data", data));
4// console.log -> Data: result
But obviously here, we only have one executor
and the parameters are not validated. Besides executor
runs in the then
, not in the Promise
. Currently, the code is simple, but it would cause problems. We will mention this below, let's go further.
Fulfill Promise
and then
parameters
Validate then
parameters
2.2.1 Both onFulfilled and onRejected are optional arguments:
2.2.1.1 If onFulfilled is not a function, it must be ignored.
2.2.1.2 If onRejected is not a function, it must be ignored.
onFulfilled
and onRejected
should be validated:
1let fulfillFunc = isFunction(onFulfilled) ? onFulfilled : (value) => value;
2let rejectFunc = isFunction(onRejected)
3 ? onRejected
4 : (e) => {
5 throw e;
6 };
Update state
After validating the parameters, we can implement the point 2.2.2
and 2.2.3
. Translate here:
If onFulfilled/onRejected is a function,
it must be called after promise is fulfilled/rejected, with promise's value/reason as its first argument;
it must not be called before promise is fulfilled/rejected;
it must not be called more than once;
So we need to set the state and pass the value or reason to related functions. To do this, I wrap the validated functions and update internal states inside of them:
1// 2.2.2.1 onFulfilled must be called after promise is fulfilled, with promise’s value as its first argument.
2function resolve(value) {
3 if (self.state === STATE.PENDING) {
4 // 2.2.2.2 it must not be called before promise is fulfilled.
5 self.state = STATE.FULFILLED;
6 self.value = value;
7 fulfillFunc(value);
8 }
9}
10
11// 2.2.3.1 onRejected must be called after promise is rejected, with promise’s reason as its first argument.
12function reject(err) {
13 if (self.state === STATE.PENDING) {
14 // 2.2.3.2 it must not be called before promise is rejected.
15 self.state = STATE.REJECTED;
16 self.value = err;
17 rejectFunc(err);
18 }
19}
Asynchronized execution
According to the first point 2.2.4
refering NOTE 3.1
, we should execute the fulfill and reject functions asynchronously, which is the main reason for people using it. In javascript, we can utilize setTimeout
.
1setTimeout(() => fulfillFunc(value), 0);
Return Promise
in then
Before we continue implementing the rest requirement, we need to reorganize the codes first. We need to move the executor
from then
to the constructor of Promise
.
Why? One main reason is we will execute the executor
multiple times if it's in then
, since one promise can have multiple then
s. This is definitely unacceptable because we only need to execute executor
once.
So til now, the Promise
function looks like this:
1function Promise(executor) {
2 let self = this;
3
4 // set the state as pending, 2.1.1
5 self.state = STATE.PENDING;
6
7 // 2.2.2.1 onFulfilled must be called after promise is fulfilled, with promise’s value as its first argument.
8 function resolve(value) {
9 if (self.state === STATE.PENDING) {
10 // 2.2.2.2 it must not be called before promise is fulfilled.
11 self.state = STATE.FULFILLED;
12 self.value = value;
13 setTimeout(() => resolveFunc(value), 0);
14 }
15 }
16
17 // 2.2.3.1 onRejected must be called after promise is rejected, with promise’s reason as its first argument.
18 function reject(err) {
19 if (self.state === STATE.PENDING) {
20 // 2.2.3.2 it must not be called before promise is rejected.
21 self.state = STATE.REJECTED;
22 self.value = err;
23 setTimeout(() => rejectFunc(err), 0);
24 }
25 }
26
27 try {
28 // executor is function whose parameters is resolve and reject functions,
29 // which would be called inside of executor.
30 if (isFunction(executor)) executor(resolve, reject);
31 } catch (err) {
32 reject(err);
33 }
34}
And you may notice we use the function resolveFunc
and rejectFunc
, but we haven't define them. They would work together with the requirement of multiple calling of then
.
Call then
mutiple times 2.2.6
2.2.6
then
may be called multiple times on the same promise.
So all the respective fulfill/reject functions should be called in the order of original calls. Obviously, we can apply a queue here. Create a callback queue, add then
's onFulfilled
and onRejected
parameters to it and handle it when fulfilled/rejected. And this is how we handle the above resolveFunc
and rejectFunc
.
1function resolve(value) {
2 ...
3 // 2.2.6.1
4 setTimeout(
5 () => self.callback.forEach(({ resolveFunc }) => resolveFunc(value)),
6 0
7 );
8}
9
10function reject(err) {
11 ...
12 // 2.2.6.2
13 setTimeout(
14 () => self.callback.forEach(({ rejectFunc }) => rejectFunc(err)),
15 0
16 );
17}
When Promise
is fulfilled/rejected, we would execute all the related functions in the queue. And obviously, we have to push the callback functions to the queue in then
when the state is still pending.
1Promise.prototype.then = function (onFulfilled, onRejected) {
2 let self = this;
3
4 // 2.2.1 Both onFulfilled and onRejected are optional arguments, if any is not function, must ignore it
5 let fulfillFunc = isFunction(onFulfilled) ? onFulfilled : (value) => value;
6 let rejectFunc = isFunction(onRejected)
7 ? onRejected
8 : (e) => {
9 throw e;
10 };
11
12 switch (self.state) {
13 // if the state is fulfilled or rejected, just execute the related function and pass the result to the resolvePromise
14 case STATE.FULFILLED:
15 case STATE.REJECTED:
16 return setTimeout(() => {
17 try {
18 let func = self.state == STATE.FULFILLED ? fulfillFunc : rejectFunc;
19 func(self.value);
20 } catch (e) {
21 rejectFunc(e);
22 }
23 }, 0);
24 case STATE.PENDING:
25 // if it's still pending, push the resolve/reject to callback queue. All the callback functions would be executed once state are changed
26 return self.callback.push({
27 resolveFunc: () => {
28 try {
29 fulfillFunc(self.value);
30 } catch (e) {
31 rejectFunc(e);
32 }
33 },
34 rejectFunc: () => {
35 try {
36 rejectFunc(self.value);
37 } catch (e) {
38 rejectFunc(e);
39 }
40 },
41 });
42 }
43};
Return Promise
2.2.7
Here comes the difficult part, then
should return a Promise
which would support the chaining feature.
then must return a promise [3.3].
promise2 = promise1.then(onFulfilled, onRejected);
After reading other implementations, here comes a question. Would this promise2
have its own executor
? Yes or no would have different implementations.
Let's first implement the simple one - Yes. It would have its own resolve/reject
functions in executor
. I would implement the optimized one -- No, with an empty promise, in another blog.
Another thing we need to think of is what if the value returned by promise2
is a promise. This is what the 2.3 Promise Resolution Procedure
would do. Let's preserve this to later chapter and assume the value returned by promise2
is NOT another promise
Then the logic of this promise2
's executor
should be:
- If the state is fulfilled, the value returned by the
onFulfilled
function should be passed toresolve2
function inpromise2
's executor - If the state is rejected, the value returned by the
onRejected
function should be passed toreject2
function inpromise2
's executor - If the state is still pending, pass the
resolveFunc
andrejectFunc
which would callresolve2
andreject2
, to callback queue - Any exception throwed by
onFulfilled
oronRejected
should be handled byreject2
And we can extract the process of handling self.value
as a function to reuse code.
1function handleResult(resolve2, reject2) {
2 return () => {
3 try {
4 // 2.2.7.1, 2.2.7.2
5 let func = self.state == STATE.FULFILLED ? fulfillFunc : rejectFunc;
6 let func2 = self.state == STATE.FULFILLED ? resolve2 : reject2;
7 func2(func(self.value));
8 } catch (e) {
9 reject2(e);
10 }
11 };
12}
Til now, we have implement the features
- Return Promise in
then
to support chaining - Multiple
then
of one Promise
The codes should be like this:
1const STATE = {
2 PENDING: Symbol.for("pending"),
3 FULFILLED: Symbol.for("fulfilled"),
4 REJECTED: Symbol.for("rejected"),
5};
6
7const isFunction = (func) => func && typeof func === "function";
8const isObject = (arg) => arg && typeof arg === "object";
9
10function Promise(executor) {
11 let self = this;
12
13 // set the state as pending, 2.1.1
14 self.state = STATE.PENDING;
15
16 self.callback = [];
17
18 // 2.2.2.1 onFulfilled must be called after promise is fulfilled, with promise’s value as its first argument.
19 function resolve(value) {
20 if (self.state === STATE.PENDING) {
21 // 2.2.2.2 it must not be called before promise is fulfilled.
22 self.state = STATE.FULFILLED;
23 self.value = value;
24 // 2.2.6.1
25 setTimeout(
26 () => self.callback.forEach(({ resolveFunc }) => resolveFunc(value)),
27 0
28 );
29 }
30 }
31
32 // 2.2.3.1 onRejected must be called after promise is rejected, with promise’s reason as its first argument.
33 function reject(err) {
34 if (self.state === STATE.PENDING) {
35 // 2.2.3.2 it must not be called before promise is rejected.
36 self.state = STATE.REJECTED;
37 self.value = err;
38 // 2.2.6.2
39 setTimeout(
40 () => self.callback.forEach(({ rejectFunc }) => rejectFunc(err)),
41 0
42 );
43 }
44 }
45
46 try {
47 // executor is function whose parameters is resolve and reject functions,
48 // which would be called inside of executor.
49 if (isFunction(executor)) executor(resolve, reject);
50 } catch (err) {
51 reject(err);
52 }
53}
54
55Promise.prototype.then = function (onFulfilled, onRejected) {
56 let self = this;
57
58 // 2.2.1 Both onFulfilled and onRejected are optional arguments, if any is not function, must ignore it
59 let fulfillFunc = isFunction(onFulfilled) ? onFulfilled : (value) => value;
60 let rejectFunc = isFunction(onRejected)
61 ? onRejected
62 : (e) => {
63 throw e;
64 };
65
66 function handleResult(resolve2, reject2) {
67 return () => {
68 try {
69 // 2.2.7.1, 2.2.7.2
70 let func = self.state == STATE.FULFILLED ? fulfillFunc : rejectFunc;
71 let func2 = self.state == STATE.FULFILLED ? resolve2 : reject2;
72 func2(func(self.value));
73 } catch (e) {
74 reject2(e);
75 }
76 };
77 }
78
79 return new Promise((resolve2, reject2) => {
80 switch (self.state) {
81 // if the state is fulfilled or rejected, just execute the related function and pass the result to the resolvePromise
82 case STATE.FULFILLED:
83 return setTimeout(handleResult(resolve2, reject2), 0);
84 case STATE.REJECTED:
85 return setTimeout(handleResult(resolve2, reject2), 0);
86 case STATE.PENDING:
87 // if it's still pending, push the resolve/reject to callback queue. All the callback functions would be executed once state are changed
88 return self.callback.push({
89 resolveFunc: handleResult(resolve2, reject2),
90 rejectFunc: handleResult(resolve2, reject2),
91 });
92 }
93 });
94};
Let's simply test it:
1const p1 = new Promise((resolve, reject) => {
2 setTimeout(() => resolve("resolved first one"), 3000);
3});
4
5p1.then((res) => {
6 console.log("then1: ", res);
7 return res;
8}).then((res) => {
9 setTimeout(() => console.log("then2: ", res), 1000);
10});
11
12p1.then((res) => {
13 console.log("another then: ", res);
14});
15
16// then1: resolved first one
17// another then: resolved first one
18// then2: resolved first one
Promise Resolution Procedure 2.3
Here comes the last step, implement the Promise Resolution Procedure
. According to the description of requirement:
This treatment of thenables allows promise implementations to interoperate, as long as they expose a Promises/A+-compliant then method. It also allows Promises/A+ implementations to “assimilate” nonconformant implementations with reasonable then methods.
And before we begin to implement, let's think of why we have to have this resolvePromise
, since it calls itself inside recursively. Comparing the text explanation, let me present one test case from Promise/A+. The case is generated by two loops, I just pick one here.
Adapter
In the test case, an adapter is utilized and explained in the Promise/A+ as well, check adapter;
1Promise.defer = Promise.deferred = function () {
2 let dfd = {};
3 dfd.promise = new Promise((resolve, reject) => {
4 dfd.resolve = resolve;
5 dfd.reject = reject;
6 });
7 return dfd;
8};
9
10adapter.resolved = function (value) {
11 var d = adapter.deferred();
12 d.resolve(value);
13 return d.promise;
14};
Definitely, we have seen this resolved
many times, but what it does exactly? Through its code, we can understand it
- Create a promise with a very simple
executor
which normally executes the logic of asynchronous codes - Resolve the promise with the value immediately by updating state and saving the value before we have this
resolve
method - Return this created promise
So for the code resolved(value)
, actually it just preserves the value and waiting the resolve
method from its then
. When its then
is called, the value would be passed to resolve
method immediately.
Test case
The test case's description is
y
is an already-fulfilled promise for a synchronously-fulfilled custom thenable,then
callsresolvePromise
synchronously
And I can simplify the test code, removing all the wrapped test functions:
1const result = { result };
2
3var promise = resolved({ dummy }).then(function onBasePromiseFulfilled() {
4 return {
5 then: function (resolvePromise) {
6 resolvePromise(
7 resolved({
8 then: function (onFulfilled) {
9 onFulfilled(result);
10 },
11 })
12 );
13 },
14 };
15});
16
17promise.then(function onPromiseFulfilled(value) {
18 assert.strictEqual(value, result);
19 done();
20});
Yes, HHHHHHHHeadache!!!!
Definitely there are a lot of promises wrapped like matryoshka doll, and so hard to dig into it. Yes, I know, but we can analysis the codes step by step, at leas we can simply count how many promises are here:
resolved({ dummy })
usesresolved
. As explained above, it returns a resolved promise and waits for theresolve
method from itsthen
. Let's call this promise aspromise-TEMP
;promise-TEMP
calledthen
which passes the functiononBasePromiseFulfilled
asresolve
. AND it returns our first promise --promise
onBasePromiseFulfilled
return athenable
object as value ofpromise
. Let's call itx
;x
, which we can simply treat it as aPromise
as well --promise2
, has thethen
function which would call its parameterresolvePromise
further. The called value would be the value ofpromise2
. Let's call ity
;- The value passed to
resolvePromise
is another resolvedpromise
--promise3
, which is fulfilled and has noresolve
method. But its value is another thenable object -- orpromise4
; - Finally we reach the bottom level, the resolve method
onFulfilled
ofpromise4
'sthen
calls theresult
;
So let's count how many promises and values we have here (ignore the promise-TEMP
):
promise
-- the only one having name in our test codes- its value
x
->promise2
- its value
promise2
-- first thenable object- its value
y
->promise3
- its value
promise3
-- a resolvedpromise
- its value ->
promise4
- its value ->
promise4
-- second thenable object- its value ->
result
, an object
- its value ->
So we can see the value of promise
is a promise -- promise2
which wraps another two promises -- promise3
and promise4
. And the assert
sits inside of the resolve onPromiseFulfilled
method of the first promise
, it would expect the value returned by onPromiseFulfilled
to be the same with result
, which is the value of promise4
.
We can conclude some points here:
- The thenable object can be treated as a promise, which means they can be handled as the same codes. (This is not a principle, we can discuss this in another blog)
- If the value of a promise is a thenable object, the promise's resolve/reject methods would be passed to the value until the final value is not a promise and handled by the original resolve/reject methods.
Implementation
OK, it's enough to learn from the test case, even though it's what I learned after I passed all the test cases. Let's go back to the requirements and implement it.
Since thenable object can be treated as promise, we would ignore the requirement of 2.3.2
:
2.3.2 If x is a promise, adopt its state [3.4]:
2.3.2.1 If x is pending, promise must remain pending until x is fulfilled or rejected.
2.3.2.2 If/when x is fulfilled, fulfill promise with the same value.
2.3.2.3 If/when x is rejected, reject promise with the same reason.
This point can be merged with 2.3.3
. Others would not be hard, just follow the steps:
1function resolvePromise(promise, x, resolve2, reject2) {
2 if (promise == x) {
3 return reject2(
4 new TypeError("Resolved result should not be the same promise!")
5 );
6 } else if (x && (isFunction(x) || isObject(x))) {
7 let called = false; // 2.3.3.3.3
8
9 try {
10 let then = x.then;
11
12 if (isFunction(then)) {
13 then.call(
14 // 2.3.3.3
15 x,
16 function (y) {
17 if (called) return; // 2.3.3.3.3
18 called = true;
19 return resolvePromise(promise, y, resolve2, reject2); // 2.3.3.3.1
20 },
21 function (r) {
22 if (called) return;
23 called = true;
24 return reject2(r); // 2.3.3.3.2
25 }
26 );
27 } else {
28 resolve2(x); // 2.3.3.4
29 }
30 } catch (err) {
31 if (called) return; // 2.3.3.3.4.1
32 called = true;
33 reject2(err); // 2.3.3.3.4.2
34 }
35 } else {
36 resolve2(x); // 2.3.4
37 }
38}
Test
Now we have implemented all the mandatory codes, we can run the test the codes with npm lib promises-aplus-tests.
1npm i -g promises-aplus-tests
2promises-aplus-tests promise1.js // promise1.js is the file name
The full code can be checked here. There are different versions with different techniques, you can choose anyone.
Summary
This solution is not perfect and it just simply follows the rules of Promise/A+ without any better architecture and design. There are a lot of good solutions which restructures the codes with their own idea and logic. I would rewrite the promise with other techniques later.
Besides, this version just completes the basic part. Promise also has resolve
, catch
, finally
, etc. I will implement them as well and write another article about them.
Thanks to this article, which inspires me to make decision to implement the Promise and Zhi Sun's article, which reminders me of taking steps to implement the hard part.
---------------------- UPDATE ----------------------
Instead prototype, I have implemented the Promise with javascript class, which is not the feature of ES5, therefore, we need to compile it with babel and test with nodejs. And the code logic is almost the same with the version 1. Here is the code.
There are two things we need to take care when using class:
- When we pass the
#internalResolve/#internalReject
functions which are defined as class private methods, toexecutor
, we need to usebind
to bind the function with class's instance orthis
, since we usethis
inside of#internalResolve/#internalReject
. Codes are:
1constructor(executor) {
2 try {
3 executor(
4 this.#internalResolve.bind(this),
5 this.#internalReject.bind(this)
6 );
7 } catch (err) {
8 this.#internalReject(err);
9 }
10}
11
12#internalResolve(value) {
13 if (this.#state == STATE.PENDING) {
14 // ...
15 }
16}
17
18#internalReject(reason) {
19 if (this.#state == STATE.PENDING) {
20 //...
21 }
22}
- Be careful for
this
especially when we create functions inside of class methods. It's best to define a variable and assignthis
to it, likeself
.