Abstract:
This post is about my understanding of JavaScript Promise and shows example of my own Promise class in order to help understand Promise in details.
Damn, Callback Hell
JavaScript is well known as a single-thread scripting language. However, there are some tasks that need to wait for responses to come back for a period. We do not want to block other code execution during the waiting period.
How to avoid this kind of situation? JavaScript supports the asynchronous mechanism to let async tasks, for example, ajax calls, execute when their responses come back without blocking synchronous code logic.
To handle a response in an async task, a common way is to use a callback.
A callback is a function passed as an argument to another function
https://www.w3schools.com/js/js_callback.asp
The callback function is executed when a response comes back. Then depending on the status of the response, the callback function runs different logic.
Now, consider a situation in which
you have a sequence of tasks, and each task needs to wait for the previous task to complete. For example, I have tasks
task_1 -> task_2 -> task_3 -> ........ -> task_n
In this situation, if you choose callbacks to handle the above tasks’ chain, what will your code look like? It’s probably going to be like this.
The above code piece is the so-called “Callback Hell”. With almost infinite nested, nested, ……, and nested callbacks inside callbacks, it makes code readability become almost none.
OK Promise, no more Callback Hell
A promise is a JavaScript object that is built with functionalities to handle a sequence of async tasks with the chaining mechanism, instead of leading to callback hells.
new Promise(function (resolve, reject) {
setTimeout(() => resolve(1), 1000); // (*)
}).then(function (result) { // (**)
alert(result); // 1
return result * 2;
}).then(function (result) { // (***)
alert(result); // 2
return result * 2;
}).then(function (result) {
alert(result); // 4
return result * 2;
});
The chaining mechanism really saves us from reading those infinite callback hells. It provides a way for better readability and is also easier to understand.
To understand more details about Promise and its concepts, you can read through Promises/A+ Documentation and Promise doc on MDN. I am not going to discuss the concept details here.
My Promise Polyfill
Promise is a built-in object in all of today’s JavaScript engines (assuming IE is not taken into consideration). I can easily use Promise in vanilla JS by creating a Promise object, for example Promise.resolve(myValue)
. Wait, wait, so what happens under the scene when I use code like this?
new Promise((resolve, reject) => {
// resolve and reject logic
}).then((result) => {
// result handling
});
What is the inside implementation of Promise object?
Here I would like to show how Promise works by implementing a simple Promise myself.
First, by looking at the way an Promise
object is initiated
const myPromise = new Promise((resolve, reject) => {/*custom logic*/})
It shows constructor of Promise class should take in a callback function, and at either fulfilled
or rejected
state, the Promise
should run resolve
or reject
logic by calling either of the function.
The following shows the skeleton of how my Promise
polyfill looks like
const state = {
PENDING: 'pending',
FULFILLED: 'fulfilled',
REJECTED: 'rejected'
};
class MyPromise {
constructor(executor) {
// initial state
this._state = state.PENDING;
this._value = undefined;
this._error = undefined;
// a list to save promise chains before promise is settled
this._thenQueue = [];
const resolve = this._onFulfilled.bind(this);
const reject = this._onRejected.bind(this);
executor(resolve, reject);
}
/** start: pseudo internal methods */
_onFulfilled(value) {}
_onRejected(error) {}
/** end: pseudo internal methods */
/** start: exposed methods */
then(fulfillFn, rejectFn) {}
catch(rejectFn) {}
/** end: exposed methods */
}
Here are some points that need to pay attention to in the above piece of code.
Promise State
According to Promise/A+ standard, a Promise
object must be in one of pending
, fulfilled
, or rejected
states. And, a pending
promise can either be fulfilled
with a value or rejected
with an error. Therefore, in the above code
_state
: saves the current state of promise. value =pending | fulfilled | rejected
_value
: iffulfilled
, save fulfilled value to pass down to promise chain_error
: ifrejected
, save error reason
Save Promise Chain — _thenQueue
Promise is chainable, so _thenQueue
is used to cache every promise object in the chain.
Methods to handle fulfilled
or rejected
state
The executor
function that passed into MyPromise.constructor
contains custom logic about under what condition should the promise be fulfilled
or rejected
. executor
function takes in 2 args resolve
and reject
which should be internal handler function from promise object.
Now, I have the skeleton of my Promise polyfill, next I am going to show the whole code about how my promise work when it comes to fulfilled
or rejected
state, also including chaining
Thanks a lot to YouTube video “Promises From Scratch in A Post-Apocalyptic Feature”. It helped me a lot to understand implementation of Promise. Good stuff to check out: https://youtu.be/4GpwM8FmVgQ
https://youtu.be/4GpwM8FmVgQ
const state = {
PENDING: 'pending',
FULFILLED: 'fulfilled',
REJECTED: 'rejected'
};
const isThenable = maybePromise => maybePromise && (typeof maybePromise === 'function');
class MyPromise {
constructor(executor) {
this._state = state.PENDING;
this._value = undefined;
this._errReason = undefined;
this._thenQueue = [];
if (typeof executor === 'function') {
setTimeout(() => {
try {
executor(
this._onFulfilled.bind(this),
this._onRejected.bind(this)
);
} catch (error) {
this._onRejected(error);
}
});
}
}
/** start external methods */
then(onFulfilled, onRejected) {
const controlledPromise = new MyPromise(onFulfilled, onRejected);
this._thenQueue.push([controlledPromise, onFulfilled, onRejected]);
if (this._state === state.FULFILLED) {
this._propagateFulfilled();
} else if (this._state === state.REJECTED) {
this._propagateRejected();
}
return controlledPromise;
}
catch(onRejected) {
return this.then(undefined, onRejected);
}
static resolve(value) {
return new MyPromise((resolve) => resolve(value));
}
static reject(value) {
return new MyPromise((_, reject) => reject(value));
}
/** end external methods */
/** start pseudo internal methods */
_onFulfilled(value) {
if (this._state === state.PENDING) {
this._state = state.FULFILLED;
this._value = value;
this._propagateFulfilled();
}
}
_onRejected(error) {
if (this._state === state.REJECTED) {
this._state = state.REJECTED;
this._errReason = error;
this._propagateRejected();
}
}
_propagateFulfilled() {
this._thenQueue.forEach(([controlledPromise, onFulfilled]) => {
if (typeof onFulfilled === 'function') {
const valueOrPromise = onFulfilled(this._value);
if (isThenable(valueOrPromise)) {
valueOrPromise.then(
value => controlledPromise._onFulfilled(value),
errorReason => controlledPromise._onRejected(errorReason)
);
} else {
controlledPromise._onFulfilled(valueOrPromise);
}
} else {
return controlledPromise._onFulfilled(this._value);
}
});
this._thenQueue = [];
}
_propagateRejected() {
this._thenQueue.forEach(([controlledPromise, _, onRejected]) => {
if (typeof onRejected === 'function') {
const valueOrPromise = onRejected(this._errReason);
if (isThenable(valueOrPromise)) {
valueOrPromise.then(
value => controlledPromise._onFulfilled(value),
errorReason => controlledPromise._onRejected(errorReason)
);
} else {
controlledPromise._onFulfilled(valueOrPromise);
}
} else {
return controlledPromise._onRejected(this._errReason);
}
});
}
/** end pseudo internal methods */
}
Wow, right now the logic is a lot more complicated than the simple promise skeleton. Here is something I feel worth paying attention to:
setTimeout
in constructor
I wrap the executor
function calling in a setTimeout
in order to execute it in async way. Callback of setTimeout
got added to a message queue. When JavaScript event loop
does not detect any function execution in the call stack, the callback will be polled from message queue, and got executed.
Using JS microtasks
is another way to achieve the same behavior. You can checkout queueMicrotask() on MDN.
isThenable
check
I also learned this from Promises/A+ standard.
“thenable” is an object or function that defines a
then
method.
Considering a promise object contains a chain of promises, and once I am in the middle of a promise chain. My input is the return value from the previous promise. If this input is thenable
, then means I need to try resolving the input promise to get output to pass down to the next promise in the chain.
Thus, I am checking if (isThenable(valueOrPromise))
to determine if I need to use a promise to wrap valueOrPromise
to get my result to pass down to next item in the chain.
Idea of _propagateFulfilled
and _propagateRejected
I learned this from youtube video “Promises From Scratch in A Post-Apocalyptic Feature”, it’s very inspiring to me. Basic idea of these 2 functions are executing promise chain items. While I am at one promise item in the middle of the chain, input value from last promise item has 3 possibilities: thenable
, value
, or error
. value
/ error
are straightforward to handle, use _onFulfilled
/ _onRejected
. If input is thenable
, I am wrapping controlledPromise
with thenable
inorder to pass down current promise results.
Conclusion
Overall, to me polyfill my own promise is still challenging, I got lost a lot while trying to understand the mechanism of promise chains. I have put all the learning resources in the Reference section, and all of those helped me a lot to learn the underneath world of JavaScript Promise
. Hope this could be helpful to you as well.
Reference
JavaScript Callbacks — W3School
Callback Hell and How to Rescue it?
JavaScript Promises — Understand JavaScript Promises by Building a Simple Promise Example
Leave A Comment