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
thenfunction -- 1.1, 1.2 - Fulfill
Promiseandthenparameters -- from 2.1 to 2.2.5 - Return a
Promiseinthen-- 2.2.6, 2.2.7 resolvefunction -- 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 thens. 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
thenmay 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
onFulfilledfunction should be passed toresolve2function inpromise2's executor - If the state is rejected, the value returned by the
onRejectedfunction should be passed toreject2function inpromise2's executor - If the state is still pending, pass the
resolveFuncandrejectFuncwhich would callresolve2andreject2, to callback queue - Any exception throwed by
onFulfilledoronRejectedshould 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
thento support chaining - Multiple
thenof 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
executorwhich 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
resolvemethod - 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
yis an already-fulfilled promise for a synchronously-fulfilled custom thenable,thencallsresolvePromisesynchronously
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 theresolvemethod from itsthen. Let's call this promise aspromise-TEMP;promise-TEMPcalledthenwhich passes the functiononBasePromiseFulfilledasresolve. AND it returns our first promise --promiseonBasePromiseFulfilledreturn athenableobject as value ofpromise. Let's call itx;x, which we can simply treat it as aPromiseas well --promise2, has thethenfunction which would call its parameterresolvePromisefurther. The called value would be the value ofpromise2. Let's call ity;- The value passed to
resolvePromiseis another resolvedpromise--promise3, which is fulfilled and has noresolvemethod. But its value is another thenable object -- orpromise4; - Finally we reach the bottom level, the resolve method
onFulfilledofpromise4'sthencalls 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/#internalRejectfunctions which are defined as class private methods, toexecutor, we need to usebindto bind the function with class's instance orthis, since we usethisinside 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
thisespecially when we create functions inside of class methods. It's best to define a variable and assignthisto it, likeself.