Promise 与 JavaScript 的事件循环
之前去滴滴面试的时候面试官问:「有用到什么新的技术么?」,正好最近学 Promise 就恬不知耻地说了,没想到面试官突然在电脑上啪啪啪敲了一阵后把电脑一翻:
1 | // 代码是从记忆里扒出来的,可能与原题有出入 |
「写一下这段代码的输出吧。」
当时我就懵逼了,前阵子确实看到过 Promise 的事件循环,但是我为什么扫!一!眼!就!关!掉!了!网!页!
好吧,面试挂了就挂了,回来赶紧补补课——
PS: 文章内容是自己结合网上各个博客对 JavaScript 事件循环的理解,肯定会有或大或小的错误,如果你和我一样是菜鸡,请辩证地看待,不要全信!如果你是大牛,欢迎欢迎!敬请指正!让批评来得更猛烈些吧!
PPS: 以我的写作风格估计下面肯定又是长长一大坨,所以先在这里送上上面这道题的答案吧: 2 5 4 1 3
(根据 Promise 实现不同答案可能有出入,此处以 Promise 注册微任务(micro-task)的形式给出答案,当然,如果现在看不懂的话,那就接着看吧!)。
为什么要有事件循环?
虽然用了一道面试题做开篇,但实际上这篇文章想要梳理的是事件循环的问题,包括 Promise 和 setTimeout
等函数中的任务执行顺序。
为什么会出现这样的问题呢?这就要牵扯到 JavaScript 是单线程的这一点了,单线程可以避免出现多线程的同步、锁的问题,但是只有一个线程(后文称之为主线程)在执行,一旦出现定时器等场景该怎么办呢?现在有两种解决方案:
- 停下来等计时到了再执行后面的任务。这种方法被称为 同步,很明显这种形式非常不好,在这段时间内主线程会一直等待计时任务完成,而这段时间完全可以给主线程安排其它的任务。
不是需要进行计时么,为什么主线程不是执行计时任务,而是处于等待状态?
实际上计时器并非由 JavaScript 执行,我们说的「单线程」只是解析、执行 JavaScript 代码的线程为单线程,而实际中会有许多其它的线程,比如计时器线程、处理网络请求的线程、处理 DOM 事件的线程等等,此处举例的计时器便交付给计时器线程完成,主线程本身不执行计时任务。
- 先做后面的任务,有空了再确认一下计时任务是不是完成了,有没有需要在计时任务之后做的事情。这种方法被称为 异步,也就是无需当场确认结果,而是在之后通过其它的方式得到。一般会将结果作为回调函数的参数传入,主线程在执行回调函数时便「得到」了该结果。
回调函数就是异步么?
不是,回调函数是指「将函数传入,在当前函数执行时可以调用这个函数」的这个形式,只是异步将其作为了获取任务结果的一种方法,同步也是可以使用回调函数的:
1
2
3
4
5
6
7 const callback = () => console.log('我是个回调函数');
const syncTask = cb => {
console.log('同步任务开始');
cb();
console.log('同步任务结束');
};
syncTask(callback);
这里的
syncTask
便是一个同步任务,它传入了callback
并在执行期间「回调了」这个函数。
好,那现在我们决定好了要用异步的方式解决计时任务了,那么什么时候才叫「有空」呢?如果有多个异步任务返回结果了,该用什么方式执行呢?
这就是事件循环机制要做的了。
事件循环机制
这里先提一句,事件循环机制并非由 JavaScript 引擎提供,而是 JavaScript 运行环境提供的一种机制,emmmmm,我也只能理解到这里了,再往深就要懂更底层的东西了,比如 V8 引擎,Node 运行时等,我猜是编译原理的内容,希望能有大牛指点一二。下面讲的内容都是在 Node 运行时上的内容,但浏览器中应该也是相似的。
如前文述,事件循环是用来解决多个异步任务回调事件执行顺序的,现在只有一个主线程,但却有可能同时收到 Ajax,计时器,Promise 等多个回调的事件要做,而且在处理其中一些事件的过程中,还可能有事件源源不断地产生,这便需要循环往复地查看是否还有事件需要完成。
为了理解事件循环,我们可以将事件分为两个大类:微任务事件和宏任务事件,它们下面又可以分出一些小类,具体的等一下再说,我们先着眼于事件循环的执行过程。事件循环中维护了两条微任务队列与六条宏任务队列,每条队列会存储对应的不同类的事件,主线程便按照一定顺序去循环往复地执行这些队列,如图所示:
可以看到在执行完用户代码与微任务队列后便进入了事件循环,依据 Timer → Pending i/o callbacks → Idle, prepare → Poll → Check → Close callbacks 的宏队列顺序进行检查。
每一步宏任务队列的检查都会确认当前队列中是否有需要执行的事件,并将队列中的事件按顺序执行完毕,再以 nextTickQueue → microTaskQueue 的顺序检查两条微任务队列是否有事件并执行完毕。
所以实际上微任务的「优先级」是高于宏任务的,当前宏任务中如果产生了新的微任务事件,则在当前宏任务结束后就会执行,而不需要等到下一轮事件循环。
具体每个队列里分别对应什么事件呢?
我觉得这个并不影响对事件循环的理解,所以就放在这里吧:
- 微任务
- nextTickQueue: process.nextTick
- microTaskQueue: 其它微任务
- 宏任务
- Timer: setTimeout, setInterval 等
- Pending i/o callbacks: 除了 timers, setImmediate 及 close 之外的大多数回调方法
- Idle, prepare: 只在内部调用
- Poll: 检查是否有新的 I/O 事件
- Check: setImmediate
- Close: socket.on(‘close’, …) 等
更详细的可以参考 详解“Node.js环境”中的event loop机制 - 掘金 等其它博客,或者直接查看 Node.js 文档。
setTimeout 和 setImmediate 的执行情况不能简单按这里给的任务分类推断,有兴趣可以看看 setImmediate() vs nextTick() vs setTimeout(fn,0) – in depth explanation | Void Canvas 这篇文章,另外 JavaScript运行机制深入浅出学习 - 掘金 这篇文章中也有简要叙述(我就是喜欢看中文的嘿)。
Promise 的执行顺序
好了,事件循环知道得差不多了,但为什么微任务和宏任务队列分类里没有 Promise 呢?这就要关系到 Promise 的执行顺序问题。
还是先甩个例子吧:
1 | new Promise(function fn1(resolve, reject) { |
当 Promise 被构造时即会执行其构造函数中传入的函数,也就是说例子中的 fn1
函数本身是在当前线程 同步 执行的。那执行 resolve()
时是否就是执行 then
中的函数呢?非也,resolve()
会将当前 Promise 实例设置成 onFulfilled
状态,并将参数记录下来在调用 then
中的函数时执行。
那构建完成了 Promise 实例之后呢,立即执行 then
中的函数么?非也非也,如果这样的话我前面还叽叽喳喳事件循环干什么呢?实际上 then
中的函数会被添加到任务队列中。
那到底是宏队列还是微队列呢?别卖关子啊!
好吧,其实不是我卖关子,是 Promise 规范中并未规定啊:
In practice, this requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack. This can be implemented with either a “macro-task” mechanism such as setTimeout or setImmediate, or with a “micro-task” mechanism such as MutationObserver or process.nextTick.
实践中要确保 onFulfilled 和 onRejected 方法异步执行,且应该在 then 方法被调用的那一轮事件循环之后的新执行栈中执行。这个事件队列可以采用“宏任务(macro-task)”机制或者“微任务(micro-task)”机制来实现。
所以 Promise 的结果还是要看 JavaScript 执行环境的实现了,这一小节的例子因为只有一个事件,所以结果并不会因为宏任务或微任务而改变,都是:
1 | 1. resolve 之前 |
小结一下 Promise 的执行便是:
- 构造函数内传入的函数会在主线程执行时同步执行,遇到
resolve/reject
函数(函数名只是示例,实际上是传入函数的前两个参数)时更改 Promise 状态; - 根据执行环境将 Promise 的
then/catch
内的函数加入相应任务队列,由事件循环机制管理。
回到最初的练习题
现在可以来回顾一下最初的题目了,我知道翻回去很累,就在这再贴一次 占字数:
1 | setTimeout(function(){ |
如果 Promise 的 then
方法将事件注册进微任务队列的话,执行顺序如下:
setTimeout
的回调放入宏任务队列,Promise 实例构造过程中输出2
,当i==99
时设置该 Promise 为 onFulfilled 状态,把then
里的回调放入微任务队列,Promise 中的setTimeout
回调也放入宏任务队列,最后输出5
;- 检查微任务队列,输出
4
; - 检查宏任务队列,依序输出
1
、3
。