在之前的《高级前端修炼之路:深入理解JQuery底层源码》的文章中,我已经详细介绍了Jquery中的Deferred延迟对象的用法并模拟了它的一些底层源码的实现。今天要介绍的,是ES6中一个和JQ延迟对象非常相似的对象——Promise。
JQ中Deferred对象的决议结果有三种可能:resolve、reject和notify,分别代表成功、失败和等待状态。而Promise的决议结果只有两种可能:resolve和reject。Promise的基本用法如图所示:
首先通过Promise构造函数创建一个promise对象,构造函数中传递的函数会同步执行(图中传递的函数就是:(res,rej) => { res(‘success’); })。函数第一个参数res表示then接下来注册的成功回调函数,rej表示接下来then注册的失败回调函数(分别对应下面的then中第一个和第二个函数)。而对于第一个then来说,执行成功回调或失败回调是又我们在Promise构造函数里决定的,我们也可以执行失败回调:
当然,then同样也支持链式写法,我们可以继续使用then注册第二个成功回调和失败回调:
是不是看出令你诧异的地方了?没错,不管第一个then执行的是成功回调还是失败回调,第二个then都默认执行成功回调。那么什么时候下一个then会执行失败回调呢?答案是当程序出bug或抛出错误的时候:
除了回调函数抛出错误会执行失败回调外,其实在Promise构造函数里抛出错误,也会同样的执行then的失败回调函数:
对于异常错误,除了用下一个then的失败回调可以接收外,也能直接用catch方法接收:
不过,要是错误信息先被第二个then的失败回调函数接收了的话,catch就捕获不到了:
不过,如果我们把catch写在第二个then前面,它就能捕获到,并把自己的返回结果作为下一个then里的成功回调函数的参数,同时执行下一个then的成功回调:
我们可以继续添加任意多个then方法,执行规则和上面说的规则是一致的。还可以设置finally方法,用于在前面设置的方法执行完毕后执行:
前面函数里的返回值都是普通参数,其实返回值也可以是一个Promise对象:
返回的Promise对象会控制接下来then的触发情况。我们这里终于看到了,即使不用抛出错误异常,后面的then中的失败回调因为返回的新的Promise触发而触发了。
此外,Promise还有两个方法:Promise.all([ .. ])和Promise.race([ .. ])。Promise.all([ .. ])会等待传入的数组参数中的所有Promise对象都触发成功回调后触发接下来的then的成功回调,或者某一个Promise对象触发失败回调后就执行失败回调:
我们可以看到,如果都执行了成功回调,那么then方法中的成功回调函数会将每一个Promise传递的成功回调参数接收并依次输出。而如果其中一个执行了失败回调,那么then方法中的失败回调函数会将第一个触发失败回调的Promise对象所传递的参数接收并直接执行then的失败回调函数。因为我们设置让all里面的数组参数中第三个函数参数最先执行(100毫秒),所以接下来的then执行的失败回调输出的是c而不是a或b。
而Promise.race([ .. ])呢则是等待第一个完成或拒绝决议,也就是说最先触发的成功回调所传参数会由then的成功回调接收并立即执行,最先触发的失败回调所传参数会由then的失败回调接收并立即执行:
关于Promise对象的基本用法就这些了,介绍完它的用法,接下来我们就一步步来模拟Promise对象的底层源码实现。
首先,我们先用executor参数代表Promise里传入的函数,前面已经说了,Promise里的函数是同步执行的,所以调用完Promise后要立即执行传入的函数:
Promise有三种状态,一开始默认是pending,如果触发了成功回调,状态会变成Fulfilled;如果触发了失败回调,状态会变成Rejected。
因为resolve或reject函数只能触发一个,所以这里加了if条件判断状态是不是pending值。
我们还要给resolve或reject函数传参,传入的参数是要给接下来的then里的回调函数使用的,所以我们这里先在外部声明两个变量,用来保存所传入的参数:
前面已经说过,如果Promise里先抛出错误的话,是直接执行错误回调函数的,所以我们这里多加一步判断:
接下来就是来写then方法了,我们把then方法写在MyPromise的原型上。then方法里的两个参数分别代表成功回调和失败回调,触发哪个取决于MyPromise状态值的改变:
我们已经初步实现了Promise的功能,先来测试一下:
测试完成,没有问题,接下来我们就要继续完善MyPromise了。但在这之前,我们先要了解下Promise的执行顺序问题。
给你这段代码,你能分析出以下几个数字的输出顺序吗?
答案是1、2、5、3、4。我们已经知道了Promise里的函数代码是同步执行的,从输出结果我们能分析出,then里面的回调函数是异步执行的。那它的异步机制是否用setTimeout来实现的呢?我们再来测试以下:
我们在顶部创建了个setTimeout定时器并给它设置0毫秒,即等同步任务执行完后立即执行。可结果却显示它是最后一个执行的,这就说明then方法里的回调机制绝对不是依赖于setTimeout定时器的,不然在定时器中后面添加进来的代码,也要等顶部的定时器先执行完再执行。
在JS的任务队列(task queue)中,异步任务分为两种:宏任务和微任务。像Ajax、定时器里执行的函数、I/O ,UI rendering等都属于宏任务,而宏任务会被添加到宏任务的执行队列中等待执行。而像Promise的回调函、process.nextTick 、Object.observe 、MutationObserver等,都属于微任务,会被添加到微任务的执行队列中等待执行。在宏任务和微任务中,微任务队列中的任务具有优先执行权,不管宏任务队列中的任务是多早添加的,只要微任务队列中有任务等待执行,就会先执行微任务,再执行宏任务。
看到这里,你应该大概能明白为什么Promise的回调函数比定时器优先执行了吧,关于JS的事件循环机制(Event Loop),这里先暂时不展开,以后会单独写一篇文章来详细介绍这个机制。
我们也可以让Promise里的同步代码异步调用成功或失败回调:
现在我们回来测试第一个功能,自己写的MyPromise,是否可以在Promise里调用定时器异步调用then里的成功回调或失败回调:
很遗憾,结果不能成功调用then里的函数。因为在MyPromise里的定时器执行到rej()之前,then方法的代码就已经先解析执行完了(此刻状态值还是pending)。所以哪怕后来rej被成功触发修改了MyPromise的状态值,then方法也不会跟着状态值的改变继续响应去触发成功回调或失败回调了(代码顺序由原来的先修改状态再注册成功或失败回调变成了先注册了成功或失败回调再修改状态)。
原生的Promise是支持在Promise里异步调用成功或失败回调的,所以我们接着来完善它。
我们先在MyPromise里创建两个空数组,用来存放接下来then方法里即将注册的成功回调函数或失败回调函数:
接着在then方法执行时,如果判断状态值仍为pending,说明MyPromise构造函数里是异步调用成功或失败回调的,此刻我们就将then注册的成功或失败回调保存到对应的两个数组集中:
注意我们此时只是用外层一个函数包住它们,再将它们保存进数组,并没有立即执行它们(等到外层这个包装函数执行了它们才会被执行)。
然后接下来呢,等到MyPromise异步触发的res或rej执行时,我们就可以在这两个函数里执行在then里添加好的成功或失败回调函数集了:
我们现在来测试一下:
或许看到这里,你已经开始头晕了。这个异步操作相比之前的同步操作,不同的地方在于我们是在修改状态值的地方(也就是resolve和reject函数里)主动去触发then里面的成功或失败回调(因为此刻then先执行了,所以回调函数已被注册完毕),而不是在状态值修改后让then方法顺着代码执行顺序判断状态值的变化情况来触发成功或失败回调。
接下来我们要来实现then方法的链式操作,在Promise规范里明确指出,then方法执行后默认返回的是一个新的Promise对象,而不是原来的Promise对象,所以我们这里不能简单的在最后将this返回。我们要创建一个新的Promise对象,再将它返回:
接着先来处理同步操作情况,记得前面说过不管上一个then触发成功还是失败回调,下一个then还是触发成功回调吗?所以我们在返回的下一个Promise里对这两种状态都触发成功回调:
这里我将上一个then的返回结果用两个变量保存起来了,并作为下一个Promise对象执行成功或失败回调函数的参数。我们先来测试一下同步情况:
程序没有问题,接下来就要来处理异步操作的情况了。前面我们已经知道,异步操作的回调函数,是在MyPromise里判断状态值时执行的,所以这里接收的返回值,就不能写在then方法里了,我们把它写在全局对象上:
接着就是写返回的下一个Promise在异步操作下对回调函数的处理情况了,即然是异步操作,状态值就还是处于pending。由于我们不知道原来的Promise触发的是成功回调还是失败回调(不知道执行的是res还是rej),不能确定当前状态值是Fulfilled还是Rejected,即不能判断接下来会从哪个数组里执行函数。所以这里我们只能在两个数组中同时添加待执行的res函数来触发下一个成功回调函数:
看到这里,或许你已经一脸懵逼了…. 这里处理异步操作的逻辑看似简单,但确实挺绕的,你得明白在返回的下一个Promise中,它需要触发res或rej去修改状态值,才能执行then接下来的成功或失败回调函数。对于上面返回新的Promise这一步操作,其实还可以做更进一步的简化,接下来要看仔细了:
我们把原来上面那部分代码,直接放进了MyPromise构造函数里并执行。我们已经讲过,MyPromise里的代码是同步执行了,所以放进MyPromise函数并不会影响里面代码的正常执行,即不会影响旧的MyPromise的then方法的执行。同时我在每一个状态值判断中都加入了执行res函数指令,这样接下来的then还是会继续触发下一个成功回调。
不知道这种简化方式有没有让你好理解一些呢,还是觉得更复杂了?接下来的处理情况还会更复杂一些,所以如果这里卡住了,还是建议你反复琢磨琢磨。
我们前面已经讲过了,then方法里执行的任务都是异步的微任务,但我们这里没有使用JS微任务的权限,所以只能用定时器来对微任务的情况进行模拟了。
我们把then里执行函数都放到定时器中,模拟异步执行。 我们来稍微测试一下:
已经模拟它的异步执行了,不过要注意的是,这里用定时器模拟的宏任务异步执行,并不能真正代替微任务,我们如果在最上方先创建个定时器,那最上方定时器里的代码还是会优先执行(真正Promise的微任务会比定时器的宏任务先执行):
我们前面模拟了在Promise里抛出错误时执行then的失败回调函数,但我们还没模拟在then中抛出错误时执行下一个then的失败回调函数。这个其实很简单,我们还是用try-catch方法,在每次执行下一个then前先进行错误检测,如果抛出错误就执行rej函数触发下一个then的失败回调:
我们现在来测试一下:
测试完毕,四种情况都没问题。我们目前已经处理好then中异步执行、抛出错误的情况,原生的Promise对象调用的then,其实还可以不传任何参数:
程序依旧正常执行,但如果换成我们自己写的MyPromise,程序就出错了:
其实对于空then,里面省略的成功回调默认是一个函数返回接收到的上一个then的回调参数,里面省略的失败回调,默认会继续抛出上一个then抛出的错误,就像下面这样:
当然,这两个默认参数不是让你自己传的,而是在MyPromise原型的then方法上进行处理:
我们直接一开始就在then方法中进行判断,如果传入的某个参数为空,则默认让它等于对应的函数。接下来我们让then置空,函数也正常执行了:
讲完了空then,接下来就要讲关于then方法的最后一部分了,即then中的返回值是一个新的Promise的情况。我们先简单来回顾返回值是Promise的情况:
在返回的Promise中我们可以主动去控制接下来要执行成功还是失败回调。首先我们需要知道的一点是,在用then注册回调函数时,第二个then是给原来的oP对象默认返回的nextPromise对象(上面已经介绍过了)注册的回调函数,而不是给我们主动返回的Promise对象注册的回调函数。那主动返回的Promise对象和默认返回的nextPromise是怎么关联起来的呢?
之前我们所有返回值都是些字符串,现在我们来创建一个函数,用来统一处理返回值的所有情况:
这个函数我们接收四个参数:默认返回的下一个nextPromise对象、上一个回调函数的返回值、默认返回的nextPromise对象里触发成功回调的res函数和触发失败回调的rej函数。用于统一来处理要执行成功还是失败回调函数:
这里可能有人会疑惑,nextPromise对象是当前Promise执行完的返回结果,handleReturn函数接收得到nextPromise对象吗?其实是可以接收到的,因为我们执行handleReturn函数时是在setTimeout里面异步执行的,所以nextPromise会先于该函数执行前就被初始化。
然后,我们就在handelReturn函数里进行判断,当返回值不是一个Promise对象时,我们还是和原来一样,执行res去触发下一个then的成功回调:
接下来处理返回值是Promise的情况,我们首先一定要清楚的明白,主动去返回一个Promise然后触发的res或rej,不是接下来的then里的成功或失败回调,而是它本身的then的成功或失败回调,所以我们要自己给它注册then里的回调函数:
接着我们再在它里面间接调用nextPromise对象里的res或rej函数,来间接触发下一个nextPromise的then所注册的回调函数:
现在我们已经把返回值是Promise对象的情况也处理完毕了,我们现在来测试一下:
测试完毕,程序可以正确执行。
到目前为止,我们已经基本将Promise对象和它的then方法模拟完毕了,接下来还剩两个方法:Promise.all( [ .. ] ) 和 Promise.race( [ .. ] )。
Promise.race( [ .. ] )比较简单,我们首先来回顾一下它的使用方法:
它会捕捉传入race的数组中最先触发回调函数的那个函数,并且我们可以将函数上的参数主动传给res或rej函数作为then接收的参数。race方法的实现方式如下:
我们的race方法会返回一个新的Promise对象,同样设置res和rej参数用于执行接下来then方法里的回调。在该对象中我们遍历传入race的数组,让数组中每个返回的Promise对象都绑定上then方法,并且注册的成功回调和失败回调函数就是原来的Promise对象的res和rej函数。所以只有有任何一个先执行了它们自身的then方法里的回调函数,就会间接触发最外层的Promise对象去执行then方法里的回调函数。
测试一下,同样可以正常执行:
接下来来写all方法,先来回顾一下all方法:
对于触发失败回调的情况,all方法和race方法一样,只返回第一个失败回调所传的参数。而只有在数组里的所有返回结果都触发成功回调后,后面的then才会触发成功回调并把每个Promise触发成功回调所传的参数一并输出。而如果传入的是空数组,则默认执行then后面的成功回调。
同样的,all方法也是返回一个新的Promise对象。首先,如果传入all的数组是一个空数组,那我们就默认执行后面then中的失败回调:
接着还是一样给每个数组中返回的Promise对象绑定then方法,失败回调可以直接绑定为rej,因为all方法同样只要有一个触发失败回调就执行then中的失败回调。而成功回调的执行,需要等传入all方法里的数组的每一个返回值(也是Promise对象)都触发成功回调后才会执行,所以这里要写一个resCount用来判断是否全部触发了它们自身的成功回调函数:
我们来测试一下:
结果几乎没有任何问题,为什么说几乎呢?因为原生的Promise对象,不管all里传的数组参数触发的先后顺序,都会按照它们在数组中的位置将参数依次输出。而我们的MyPromise对象会根据它们触发的先后顺序依次输出:
要实现这个排序很简单,只要将每个参数绑定它们在传入all的数组中的位置索引最后再进行排序就好了,不过这个小细节我们就不去模拟实现啦。
至于剩下的catch方法和finally方法,这里也不再模拟了,关于ES6中Promise源码实现的讲解便到此结束,感谢收看。