Use JavaScript to implement a simple Promise step by step, supporting asynchronous and then chained calls.


Translated and organized from
Medium: Implementing a simple Promise in Javascript – by Zhi Sun

preface

Promise is often encountered in front-end interviews and daily development. And in many interviews these days, promises are often written by hand.

Next, you’ll use JavaScript to implement a simple Promise step by step that supports asynchronous and then chained calls.

Analysis of the Promise

The Promise object is used to represent the final completion (or failure) of an asynchronous operation and its resulting value, and is often used to implement asynchronous operations.

State of Promise

Promise has three states:

  • pending

    The initial state

  • fulfilled

    Status after successful execution

  • rejected

    The state after the execution failed

Promise state can only be changed from pending to fulfilled or from pending to rejected. The changing process of the state of a Promise is called settling. Once the state is changed, it will not be changed again.

Arguments to the Promise constructor

The Promise constructor takes a function argument executor, which takes two arguments:

  • resolve
  • reject

Fulfilling will change the Promise state from pending to fulfilled and trigger the successful callback function onFulfilled in the then method.

Performing Reject changes the Promise status from pending to Rejected and triggers the failure callback function onRejected in the Then method.

Callback function parameters in the Then method

The then method takes two arguments:

  • onFulfilled

    The call back to the function succeeds, receiving a single argument, the value passed in the resolve function

  • onRejected

    Failed callback function, receiving a single argument, reject, the value passed in the function

If the Promise state becomes fulfilled, the successful callback function onFulfilled is performed; If the Promise status becomes Rejected, the failure callback function onRejected is executed.

Realize the Promise

Basic Promise

First, this constructor receives a function executor, which in turn takes two arguments, the resolve and reject functions.

Therefore, you need to create the resolve and reject functions in your constructor and pass them into the executor function.

class MyPromise { constructor(executor) { const resolve = (value) => {}; const reject = (value) => {}; try { executor(resolve, reject); } catch (err) { reject(err); }}}

Second, Promise executes the corresponding callback function, based on the state. The initial state is pending, and when resolve, the state changes from pending to fulfilled. When Reject, the state changes from Pending to Rejected.

class MyPromise { constructor(executor) { this.state = 'pending'; const resolve = (value) => { if (this.state === 'pending') { this.state = 'fulfilled'; }}; const reject = (value) => { if (this.state === 'pending') { this.state = 'rejected'; }}; try { executor(resolve, reject); } catch (err) { reject(err); }}}

When the Promise status changes, the corresponding callback function in the Then method is fired. If the state is changed from Pending to Fulfilling, then the success callback will be triggered. If the state is changed from Pending to Rejected, then the failure callback will be triggered.

class MyPromise { constructor(executor) { this.state = 'pending'; this.value = null; const resolve = (value) => { if (this.state === 'pending') { this.state = 'fulfilled'; this.value = value; }}; const reject = (value) => { if (this.state === 'pending') { this.state = 'rejected'; this.value = value; }}; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { if (this.state === 'fulfilled') { onFulfilled(this.value); } if (this.state === 'rejected') { onRejected(this.value); }}}

Then you can write some test code to test the functionality

const p1 = new MyPromise((resolve, reject) => resolve('resolved'));
p1.then(
  (res) => console.log(res), // resolved
  (err) => console.log(err)
);

const p2 = new MyPromise((resolve, reject) => reject('rejected'));
p2.then(
  (res) => console.log(res),
  (err) => console.log(err) // rejected
);

However, if you test it with the following code, you’ll find nothing.

const p1 = new MyPromise((resolve, reject) => {
  setTimeout(() => resolve('resolved'), 1000);
});

p1.then(
  (res) => console.log(res),
  (err) => console.log(err)
);

const p2 = new MyPromise((resolve, reject) => {
  setTimeout(() => reject('rejected'), 1000);
});

p2.then(
  (res) => console.log(res),
  (err) => console.log(err)
);

This is because the Promise is still pending when the Then method is called. Neither onFulfilled nor OnRejected callback functions are executed.

Therefore, the next step is to support asynchrony.

Support for asynchronous Promises

To support asynchrony, you need to save the onFulfilled and onRejected callbacks and execute the corresponding callbacks as soon as the Promise status changes.

⚠ : There is a detail to be noted here, that isonFulfilledCallbacksandonRejectedCallbacksIs an array, because a Promise may be called multiple times, so there will be multiple callbacks.

class MyPromise { constructor(executor) { this.state = 'pending'; this.value = null; this.onFulfilledCallbacks = []; this.onRejectedCallbacks = []; const resolve = (value) => { if (this.state === 'pending') { this.state = 'fulfilled'; this.value = value; this.onFulfilledCallbacks.forEach((fn) => fn(value)); }}; const reject = (value) => { if (this.state === 'pending') { this.state = 'rejected'; this.value = value; this.onRejectedCallbacks.forEach((fn) => fn(value)); }}; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { if (this.state === 'pending') { this.onFulfilledCallbacks.push(onFulfilled); this.onRejectedCallbacks.push(onRejected); } if (this.state === 'fulfilled') { onFulfilled(this.value); } if (this.state === 'rejected') { onRejected(this.value); }}}

Next, you can test the functionality with the previous test code

const p1 = new MyPromise((resolve, reject) => {
  setTimeout(() => resolve('resolved'), 1000);
});

p1.then(
  (res) => console.log(res), // resolved
  (err) => console.log(err)
);

const p2 = new MyPromise((resolve, reject) => {
  setTimeout(() => reject('rejected'), 1000);
});

p2.then(
  (res) => console.log(res),
  (err) => console.log(err) // rejected
);

However, if you test with the following code, you will find that the error is reported.

const p1 = new MyPromise((resolve, reject) => {
  setTimeout(() => resolve('resolved'), 1000);
});

p1.then(
  (res) => console.log(res),
  (err) => console.log(err)
).then(
  (res) => console.log(res),
  (err) => console.log(err)
); // Uncaught TypeError: Cannot read property 'then' of undefined

This is because the first then method does not return any value, but then methods are called continuously.

Therefore, the next step is to implement a Then chained call.

supportthenChain call Promise

To support then chained calls, then methods need to return a new Promise.

Therefore, you need to transform the Then method to return a new Promise and then execute the resolve or reject function of the new Promise after the onFulfilled or onRejected callback of the previous Promise.

class MyPromise { then(onFulfilled, onRejected) { return new MyPromise((resolve, reject) => { if (this.state === 'pending') { this.onFulfilledCallbacks.push(() => { try { const fulfilledFromLastPromise  = onFulfilled(this.value); resolve(fulfilledFromLastPromise); } catch (err) { reject(err); }}); this.onRejectedCallbacks.push(() => { try { const rejectedFromLastPromise = onRejected(this.value); reject(rejectedFromLastPromise); } catch (err) { reject(err); }}); } if (this.state === 'fulfilled') { try { const fulfilledFromLastPromise = onFulfilled(this.value); resolve(fulfilledFromLastPromise); } catch (err) { reject(err); } } if (this.state === 'rejected') { try { const rejectedFromLastPromise = onRejected(this.value); reject(rejectedFromLastPromise); } catch (err) { reject(err); }}}); }}

Next you can test the functionality with the following code

const p1 = new MyPromise((resolve, reject) => {
  setTimeout(() => resolve('resolved'), 1000);
});

p1.then(
  (res) => {
    console.log(res); // resolved
    return res;
  },
  (err) => console.log(err)
).then(
  (res) => console.log(res), // resolved
  (err) => console.log(err)
);

const p2 = new MyPromise((resolve, reject) => {
  setTimeout(() => reject('rejected'), 1000);
});

p2.then(
  (res) => console.log(res),
  (err) => {
    console.log(err); // rejected
    throw new Error('rejected');
  }
).then(
  (res) => console.log(res),
  (err) => console.log(err) // Error: rejected
);

However, if you switch to the following code test, you will find that the successful callback function in the second Then method does not output as expected (‘ resolved ‘), but instead outputs the Promise returned in the onFulfilled callback function of the previous Then method.

const p1 = new MyPromise((resolve, reject) => {
  setTimeout(() => resolve('resolved'), 1000);
});

p1.then(
  (res) => {
    console.log(res); // resolved
    return new MyPromise((resolve, reject) => {
      setTimeout(() => resolve('resolved'), 1000);
    })
  },
  (err) => console.log(err)
).then(
  (res) => console.log(res), // MyPromise {state: "pending"}
  (err) => console.log(err)
);

This is because, fulfilled/rejected, you will simply pass the values returned by the onFulfilled/ rejected callback into the resolve/reject function. There is no consideration for cases where onFulfilled/ OnRejected will return a new Promise, so the successful callback of the second Then method outputs the Promise returned by the successful callback of the previous Then method. Therefore, the next step is to solve this problem.

First of all, you can change the above test code into another way of writing, convenient to comb the train of thought.

const p1 = new MyPromise((resolve, reject) => {
  setTimeout(() => resolve('resolved'), 1000);
});

const p2 = p1.then(
  (res) => {
    console.log(res);
    const p3 = new MyPromise((resolve, reject) => {
      setTimeout(() => resolve('resolved'), 1000);
    });

    return p3;
  },
  (err) => console.log(err)
);

p2.then(
  (res) => console.log(res),
  (err) => console.log(err)
);

As you can see, there are three promises:

  • The first Promise

    That’s P1 constructed by new

  • The second Promise

    That is the P2 returned by calling the Then method

  • The third Promise

    That is the p3 returned in the success callback function argument of the p1. Then method

The problem is that P3 is still pending when P2’s then method is called.

When p2. Then is used to print the resolve/reject value in p2, it is necessary to wait for the state of p3 to change and pass the changed value into the resolve/reject value in p2. In other words, the sequence of the three Promise state changes should be p1 –> p3 –> p2.

class MyPromise { then(onFulfilled, onRejected) { return new MyPromise((resolve, reject) => { if (this.state === 'pending') { this.onFulfilledCallbacks.push(() => { try { const fulfilledFromLastPromise  = onFulfilled(this.value); if (fulfilledFromLastPromise instanceof MyPromise) { fulfilledFromLastPromise.then(resolve, reject); } else { resolve(fulfilledFromLastPromise); } } catch (err) { reject(err); }}); this.onRejectedCallbacks.push(() => { try { const rejectedFromLastPromise = onRejected(this.value); if (rejectedFromLastPromise instanceof MyPromise) { rejectedFromLastPromise.then(resolve, reject); } else { reject(rejectedFromLastPromise); } } catch (err) { reject(err); }}); } if (this.state === 'fulfilled') { try { const fulfilledFromLastPromise = onFulfilled(this.value); if (fulfilledFromLastPromise instanceof MyPromise) { fulfilledFromLastPromise.then(resolve, reject); } else { resolve(fulfilledFromLastPromise); } } catch (err) { reject(err); } } if (this.state === 'rejected') { try { const rejectedFromLastPromise = onRejected(this.value); if (rejectedFromLastPromise instanceof MyPromise) { rejectedFromLastPromise.then(resolve, reject); } else { reject(rejectedFromLastPromise); } } catch (err) { reject(err); }}}); }}

The final version Promise

Finally, a simple Promise is completed, supporting asynchronous and then chained calls. The complete code is as follows:

class MyPromise { constructor(executor) { this.state = 'pending'; this.value = null; this.onFulfilledCallbacks = []; this.onRejectedCallbacks = []; const resolve = (value) => { if (this.state === 'pending') { this.state = 'fulfilled'; this.value = value; this.onFulfilledCallbacks.forEach((fn) => fn(value)); }}; const reject = (value) => { if (this.state === 'pending') { this.state = 'rejected'; this.value = value; this.onRejectedCallbacks.forEach((fn) => fn(value)); }}; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { return new Promise((resolve, reject) => { if (this.state === 'pending') { this.onFulfilledCallbacks.push(() => { try { const fulfilledFromLastPromise  = onFulfilled(this.value); if (fulfilledFromLastPromise instanceof Promise) { fulfilledFromLastPromise.then(resolve, reject); } else { resolve(fulfilledFromLastPromise); } } catch (err) { reject(err); }}); this.onRejectedCallbacks.push(() => { try { const rejectedFromLastPromise = onRejected(this.value); if (rejectedFromLastPromise instanceof Promise) { rejectedFromLastPromise.then(resolve, reject); } else { reject(rejectedFromLastPromise); } } catch (err) { reject(err); }}); } if (this.state === 'fulfilled') { try { const fulfilledFromLastPromise = onFulfilled(this.value); if (fulfilledFromLastPromise instanceof Promise) { fulfilledFromLastPromise.then(resolve, reject); } else { resolve(fulfilledFromLastPromise); } } catch (err) { reject(err); } } if (this.state === 'rejected') { try { const rejectedFromLastPromise = onRejected(this.value); if (rejectedFromLastPromise instanceof Promise) { rejectedFromLastPromise.then(resolve, reject); } else { reject(rejectedFromLastPromise); } } catch (err) { reject(err); }}}); }}