Implement Promise Simulated (Small White Edition)
This article talks about how to simulate the basic function of a Promise. There are already many articles like this on the Internet. There will be more ink in this article because you want to use your own understanding and spoken in vernacular
Promise's basic specifications are referenced in this article: Promises/A+ Specification
But to be honest, there are too many technical terms, and they are basically translated according to the standard and canonical format. Some of them are hard to understand if they are not familiar with the standard reading style.
I just didn't read the official rules directly, so even when I read the Chinese translation, some of the expressions still take a lot of time to understand, so I want to write this article
Introduction to Promise
Promise is an asynchronous programming scheme that registers callback functions with the then method and controls the asynchronous state with constructor parameters
There are two types of state changes for Promise, success or failure. Once the change is complete, the state is not changed. All subsequently registered callbacks can receive the state, and the asynchronous execution result is passed to the callback function through parameters.
Use examples
var p = new Promise((resolve, reject) => { // do something async job // resolve(data); //end of task, trigger state change, notify successful callback processing, and pass result data // reject(err); //Task exceptions, trigger state changes, notify processing of failed callbacks, and pass cause of failure }).then(value => console.log(value)) .catch(err => console.error(err)); p.then(v => console.log(v), err => console.error(err));
The example above is the basic usage, the then method returns a new Promise, so chained calls are supported for scenarios where a task depends on the results of the previous task's execution
You can also call the then multiple times for the same Promise to register multiple callback handles
By using it to understand its functionality and understand what it supports, we can only know what code we need to write when simulating our implementation
So here's a more detailed list of Promise's basic functions:
- Promise has three states: Pending, Resolved, and Rejected, which do not change once the change is complete
- The Promise constructor takes a function parameter, which can be called the task handler
- The task handler is used to handle asynchronous work. This function has two parameters and is also a function type. When asynchronous work ends, Promise status changes, callback triggers, and result delivery are notified by calling these two function parameters
- Promise has a then method for registering callback processing. When the state change ends, the registered callback must be processed, even if the state change ends before registering through the then
- The then method supports calling multiple times to register multiple callback handles
- The then method receives two optional parameters, both of which are functions, that is, callback handlers that need to be registered, which are callback functions for success and callback functions for failure.
- These callback functions have a parameter of any type, and the value is the result of the callback that needs to be notified at the end of the task, which is passed in by calling the parameters of the task processing function (the type is the function)
- The then method returns a new Promise to support chain calls, and changes in the state of the new Promise depend on the return value of the callback function, which is handled differently for different types
- In a chain call to the then method, if the callback handling passed in by one of the middle thens is not friendly to the callback work (such as passing to the non-function type parameter), the work will continue to be passed down to the next one registered with the then
- Promise has a catch method for registering failed callback handling, which is actually then(null, onRejected) grammatical sugar
- When a code exception occurs during the execution of a task handler or callback function, Promise captures the state automatically and treats it as a failure directly
- When new Promise(task), the incoming task function will be executed immediately, but the callback function passed to the n will be queued for execution as a microtask (it is understood that if you do not know how to simulate a microtask, you can use the macro task generated by setTimeout to simulate it)
These basic features are sufficient for Promise to use on a daily basis, so the goal of our simulated implementation is to achieve them
Simulating implementation ideas
Step 1: Skeleton
The basic functions of Promise are clear, so how do we write our code and what do we write?
From a code point of view, it is just some variables and functions, so we can think about what code we need for each function point:
- At least three states are required on the variable, the current state (_status), and the resulting value (_value) passed to the callback function.
- constructor
- task handler
- Two functions of the task handler to notify state changes (handleResolve, handleReject)
- then method
- Two callback functions registered with the then method
- Callback function queue
- catch method
Both the task handler and the registered callback handler are code that the user writes to suit their business needs when using Promise
So the rest is the code we need to write to implement Promise so that the Promise skeleton can actually come out:
export type statusChangeFn = (value?: any) => void; /* Callback function type */ export type callbackFn = (value?: any) => any; export class Promise { /* Three States */ private readonly PENDING: string = 'pending'; private readonly RESOLVED: string = 'resolved'; private readonly REJECTED: string = 'rejected'; /* promise current state */ private _status: string; /* promise results of enforcement */ private _value: string; /* Successful callbacks */ private _resolvedCallback: Function[] = []; /* Failed callbacks */ private _rejectedCallback: Function[] = []; /** * Handles resolve state change related work, parameter receives external incoming execution results */ private _handleResolve(value?: any) {} /** * Handle reject status change related work, parameter receive external incoming failure reason */ private _handleReject(value?: any) {} /** * Constructor, which receives a task handler. task has two optional parameters, and the type is also a function. In fact, the two functions above that handle state change work (_handleResolve, _handleReject) are used to trigger state change for the user */ constructor(task: (resolve?: statusChangeFn, reject?: statusChangeFn) => void) {} /** * then Method, receives two optional parameters to register callback processing for success or failure, so the type is also a function, which has one parameter, receives the Promise execution result or the reason for the failure, and returns any value as the result of the new Promise execution */ then(onResolved?: callbackFn, onRejected?: callbackFn): Promise { return null; } catch(onRejected?: callbackFn): Promise { return this.then(null, onRejected); } }
Note: The code here in the skeleton, I use TypeScript, which is a strongly typed language, can mark the various variables, parameter types, easy to describe and understand, it is okay to not understand, compiled into js version below
So what we're going to add to this is three things: what the Promise constructor does, what state changes need to be done, and what the then needs to do when registering callback functions
Step 2: Constructor
What the Promise constructor does is simply execute the incoming task handler immediately, pass the two state change handlers it provides internally to the task, and set the current promise state to PENDING.
constructor(task) { // 1. Set the current state to PENDING this._status = this.PENDING; // Parameter Type Check if (!(task instanceof Function)) { throw new TypeError(`${task} is not a function`); } try { // 2. Call the task handler and pass the function that notifies you of the state change to the past. Note how this is handled task(this._handleResolve.bind(this), this._handleReject.bind(this)); } catch (e) { // 3. If an exception occurs to the task handler, handle it as a failure this._handleReject(e); } }
Step 3: State Change
Relevant processing of Promise status changes is one of the hardest parts I think of implementing Promise. The hardest part here is not how complex the code is, but that it needs to be understood or that it is not easy to understand the specification, because there are some processing to consider, and I read some Promise implementation articles online, which are all problematic.
The work of state change is triggered when two function parameters passed to the task handler are called, for example:
new Promise((resolve, reject) => { resolve(1); });
A call to resolve or reject triggers work within Promise to handle state changes. Remember what the constructor does, where resolve or reject is essentially a function that handles state changes internally
But one thing to note here is that Promise's state must change as soon as resolve is called?
The answer is no, I read some of these articles on the Internet, they handle resolve calls, the status changes, and they go to handle the callback queue
But in fact, that's wrong
Changes in state actually depend on the type of parameters passed in the past when the resolve call is made, because any type of value can be passed here, either as a base type or as a Promise
When the types are different, the state changes are handled differently. The specification at the beginning contains detailed instructions, but it is not easy to understand them. I will simply use my understanding here:
- resolve(x) triggered pending => resolved processing:
- When the x type is a Promise object:
- When the state change of the Promise x ends, the state change is processed using the internal state and results (_status and _value) of the Promise x as the state and result of the current Promise
- It can be simply understood that the current Promise depends on x, which is x.then(this._handleResolve, this._handleReject)
- When the x type is the thenable object (an object with the then method):
- Processing this then method as a task handler returns you to the first step, waiting for a state change to occur
- It can be simply understood as x.then(this._handleResolve, this._handleReject)
- The x.then here is not Promise's then processing, it's just a simple function call, it just happens to be called then
- For the remaining types:
- Set internal state (_status) to RESOLVE
- Internal result (_value) set to x
- Simulate setTimeout processing callback function queue
- When the x type is a Promise object:
- reject(x) triggered pending => rejected processing:
- Do not distinguish between x types, go straight to rejected processing
- Internal state (_status) set to REJECTED
- Internal structure (_value) set to x
- Simulate setTimeout processing callback function queue
- Do not distinguish between x types, go straight to rejected processing
So you can see that even though resolve is invoked, the state does not necessarily change internally, only when the parameter type passed by resolve is neither Promise object type nor thenable object with the then method
When the parameter passed is Promise or the thenable object with the then method, it is almost equivalent to waiting for the task function to be processed recursively back to the first step.
Think about why you need this treatment, or why you need this design?
This is because there is a scenario where there are multiple asynchronous tasks that are synchronous, and the execution of a task depends on the results of the previous asynchronous task. When these asynchronous tasks are combined through the chain call of the then, the state change of the new Promise produced by the then method is dependent on the return value of the callback function.So this state change needs to support asynchronous wait processing when the value type is Promise so that the asynchronous task chain can achieve the desired execution results
When you look at a specification, or at its Chinese translation, there is actually a more detailed explanation of how this is handled, such as the article with the link at the beginning that has a dedicated module: Promise's resolution process, also expressed as [[Resolve] (promise, x). That's what this is all about
But I want to describe it with my own understanding, which is easier to understand. Although I can only describe a general job, more detailed and comprehensive processing should follow the specifications, let's look at the code below:
/** * resolve State change handling for */ _handleResolve(value) { if (this._status === this.PENDING) { // 1. If the value is Promise, wait for the Promise status result to come out before doing state change processing again if (value instanceof Promise) { try { // The reason you don't need to use bind to notice this is because you use the arrow function // This can also be written as value.then(this._handleResole.bind(this), this._handleReject.bind(this)) value.then(v => { this._handleResolve(v); }, err => { this._handleReject(err); }); } catch(e) { this._handleReject(e); } } else if (value && value.then instanceof Function) { // 2. If the value is an object with the then method, then use the then method as a task handler, leaving the triggering of state changes to the then, and note the handling of this try { const then = value.then; then.call(value, this._handleResolve.bind(this), this._handleReject.bind(this)); } catch(e) { this._handleReject(e); } } else { // 3. Other types, state changes, triggering successful callbacks this._status = this.RESOLVED; this._value = value; setTimeout(() = { this._resolvedCallback.forEach(callback => { callback(); }); }); } } } /** * reject State change handling for */ _handleReject(value) { if (this._status === this.PENDING) { this._status = this.REJECTED; this._value = value; setTimeout(() => { this._rejectedCallback.forEach(callback => { callback(); }); }); } }
Step 4: then
The function that the then method is responsible for is also very complex, because it returns a new Promise, whose state and result depend on the return value of the callback function. The execution of the callback function depends on whether it is cached in the callback function queue or dropped into the microtask queue after taking the state result of the dependent Promise directly.
Although functional complexity is a bit complex, in fact, implementation depends on the constructor and state change function that have been written before, so as long as the previous steps are implemented without problems, the then method will not have too many problems, just look at the code:
/** * then Method that receives two optional parameters for registering callback processing, so the type is also a function with one parameter that receives the Promise execution result and returns any value as the result of the new Promise execution */ then(onResolved, onRejected) { // The then method returns a new Promise whose status results depend on the return value of the callback function return new Promise((resolve, reject) => { // Encapsulate the callback function one level, mainly because the result of executing the callback function affects the status and result of the new Promise returned const _onResolved = () => { // Decide how to handle state changes based on the return value of the callback function if (onResolved && onResolved instanceof Function) { try { const result = onResolved(this._value); resolve(result); } catch(e) { reject(e); } } else { // If a non-function type is passed in, the last Promise result is passed to the next process resolve(this._value); } }; const _onRejected = () => { if (onRejected && onRejected instanceof Function) { try { const result = onRejected(this._value); resolve(result); } catch(e) { reject(e); } } else { reject(this._value); } }; // If the current Promise state has not changed, queue the callback function for execution // Otherwise, create the microtask directly to handle these callback functions if (this._status === this.PENDING) { this._resolvedCallback.push(_onResolved); this._rejectedCallback.push(_onRejected); } else if (this._status === this.RESOLVED) { setTimeout(_onResolved); } else if (this._status === this.REJECTED) { setTimeout(_onRejected); } }); }
other aspects
Because the purpose was to clarify Promise's primary functional responsibilities, my implementation did not follow the specifications step by step, in detail, or in some special scenarios, which may not be considered
For example, check processing for each function parameter type, because Promise's parameters are basically function types, but even if other types are passed, Promise's use is not affected.
For example, to avoid being changed to an implementation, some internal variables can be implemented using Symbol instead
But in general, considering the above steps, the basic functions are almost the same. It is important to consider all the aspects of state change processing. Some articles on the Internet have implementation versions that are omitted from consideration.
And don't panic when an interview encounters someone who lets your handwriting implement Promise. Just follow this line of thought, first review the basic usage of Promise, then recall the functions it supports, and then have a skeleton in mind. It's just a few internal variables, constructors, state change functions, and then the n functions, but it's not good to remember by heart, have a way of thinking, step by step, always recall
Source code
The source code complements the implementation of catch, resolve, and other methods, which are based on a layer of encapsulation on the basic functions of Promise for ease of use
class Promise { /** * The constructor receives and executes a task handler, passes two state change handlers it provides internally to the task, and sets the current promise state to PENDING. */ constructor(task) { /* Three States */ this.PENDING = 'pending'; this.RESOLVED = 'resolved'; this.REJECTED = 'rejected'; /* Successful callbacks */ this._resolvedCallback = []; /* Failed callbacks */ this._rejectedCallback = []; // 1. Set the current state to PENDING this._status = this.PENDING; // Parameter Type Check if (!(task instanceof Function)) { throw new TypeError(`${task} is not a function`); } try { // 2. Call the task handler and pass the function that notifies you of the state change to the past. Note how this is handled task(this._handleResolve.bind(this), this._handleReject.bind(this)); } catch (e) { // 3. If an exception occurs to the task handler, handle it as a failure this._handleReject(e); } } /** * resolve State change handling for */ _handleResolve(value) { if (this._status === this.PENDING) { if (value instanceof Promise) { // 1. If the value is Promise, wait for the Promise status result to come out before doing state change processing again try { // The reason you don't need to use bind to notice this is because you use the arrow function // This can also be written as value.then(this._handleResole.bind(this), this._handleReject.bind(this)) value.then(v => { this._handleResolve(v); }, err => { this._handleReject(err); }); } catch(e) { this._handleReject(e); } } else if (value && value.then instanceof Function) { // 2. If the value is an object with the then method, then use the then method as a task handler, leaving the triggering of state changes to the then, and note the handling of this try { const then = value.then; then.call(value, this._handleResolve.bind(this), this._handleReject.bind(this)); } catch(e) { this._handleReject(e); } } else { // 3. Other types, state changes, triggering successful callbacks this._status = this.RESOLVED; this._value = value; setTimeout(() => { this._resolvedCallback.forEach(callback => { callback(); }); }); } } } /** * reject State change handling for */ _handleReject(value) { if (this._status === this.PENDING) { this._status = this.REJECTED; this._value = value; setTimeout(() => { this._rejectedCallback.forEach(callback => { callback(); }); }); } } /** * then Method that receives two optional parameters for registering callback processing, so the type is also a function with one parameter that receives the Promise execution result and returns any value as the result of the new Promise execution */ then(onResolved, onRejected) { // The then method returns a new Promise whose status results depend on the return value of the callback function return new Promise((resolve, reject) => { // Encapsulate the callback function one level, mainly because the result of executing the callback function affects the status and result of the new Promise returned const _onResolved = () => { // Decide how to handle state changes based on the return value of the callback function if (onResolved && onResolved instanceof Function) { try { const result = onResolved(this._value); resolve(result); } catch(e) { reject(e); } } else { // If a non-function type is passed in, the last Promise result is passed to the next process resolve(this._value); } }; const _onRejected = () => { if (onRejected && onRejected instanceof Function) { try { const result = onRejected(this._value); resolve(result); } catch(e) { reject(e); } } else { reject(this._value); } }; // If the current Promise state has not changed, queue the callback function for execution // Otherwise, create the microtask directly to handle these callback functions if (this._status === this.PENDING) { this._resolvedCallback.push(_onResolved); this._rejectedCallback.push(_onRejected); } else if (this._status === this.RESOLVED) { setTimeout(_onResolved); } else if (this._status === this.REJECTED) { setTimeout(_onRejected); } }); } catch(onRejected) { return this.then(null, onRejected); } static resolve(value) { if (value instanceof Promise) { return value; } return new Promise((reso) => { reso(value); }); } static reject(value) { if (value instanceof Promise) { return value; } return new Promise((reso, reje) => { reje(value); }); } }
test
There are libraries on the Web that specifically test Promise that you can use directly, such as: promises-tests
Here are some test cases of basic functionality:
- Test Chain Call
output// Test Chain Call new Promise(r => { console.log('0.--synchronization-----'); r(); }).then(v => console.log('1.-----------------')) .then(v => console.log('2.-----------------')) .then(v => console.log('3.-----------------')) .then(v => console.log('4.-----------------')) .then(v => console.log('5.-----------------')) .then(v => console.log('6.-----------------')) .then(v => console.log('7.-----------------'))
0.--synchronization----- 1.----------------- 2.----------------- 3.----------------- 4.----------------- 5.----------------- 6.----------------- 7.-----------------
- Testing multiple callbacks to the n to register multiple callback processing
output// Testing multiple callbacks to the n to register multiple callback processing var p = new Promise(r => r(1)); p.then(v => console.log('1-----', v), err => console.error('error', err)); p.then(v => console.log('2-----', v), err => console.error('error', err)); p.then(v => console.log('3-----', v), err => console.error('error', err)); p.then(v => console.log('4-----', v), err => console.error('error', err));
1----- 1 2----- 1 3----- 1 4----- 1
- Test Asynchronous Scenarios
output// Test Asynchronous Scenarios new Promise(r => { r(new Promise(a => setTimeout(a, 5000)).then(v => 1)); }) .then(v => { console.log(v); return new Promise(a => setTimeout(a, 1000)).then(v => 2); }) .then(v => console.log('success', v), err => console.error('error', err));
1 // 5s before output success 2 // Output takes another 2 seconds
This test can detect whether resolve's state changes are handled differently according to specifications and scenarios. You can find an implementation of Promise on the Internet, paste its code into the console of the browser, and then test it to see if there is a problem.
- Test execution result type Promise object scenario
output// Test execution result type is Promise Object Scenario (change after Promise state 5s) new Promise(r => { r(new Promise(a => setTimeout(a, 5000))); }).then(v => console.log('success', v), err => console.error('error', err));
success undefined // 5s before output
output// Test execution result type is Promise Object Scenario (Promise state will not change) new Promise(r => { r(new Promise(a => 1)); }).then(v => console.log('success', v), err => console.error('error', err));
//Never output
- Test execution result type is thenable object scene with then method
output// The test execution result type is the thenable object scenario with the then method (the function parameter passed is called inside the then method) new Promise(r => { r({ then: (a, b) => { return a(1); } }); }).then(v => console.log('success', v), err => console.error('error', err));
success 1
output// //Test execution result type is thenable object scenario with the then method (function parameters passed are not called inside the then method) new Promise(r => { r({ then: (a, b) => { return 1; } }); }).then(v => console.log('success', v), err => console.error('error', err));
//Never output
output// The test execution result type is an attribute with the then, but the attribute value type is not a function new Promise(r => { r({ then: 111 }); }).then(v => console.log('success', v), err => console.error('error', err));
success {then: 111}
- Transfer of test execution results
output// When testing Promise rejectd, the reject status result is passed to the callback of the then that can handle the failure result new Promise((r, j) => { j(1); }).then(v => console.log('success', v)) .then(v => console.log('success', v), err => console.error('error', err)) .catch(err => console.log('catch', err));
error 1
output// When the parameters passed to the test are non-function types, execution results and status are passed along new Promise(r => { r(1); }).then(1) .then(null, err => console.error('error', err)) .then(v => console.log('success', v), err => console.error('error', err));
success 1
output// When a test rejectd failure is handled, rejectd is not passed on new Promise((r,j) => { j(1); }).then(2) .then(v => console.log('success', v), err => console.error('error', err)) .then(v => console.log('success', v), err => console.error('error', err));
error 1 success undefined
Finally, when you finish writing your own simulated Promise implementation, you can paste the code into your browser, test the use cases yourself, and compare them with the official Promise execution, and you will know if there are any problems with the basic Promise functionality you have implemented.
Of course, there are some test libraries to help you if you need more comprehensive testing
However, the purpose of implementing a Promise by myself is to clarify the basic functions, behaviors and principles of Promise, so if these use cases can be measured and passed, you will basically have these points of knowledge.