从一道面试题来理解JS事件循环

  上周一个朋友发了某互联网公司的笔试题给我看,其中有一道题比较有意思,考察了对JS事件循环的理解,所以故事的开始让我们从一道复杂的面试题开始。。。

一道面试题

  说出下面代码的运行结果,并说明原因:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async function async1(){
console.log('async1 start')
await async2()
console.log('async1 end')
}

async function async2(){
console.log('async2')
}

console.log('script start')

setTimeout(function(){
console.log('setTimeOut')
}, 0)

async1()

new Promise(function(resolve){
console.log('promise1')
resolve()
}).then(function(){
console.log('promise2')
})

console.log('script end')

  先贴一下在浏览器里的运行的结果(如果跟你的思路一模一样的话,大佬请直接Ctrl+F4):

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
//script start
//async1 start
//async2
//promise1
//script end
//async1 end
//promise2
//setTimeOut

  如果跟你的思路不一样的话也不用担心,我们从简单的开始一点点剖析这道面试题。

单线程

  首先我们都知道,JavaScript是一门单线程的语言,所谓单线程指的是在JavaScript引擎中负责解释和执行代码的线程只有一个,通常称为主线程。那么为什么JavaScript必须是单线程的语言,而不能像他的老大哥Java一样,手动开启多个线程呢?

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  因为这是由于JavaScript所运行的浏览器环境决定,他只能是单线程的。试想一下,如果JavaScript能开启多个线程,页面上有一个div,我们同时在多个线程中来改变这个div中的内容,那么最终这个div会变成什么样子谁也确定不了,最后只能听天由命,看哪个线程是最后一个运行结束的。

uncertain.jpg

  因此多线程带来了很多的不确定性,为了避免这种问题,JavaScript必须是单线程。

  可能有的同学又会说了,JavaScript不是可以通过Web Worker开启多线程么?是的,Web Worker是可以开启另一个线程,但是这个新开线程的功能被限制了,只能做一些消耗CPU的逻辑运算等,数据传输也是通过回调的方式来进行,不会阻塞主线程的执行;而且最最重要的是,Web Worker不能来操作dom,笔者经过尝试发现,在新开的线程中甚至都不能获取到document和window对象。

  所以还是没有改变JavaScript是单线程运行这一核心原则。当然,虽然JavaScript是单线程运行的,但是还是存在其他线程的;例如:处理Ajax请求的线程、定时器的线程、读写文件的线程(nodejs中)等。

同步任务和异步任务

  因为JavaScript是单线程运行的,所有的任务只能在主线程上排队执行;但是如果某个任务特别耗时,比如Ajax请求一个接口,可能1s返回结果,也可能10s才返回,有很多的不确定因素(网络延迟等);如果这些任务也放到主线程中去,那么会阻塞浏览器(用户除了等,不能进行其他操作)。

  于是,浏览器就把这些任务分派到异步任务队列中去,并且跟他们说:你们自己去后台玩儿,等你们好了再过来通知我!先来看简单的例子来理解一下同步和异步任务:

1
2
3
4
5
6
7
console.log('start')

setTimeout(function() {
console.log('setTimeout')
}, 0)

console.log('end')

  当主线程执行到setTimeout的时候,虽然是延迟了0s,但是并不会马上来运行,而是放到异步任务队列中,等下面的同步任务队列执行完了,再来执行异步队列中的任务,所以运行结果是:start、end、setTimeout。

  但如果同步任务中有特别耗时的操作,阻塞了setTimeout的定时执行,那么setTimeout就不会按时来完成。来看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('start')
console.time('now')
let list = []

setTimeout(function() {
console.timeEnd('now')
}, 1000)


for(let i = 0;i<9999999;i++){
let now = new Date()
list.push(i)
}

  虽然我们让setTimeout1s后执行,但是for循环占用了太多的线程资源,实际执行会在2s后。所以事件循环的流程大致如下:

  1. 所有任务都在主线程上执行,形成一个执行栈。
  2. 主线程发现有异步任务,就在“任务队列”之中加入一个任务事件。
  3. 一旦“执行栈”中的所有同步任务执行完毕,系统就会读取“任务队列”(先进先出原则)。那些对应的异步任务,结束等待状态,进入执行栈并开始执行。
  4. 主线程不断重复上面的第三步,这样的一个循环称为事件循环。
  5. 谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

宏任务与微任务

  如果任务队列中有多个异步任务,那么先执行哪个任务呢?于是在异步任务中,也进行了等级划分,分为宏任务(macrotask)和微任务(microtask);不同的API注册的任务会依次进入自身对应的队列中,然后等待事件循环将它们依次压入执行栈中执行。

  宏任务包括:

  • script(整体代码)
  • setTimeout, setInterval, setImmediate,
  • I/O
  • UI rendering

  微任务包括:

  我们可以把整体的JS代码也看成是一个宏任务,主线程也是从宏任务开始的。我们把上面事件循环的步骤更新一下:

  1. 执行一个宏任务
  2. 执行过程中如果遇到微任务就加入微任务队列,遇到宏任务就加入宏任务队列
  3. 宏任务执行完毕后,检查当前微任务队列,如果有,就依次执行(一轮事件循环结束)
  4. 开始下一个宏任务

task-job.png

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  让我们来看一个例子:

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

setTimeout(function() {
console.log('timeout');
}, 0)

new Promise(function(resolve) {
console.log('promise');
//注意这边调用resolve
//不然then方法不会执行
resolve()
}).then(function() {
console.log('then');
})

console.log('end');

  分析一下执行流程:

  • 刚开始打印start
  • 遇到setTimeout,放入宏任务中,等待执行
  • 遇到new Promise的回调函数,同步执行,打印promise
  • 当resolve后,then方法会放入微任务,等待执行
  • 打印end,这时整个执行栈清空了,宏任务和微任务队列各有一个回调方法
  • 先执行微任务队列,打印then
  • 执行宏任务,打印timeout

  我们把Promise进行一下改变,看一下下面的例子:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
10
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
async1()
console.log('script end')

  刚开始我们会想当然的认为执行顺序是:async1 start –> async2 –> async1 end –> script end。但是当真正理解了async函数的本质后,我们知道async函数还是基于Promise的一些封装,而Promise是属于微任务的一种;因此会把await async2()后面的所有代码放到Promise的then回调函数中去,因此,如果把上面代码进行如下改写,会好理解很多:

1
2
3
4
5
6
7
8
9
10
11
async function async1() {
console.log('async1 start')
new Promise(function(resolve){
console.log('async2')
resolve()
}).then(function(){
console.log('async1 end')
})
}
async1()
console.log('script end')

  根据上面对微任务的理解,console.log('async1 end')会放到微任务队列中,所以实际执行顺序是:async1 start –> async2 –> script end –> async1 end

  最后来看那道面试题,相信已经不难理解了。

  1. 第一轮循环开始
  2. 打印script start
  3. 发现setTimeout,放入宏任务1
  4. 打印async1 start
  5. 打印async2
  6. 谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  7. 把await async2函数后面的回调放入微任务1
  8. 打印promise1
  9. 把then中的函数放入微任务2
  10. 打印script end
  11. 调用栈清空,开始执行微任务1,打印async1 end
  12. 执行微任务2,打印promise2
  13. 微任务执行完,第一轮循环结束
  14. 开始宏任务1,打印setTimeOut
  15. 结束,完美撒花

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。