EventLoop时间循环解析
Gwolf

写在前面

浏览器中的事件循环也是老生常谈的只是点了,今天来扒一扒浏览器中的事件循环机制。

来看一道题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
console.log(1)

setTimeout(function() {
console.log(2)
})

new Promise(function (resolve) {
console.log(3)
resolve()
}).then(function () {
console.log(4)
}).then(function() {
console.log(5)
})

console.log(6)

上述代码的输出结果是什么?

答案是:1、3、6、4、5、2

如果能够准确给出上面的回答,那么你的事件循环基础很扎实,但是究竟是因为什么机制导致的这个执行顺序呢?接下来会一一剖析:

Event-Loop 解析

关键组成分析

在浏览器的事件循环中,首先要认清楚 3 个角色:函数调用栈、宏任务(macro-task)队列和微任务(micro-task)队列。

所谓函数调用栈即:当引擎第一次遇到 JS 代码时,会产生一个全局执行上下文并压入调用栈。后面每遇到一个函数调用,就会往栈中压入一个新的函数上下文。JS引擎会执行栈顶的函数,执行完毕后,弹出对应的上下文:

一句话:如果你有一坨需要被执行的逻辑,它首先需要被推入函数调用栈,后续才能被执行。函数调用栈是个干活的地方,它会真刀真枪地给你执行任务。

那么什么是宏任务队列、微任务队列呢?

JS 的特性就是单线程+异步。在JS中,有一些任务,比如说上面塞进 setTimeout 里那个任务,再比如说你在 Promise 里面塞进 then 里面那个任务——这些任务是异步的,它们不需要立刻被执行,所以它们在刚刚被派发的时候,并不具备进入调用栈的“资格”。

这暂时没资格咋整呢?只能排队了。
于是这些待执行的任务,按照一定的规则,乖乖排起长队,等待着被推入调用栈的时刻到来——这个队列,就叫做“任务队列”。

所谓“宏任务”与“微任务”,是对任务的进一步细分。
常见的 macro-task 比如: setTimeout、setInterval、 setImmediate、 script(整体代码)、I/O 操作等。

常见的 micro-task 比如: process.nextTick、Promise、MutationObserver 等

注意:script(整体代码)它也是一个宏任务;此外,宏任务中的 setImmediate、微任务中的 process.nextTick 这些都是 Node 独有的。

循环过程

基于对 micro 和 macro 的认知,来走一遍完整的事件循环过程。

一个完整的 Event Loop 过程,可以概括为以下阶段:

执行并出队一个 macro-task。注意如果是初始状态:调用栈空。micro 队列空,macro 队列里有且只有一个 script 脚本(整体代码)。这时首先执行并出队的就是 script 脚本;
全局上下文(script 标签)被推入调用栈,同步代码执行。在执行的过程中,通过对一些接口的调用,可以产生新的 macro-task 与 micro-task,它们会分别被推入各自的任务队列里。这个过程本质上是队列的 macro-task 的执行和出队的过程;
上一步出队的是一个 macro-task,这一步处理的是 micro-task。但需要注意的是:当 macro-task 出队时,任务是一个一个执行的;而 micro-task 出队时,任务是一队一队执行的。因此,处理 micro 队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空;
执行渲染操作,更新界面;
检查是否存在 Web worker 任务,如果有,则对其进行处理。

做题,一步步解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
console.log(1)

setTimeout(function() {
console.log(2)
})

new Promise(function (resolve) {
console.log(3)
resolve()
}).then(function () {
console.log(4)
}).then(function() {
console.log(5)
})

console.log(6)

首先被推入调用栈的是全局上下文,也可以理解为是 script 脚本作为一个宏任务进入了调用栈,这个动作同时创建了全局上下文;与此同时,宏任务队列被清空,微任务队列暂时还是空的:

全局代码开始执行,跑通了第一个console:

1
console.log(1)

此时输出1。

接下来,执行到 setTimeout 这句,一个宏任务被派发了,宏任务队列里多了一个:

再往下走,遇到了一个 new Promise。大家知道,Promise 构造函数中函数体的代码都是立即执行的,所以这部分逻辑执行了:

1
2
console.log(3)
resolve()

第一步输出了3,第二步敲定了 Promise 的状态为 Fullfilled,成功把 then 方法中对应的两个任务依次推入了微任务队列:

再往下走,就走到了全局代码的最后一句:

1
console.log(6)

这一步输出了6,script脚本中的同步代码就执行完了。不过大家注意,全局上下文并不会因此消失——它与页面本身共存亡。接下来,咱们就开始往调用栈里推异步任务了。本着“一个 macro,一队micro”的原则,咱们现在需要处理的是微任务队列里的所有任务:

首先登场的是 then 中注册的第一个回调,这个回调会输出4:

1
2
3
function () {
console.log(4)
}

接着处理第二个回调:

这个回调会输出5:

1
2
3
function () {
console.log(5)
}

如此一来,微任务队列就被清空了:

重新把目光放在宏任务队列上,将其队列头部的一个任务入栈:

对应的回调执行,输出2:

1
2
3
function() {
console.log(2)
}

执行完毕后,就结束了所有任务的处理,两个任务队列都空掉了:

此时,只剩下一个全局上下文,待你关闭标签页后,它也会跟着被销毁。