由一道面试题想到的

前不久的一次面试,虽然和面试官聊的不多,但是其中一道关于事件循环的题目值得回味…

题目是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
new Promise((resolve, reject) => {
console.log(111)
setTimeout(() => {
console.log(222)
})
resolve(333)
}).then(res => {
setTimeout(() => {
console.log(res)
})
return 444
}).then(res => {
console.log(res)
throw Error(555)
}).catch(err => {
console.log(err)
return 666
}).then(res => {
console.log(res)
})

// 请说出它的正确执行顺序

闭上眼睛默默地在心里执行一遍…

正确的顺序是111,444,Err: 555,666,222,333 如果你一眼能看出它的输出顺序,那下面就不用看了…

首先我们抛开这道题,先来讲讲js的事件循环(Event Loop)。
由于js单线程的缘故,在执行代码时所有的代码都在一个线程上执行。然而,对于一些执行时间不确定的代码,如果采用同步的方式,会阻塞后续代码的执行。比如,在你发起一个同步请求之后,你后续代码里还有一些dom操作的代码。这种情况下,只有当请求结果返回后才能执行操作dom的代码,然而你并不能保证这个请求什么时候返回结果…

于是乎,为了避免类似情况的情况,引入了异步请求。同步代码往往伴随着阻塞,而异步代码随之而来的是非阻塞。
以http请求为例,发送一个获取数据的请求,数据返回的时间不一定。这时候我们可以把获取数据后的操作放在回调函数里。然而,即使是回调函数,在代码中有多个异步事件时,也要分个执行顺序。

js采用了任务队列的管理方式。任务队列分为宏任务和微任务。在执行时,一次事件循环会清空微任务队列,执行宏任务队列中最先入队列的回调函数。

所以,当js代码执行时,先执行主线程的代码,遇到异步请求时会将其挂起,接着执行主线程的代码,当异步请求返回结果后,会将回调函数放入任务队列中去。等到主线程上所有代码执行完,接着检查任务队列。首先检查微任务队列,执行微任务队列上的回调函数并清空队列,再检查宏任务队列,按照先进先出的顺序执行队列中的第一项,依次循环,直至任务队列清空。

一般,nextTick(node), Promise属于微任务,它们的回调函数会被放在微任务队列中。
而setTimeout, setInterval, setImmediate(node)属于宏任务,它们的回调函数会被放在宏任务队列中。

到这里,我们已经能够解答这道题了。具体来分析一下:

  1. 首先执行主线程上的代码,所以 111 被输出,并且new Promise中的setTimeout会被放入宏任务队列中,而第一个then对应的函数被放入微任务队列中,至此,主线程上的代码执行完毕
  2. 接着,检查微任务队列,发现了刚才加入的then的回调函数,然后执行。执行过后,回调函数里的setTimeout被放入宏任务队列中,此时宏任务队列中有两个
  3. 微任务队列的第一个任务执行完,返回了一个新的promise示例,并且已经被resolve,此时第二个then对应的回调函数进入微任务队列并开始执行,此时输出444。执行到第二行时,throw了一个error,这个error会被捕获到,并且返回一个新的promise示例,且已经被reject,此时当前函数执行完毕,同时下边的catch中的函数进入微任务队列中
  4. 接着执行catch所对应的函数。这里,”err: 555“ 被输出。接着返回了一个新的状态为resolve的promise的示例。执行过程类似,执行完最后一个then对应的函数,666 被输出,此时微任务队列再没有别的任务了
  5. 接着检查宏任务队列。根据之前的分析,此时宏任务队列中有两个setTimeout,先检查最先进入的那个setTimeout,如果已经到了执行时间(在这里,setTimeout默认是1),对应的回调函数会被执行,222 被输出;至此,本轮事件循环执行完毕;接着进行下一轮事件循环。在第二轮中,微任务队列为空,宏任务队列仅剩一个。当到达或者超过了了延迟执行之间后,该函数被执行,输出 333。至此,宏任务微任务队列都被清空。

当然,事件循环的东西远不止这些,还有很多…