一网打尽手写Promise
导读
首先,让我们回顾一下Promise
的定义,为了偷懒,就直接复制粘贴了:
可以看到,Promise是一个允许异步操作的代理,我更愿意简单点叫它“容器”。
在各大面试题中,手写Promise是一道高频题,笔者自己也遇到了好几次,同时其也涉及到****事件循环的有关知识,为了进一步强化大家对于其原理和事件循环中微任务队列的关系,随将用比较长的篇幅着重介绍这些内容,并包含其PromiseLike
、Awaited
等typescript
的类型推断。
如果你想直接查看代码,请移步我的
GitHub - zerotower69/handle-promise-TPromise-: achieve myself promise by using queueMicroTask api.
。
微任务队列
或许你经常听到事件循环这一概念,除此之外还有JS是单线程的这一说法。首先,事件是JS的核心,JS为了实现异步操作,总是使用发布订阅的设计模式,也就是在无法事先确定任务(代码中异步逻辑的部分,通常是函数,也叫回调函数),会使用队列(通常是数组)等结构储存这些任务,待某个未来的时间点事件被触发时依次执行(按进入队列的顺序),这个队列也叫任务队列。在JS这个“单线程”中,异步任务存放在异步任务队列中,而异步任务又分为微任务和宏任务,因此,JS单线程中的异步队列实际是微任务队列和宏任务队列的统称。但注意,前文的表述中“单线程”并不是说整个JS就是真的一个线程,在整个JS的宿主环境中(分别为web浏览器环境和Node环境)其实是有多个线程的,比如浏览器中还有GUI渲染线程、http异步线程等,Node环境还有编译线程、垃圾回收线程等。而单线程说的是执行JS代码的线程始终只有一个(除非Node环境中强制开启了子线程,process和child_process),这个线程也称为执行线程,也就是说,真正运行JS逻辑块的只有一个线程,哪怕是异步队列中的任务的逻辑块,也是在事件触发时(执行时机到达),从队列中取出,放入到执行线程中执行。因此,准确地描述是:绝大多数情况下,JS代码是通过唯一一个执行线程来执行的。
如果你想详细了解宏任务和微任务以及事件循环,可以阅读我的文章
此外,如果你想更深入了解Promise以及事件循环的工作流程,可以参考国外的这篇文章:
queueMicroTask API
为了能够使得第三方库、polyfill等能够执行微任务,JS环境(浏览器和Node)暴露了全局的queueMicroTask
接口,详细参考MDN。其可以将一个回调函数视为一个微任务加入到微任务队列中,就像:
1 | queueMicroTask(()=>{ |
这也为下文中我们完整地实现手写Promise
提供了可能。
Promise A+规范
在手写前,我们必须了解一下
,其为现如今各大主流浏览器实现Promise的一个规范和参考标准,因此我们要想自己实现Promise
,也必须将其视为参考,并严格按照其规定实现。
首先,规定了一个promise
实例(文中以大写的Promise
表示统称、类和对象,以小写的promise
表示Promise
的实例)的状态一共为三类:pending
、fulfilled
、rejected
,要记忆的话,分别对应** 谈恋爱**、结婚了、**分手了**三种结果。但与结婚了还可以分手不同,三种状态一经改变不可逆转,也不可以发生fulfilled到rejected和rejected到fulfilled的转变,其描述如下图所示。
如果你已经使用过Promise
,你会发现刚好对应着我们实例化时,智能拿到执行器中的resolve
和reject
两个回调函数,其刚好代表着Promise
状态的仅有的两条转化路径。
其次,Promise A+
规定了Promise.prototype.then
方法接收onFulfilled
和onRejected
两个回调作为函数参数,最终返回一个新的Promise
实例(记为p2)。而onFulfilled
和onRejected
这两个回调函数各自接收一个参数,value
和reason
,分别代表兑现(fulfilled
)值和拒绝(rejected
)理由,如同结婚时的彩礼嫁妆和分手时给的理由,这两个值又分别由实例化时的两个回调函数resolve
和reject
分别传入。
也因为Promise.prototype.then
方法返回了p2,因此我们平时使用的Promise
能够支持链式调用,但每次链式后都会返回一个新的**Promise**
实例。
手写前分析
在我们正式手写前,我们先通过一张思维导图来看看整个Promise的结构和所具备的方法:
可以看到除了Promise A+
的状态以及Promise.prototype.then
方法,原型方法还有:Promise.prototype.catch
和Promise.prototype.finally
,静态方法有:Promise.resolve
、Promise.reject
、Promise.any
、Promise.allSetteld
、Promise.race
和Promise.all
。其中核心就是构造函数和Promise.prototype.then
方法的实现,尤其是Promise.prototype.then
方法,其它原型方法基本要依赖其实现。
除了思维导图之外,还需注意一点,由于Promise
支持链式使用,链式时返回的都是一个新的Promise
实例new
,这就涉及了newP
的状态将由上一个promise
的状态或者内部回调函数的逻辑决定(相当于还没结婚呢就开始买车买房,这些承诺之间相互影响)。且我们使用Promise
时,经常使用catch
在这个Promise链
中捕获之前reject的reason或者逻辑块中的异常,例如:
1 | Promise.reject(4).then((value)=>{ |
如果没有了catch,就会抛出异常:
也就是说,我们value或者reason会****在链式中向下传递,直到被使用或者捕获(统称为拦截)。
手写构造器部分
为了充分理解构造器,手写采用ES6的class语法,但必须注意的是,class语法也只是原型链prototype
的语法糖,底层是一致的,javascript
中的class并不是严格意义中的面向对象中的类。
将我们手写的Promise
命名为TPromise
,初始状态为pending
:
1 | const PENDING="pending" |
接着参考MDN上对于构造函数的描述,executor将会接收两个构造函数,分别用于更改Promise
的状态。
- resolve(vaue)
- reject(reason)
**且我们知道,状态转变后就会执行之前添加的微任务(实际是将之前的任务添加到真实的微任务队列),那么,我们需要有一个队列存储我们传入的任务(回调函数),且状态从 **pending
到fulfilled
和pending
到rejected
两条路径,于是也就会有两个队列来维护存储这些回调,于是代码如下:
1 | class TPromise{ |
在之前的基础上,我们添加了onFulfilledCallbacks
和onRejectedCallbacks
两个数组用于存储任务,并在定义的resolve
和reject
函数中逐一调用,并使用传入的value
或reason
值作为回调的参数。尤其需要注意的是,resolve
和reject
内部均判断了当前TPromise
的状态是否还是pending
,只有TPromise
的状态还是pending
才执行逻辑,满足Promise A+
规范说的,状态只改变一次,不可逆转。且执行executor
函数时,还要捕获内部的抛出的错误,如果抛出错误了,那么这个直接调用reject
将状态设置为rejected
,表示兑现失败。
手写Promise.prototype.then
then
方法是整个Promise
的灵魂所在,也就是它的内部创建了一个又一个的任务(回调函数),并立即将其加入到微任务队列,或是先添加到相对应的回调队列中等待resolve
和reject
调用时添加到微任务队列中。
由于其返回一个新的TPromise
实例(记为newP,支持链式调用的本质),有:
1 | then(onFulfilled,onRejected){ |
接着的逻辑是:如果此时的状态已经发生转变,也就是在executor
内部就调用了resolve
或者reject
,我们应该立即将任务送入微任务队列;如果状态还是pending
,就应该把任务放入TPromise
的回调队列中。
1 |
|
先看swtich部分,其就是实现了上述所说的状态变就利用queueMicroTask
加入微任务队列,状态不变入自身的回调队列。由于任务要么被直接加入微任务队列要么加入回调队列暂存,我先定义了fulfilledCallback
和rejectedCallback
函数,用来进一步封装并节省代码量。且可以通过代码看出,加入微任务队列这一操作还是同步操作,异步的微任务行为是最终从微任务队列取出执行的阶段,这一过程并不是我们控制的,我们真正做的还只是指定某个任务(回调函数)进入到微任务队列中!
而微任务中的逻辑块中
1 | try{ |
其表示执行传入的onFulfilled
,并获取返回值,并捕获其中抛出的错误,如果抛出错误,返回的新的TPromise
实例就调用reject
回调使其状态变为rejected
,否则将继续判断result的值处理,而这部分比较复杂,又单独利用外部定义的resolvePromise
函数处理。而3-4行是为了判断onFulfiiled
和onRejected
回调是不是不传或者不是函数类型,如果是就把执行时传入的value
或reason
沿着Promise链
向下传递,只有reason
用throw
语句抛出,就是因为reject
如果被自动调用,其都是在try...catch
语句中的catch
部分被调用,而你既然要被catch
捕获,自然就要先抛出了,只有链式上的每一环都throw
抛出,层层传递,才能被最终的最后的.catch((reason)=>)
所捕获执行。
接着,解释下resolvePromise
函数,先上完整版本的代码:
1 | function resolvePromise(promise,data,resolve,reject){ |
首先,什么叫****循环引用,就是一个**Promise**
状态的改变取决于自身的状态的改变,也就是先等我们结婚了再结婚,这显然是无稽之谈嘛,具体触发的可能情况在后续还会有说道,还请继续耐心看下去吧。
接着,让我们重新看看[Promise A+规范](https://promisesaplus.com/)
。其谈到,对于onFulfilled
和onRejected
的返回值(都记为result),如果其是一个Promise
实例,就用其兑现后的状态设置newP
的状态,如果其是一个对象且具有then
方法,那么其是一个thenable
对象,对result.then(onFulfilled,onRejected)
,执行并在其两个回调中,由于onFulfilled
的参数value
可能又是一个Promise
实例或者thenable
对象,递归调用resolvePromise
函数,onRejected
的参数reason
则直接作为newP
的rejected
的reason
;其它情况下,newP
的状态均为fulfilled
,其流程如下图所示:
由此,Promise.prototype.then
方法的逻辑已经完全实现,并严格遵循了Promise A+
规范。
手写Promise.prototype.catch
从MDN可知,Promise.prototype.catch
只接收一个onRejected
回调作为参数,其等价于this.then(null,onRejected)
。
因此,其内部实现为:
1 | catch(onRejected){ |
也就是说,其就是添加一个rejected时应该执行的微任务。
手写Promise.resolve
从
Promise.resolve() - JavaScript | MDN
可知,Promise.resolve
是Promise上的一个静态方法,其将给定的值转为一个Promise,如果给定的值value
就是一个Promise
实例,直接返回;否则就直接返回一个新的Promise,并直接使用resolve
将其兑现(可能是fulfilled
也可能是rejected
)。
于是有以下代码:
1 | static resolve(value){ |
手写Promise.reject
从MDN可知,静态方法Promise.reject
返回一个已拒绝(rejected)的<font style="color:rgb(27, 27, 27);"> </font><font style="color:rgb(27, 27, 27);">Promise</font><font style="color:rgb(27, 27, 27);"> </font>
对象,拒绝原因为给定的参数,无论给定的<font style="color:rgb(27, 27, 27);">reason</font>
是什么,<font style="color:rgb(27, 27, 27);">reject</font>
本身的行为就是拒绝,如同我和你分手的原因是因为我有了新欢,新欢将来结婚了还是分手还不知道,但我现在就要和你分手,这就是<font style="color:rgb(27, 27, 27);">reason</font>
及时也是一个<font style="color:rgb(27, 27, 27);">Promise</font>
对象也可以表示拒绝的理由,于是代码就很简单了。
1 | static reject(reason){ |
手写Promise.prototype.finally
从MDN可知,Promise.prototype.finally
是注册一个会在Promise
兑现(无论是fulfilled
还是rejected
)时都会执行的函数,其不代表着Promise链
的终结,依旧会返回一个等效的Promise
对象。如果处理程序抛出错误或返回被拒绝的 promise,那么 finally() 返回的 promise 将以该值被拒绝。否则,处理程序的返回值不会影响原始 promise 的状态。
这里的等效其实就是说,接到的value还要继续向下传递,接到的reason还得继续抛出,但如果finally强制指定了rejected
状态的Promise
,或者抛出错误,那么还是得rejected
。看看以下代码:
1 | Promise.resolve(5).finally(()=>{ |
1 | Promise.reject(5).finally(()=>{ |
其也利用Promise.prototype.then
实现,代码为:
1 | finally(onFinally){ |
在.finally(onFinally)
这个Promise
对象时,接收到其的value
或reason
,但onFinally()
的返回值(记为fResult
)未定,其可能是Promise
对象也有可能是thenable
对象,更有可能是其它类型,我们统一使用Promise.resolve
将其处理为真的Promise
对象fP
,当fP
敲定时(状态为fulfilled
或者rejected
)
手写其它静态方法
以下几个静态方法都有一些共同的特性,给定一个可迭代对象(Array、Map、Set、String等任何具有[Symbol.iterator]属性的对象,如果你还不了解可迭代对象,请查阅MDN),将返回一个新的Promise
对象。可迭代对象中的每一个元素可以是Promise
对象也可以不是Promise
对象,因此,内部就使用Promise.resolve
将其处理为Promise
对象,在此,约定把这些Promise
对象称为p1、p2、p3、... 、pn
,它们状态从pending
转为fulfilled
时的value值分别记为V1、V2、V3、... 、Vn
,状态从pending
转为rejected
时的reason值分别为R1、R2、R3、... 、Rn
。而每个方法放回的Promise
对象,记为P
,其状态都将由p1、p2、p3、... 、pn
等共同决定。
在下述的示例图中,我会使用****蓝色表示pending
的Promise
对象,绿色表示fulfilled
的Promise
对象,红色表示rejected
的Promise
对象。
Promise.all
从MDN得知,Promise.all
返回的Promise
对象P
,由p1、p2、p3、… 、pn 共同决定:只有所有的Promise
对象被兑现为fulfilled
,其P
的状态才为fulfilled
,且value值为所有Promise
的value值的有序数组,value=[V1,V2,...,Vn]
如图所示;
而只要p1、p2、p3、... 、pn
中任意一个Promise
对象兑现为rejected
,P
的状态也为rejected
,且reason值为第一个兑现为rejected
的Promise
的reason值(记为Rq
),如下图所示:
1 | static all(values){ |
其中,只解释如何使得在Promise
兑现无序的情况下使得最终fulfilled
状态时的value数组有序,下述的其它静态方法也同理。
就是实例化Promise
时,就创建一个结果数组results
,然后遍历传入的可迭代对象,并更新迭代的下标(由于我们使用for...of
遍历一个可迭代对象,只能得到元素无法取得下标),很明显地,下标应该是有序的,当兑现为fulfilled
时,按下标放入results
数组中,而不是直接push
;并使用一个计数变量count
统计总的fulfilled
的次数,当其和下标相等时,就是所有的Promise
均为fulfilled
,此时调用resolve
回调兑现返回的P
的状态,如果有任意Promise
被兑现为rejected
,就直接调用reject
将P
兑现为rejected
,且由于P
的状态已发生改变,就算后续其他Promise
被兑现为rejected
调用了reject
回调,P
的状态也不会再发生变化了。
Promise.any
从MDN得知,Promise.any
返回的Promise
对象P
,由p1、p2、p3、… 、pn 共同决定:只要任意一个Promise
兑现为fulfilled
,P
的状态为fulfilled
,如下图所示:
当所有的Promie
都被兑现为rejected
时,P
的状态为rejected
,其reason=[R1,R2,...,Rn]
,如下图所示:
1 | static any(values){ |
Promise.race
从MDN得知,Promise.race
返回的P
的状态随着第一个兑现的Promise
对象决定。如果第一个兑现为fulfilled
,P
也兑现为fulfilled
,且value值和其等同,如下图所示:
当 第一个兑现的为rejected
,P
也兑现为rejected
,且reason值和其等同,如下图所示:
1 | static race(values){ |
Promise.allSettled
从MDN得知,Promise.allSettled
返回一个状态为fulfilled
的Promise
对象,其为p1、p2、p3、... 、pn
全部兑现后结果的有序数组,并将同时记录其兑现后的状态。
1 | static allSettled(values){ |
测试我们手写的promise
手写完我们自己的TPromise
后,我们还需要进一步确认我们实现的TPromise
是否全部符合Promise A+规范
,为此,Promise A+
官方提供了一个测试库promises-aplus-tests。新建一个adapter.js 文件
1 | module.exports={ |
再新建一个test.js文件,使用这个测试库测试,其共拥有****872个测试用例,通过了就真正完成了我们的手写Promise
的全部过程。
1 | const promisesAplusTests = require('promises-aplus-tests'); |
小结
手写部分逐个分析了Promise
的构造函数、三个原型方法和六个静态方法。其中最重要的就是Promise.prrototype.then
和Promise.resolve
两个方法,因为其除了自身逻辑复杂,还被其他方法使用到,如果要在面试时利于不败之地,必须每行代码都要吃透。同时,通过手写,我们也逐步了解到,Promise
真正异步的逻辑部分是使用then
、catch
、finally
三个原型方法的回调函数部分,且**Promise**
本身不执行微任务,而是把微任务放入到**javascript**
执行线程中的微任务队列中。且Promise
状态的流转只能发生一次,状态一经改变就意味着Promise
已经兑现。
记忆时,可以参考刚开始的思维导图,三个原型方法都支持链式调用;六个静态方法都会返回Promise
,按单词本身理解是记忆的最好方法。
方法 | 记忆 | 是否一定返回新的Promise |
---|---|---|
Promise.resolve | resolve为解决,解决什么,当然是解决value为Promise | 否 |
Promise.reject | reject为拒绝,当然是无条件拒绝 | 是 |
Promise.all | all,所有,所有的都“成功” | 是 |
Promise.any | any,任意,任意一个”成功“ | 是 |
Promise.race | race,竞赛,状态是否改变的时间竞赛 | 是 |
Promise.allSettled | allSettled,所有的都解决,所有的Promise都兑现了 | 是 |
async/await 是Promise的语法糖
async/await
允许我们以一种更为简洁的方式实现promise的异步编程,省去我们链式操作的烦恼,其本质也是对于Promise
的封装,是语法糖。经常有人说,async/await
使得Promise
同步化,但其本质还是Promise
,你的代码逻辑还是会被送入微任务队列,怎么就同步了呢?还有的说法是await
会阻塞代码,其实并不是,javascript
可是号称非阻塞线程的,如果await
真的阻塞一个几天以后才会执行的代码那还得了!那await
其实只是把其后的逻辑块处理为一个返回promise的的异步函数,这也很容易让我们联想到Promise.resolve
静态方法,其包裹的值将被处理为一个Promise
对象,
如下的代码:
1 | async function asyncFn(){ |
等价于:
1 | function asyncFn(){ |
在JavaScript
中,如果函数不指定返回值,默认会返回undefined,于是上述的代码再次等价于:
1 | function asyncFn(){ |
且我们知道,Promise.prototype.then
方法接收的onFulfilled
回调的返回值又将决定新的Promise
的状态,如果它不是thenable
对象也不是Promise
,它直接作为新Promise
的value值。于是,下述的代码将输出undefined
:
1 | asyncFn().then((value)=>{ |
这个undefined
的值并非async/await
指定,而是函数默认的return undefined
这一行为所导致,async/await
只是做了一层包裹,于是乎,我们得到了async/await
语法糖的实质:
1 | async function(){ |
由于Promise.resolve
可以传入一个promise
实例或者thenable
对象,我们来看看函数aa
如果也是返回一个promise
的情况:
1 | async function asyncFn(){ |
等价于:
1 | function asyncFn(){ |
可以明确看出,由于函数aa
返回的是rejected
状态的promise
,最终输出不会有2,而是输出 1、5、4、3
。其中,由于Promise.resolve
如果传入的是一个promise
实例将直接返回,所以此刻在函数asyncFn
中并不会新建一个promise
实例,而是直接使用函数**aa**
返回的**promise**
实例。
再次强调:*构造函数以及**resolve**
和**reject**
调用的过程均是同步行为,只有**then**
*、**catch**
、**finally**
三个原型方法传入的回调才会异步执行,且这三个原型方法调用的本身都是同步行为。
接下来我们再看,函数内有多个await
的情况:
1 | async function asyncFn(){ |
等价于:
1 | function asyncFn(){ |
等价于:
1 | function asyncFn(){ |
等价于:
1 | function asyncFn(){ |
由此,如果存在多个await
,先使用Promise.resolve
替换第一个await
语句,然后将剩下的语句塞入其.then
方法的onFulfilled
回调函数中,并且把async
关键字挪到回调之前,即onFulfilled
回调函数此时也是一个async/await
函数,也就说明这个onFulfilled
回调也将返回一个promise
实例。重复此操作不停内嵌,直至所有的await
语句被替换。最终,函数asyncFn
返回的promise
由最后一句await
其后的返回所决定,由下述代码所输出的那样:
1 | function asyncFn(){ |
且经过转换后,我们也看到了如果没有async/await
语法糖,多个**promise**
的嵌套将会引发我们经常听说的回调地狱,而有了async/await
就可以解决这个问题,增强了我们代码的可读性(但确实不利于直观地明白输出顺序了,面试害死人……)
经典面试题
以下举例两道经典的面试题,请你先将其async/await
等价替换后,给出输出的结果,最终答案以及解析将发在评论区,欢迎留言讨论哦。
面试题01
1 | async function async1() { |
面试题02
1 | async function asy1(){ |
补充1:构造函数中resolve的进一步解释说明
将以下的代码通过 Chrome、Edge、FireFox浏览器以及Node环境下测试:
1 | new Promise(resolve=>{ |
1 | promise1 |
按照我们的构想以及测试我们手写的TPromise
,输出的结果似乎为:
1 | resolved promise |
总结
本文按照Promise A+规范
利用queueMicroTask
API手写了Promise
,并解释了微任务产生以及执行的具体时机。
另外,介绍了promises-plus-tests
库用于测试我们的手写Promise
是否完全符合Promise A+规范
。
最后,解释了async/await
如何等价转换为Promise
,并留下两道经典面试题作为思考。
所有的手写代码可以在我哥github仓库查看。