「高频面试题」女友:消息队列 和 事件循环系统终于弄明白了!(内附思维导图)

4,368 阅读22分钟

前言

最近又和女友,咳咳…(说出来可能又会被打s)学习事件循环,这不,学会(废)了之后,赶紧写一篇博客复盘总结一下~

接上一期「数组方法」写给女友的一系列 JS 数组操作(建议收藏 | 内附思维导图) 文章发出去之后,有些小伙伴还真发给自己女友了,不知道“感动”了没有哈(手动滑稽)。

那么,这次女友直接说明白了,那么我就从“头”开始讲讲事件循环系统,通过一篇文章搞定这一块知识点。

事件循环非常底层且非常重要,学会它能让你理解页面到底是如何运行的。

话说女友会点开这篇文章么?

我“啪”地一下就醒过来了,啊这,这...这还重要吗?

(手机端可能看不清)获取高清PDF,请在微信公众号【小狮子前端Vue】回复【事件循环】

另外,可能小狮子们会问到我的作图工具是啥,这里先提前告诉大家(考虑的这么周到,这还不来点个赞支持一下?)

作图工具:excalidraw

此外,我们可以通过使用 Loupe ( Loupe是一种可视化工具,可以帮助您了解JavaScript的调用堆栈/事件循环/回调队列如何相互影响)工具来了解代码的执行情况

Loupe 可视化工具

阅读须知

关于消息队列和事件循环这一块,不仅底层而且面试经常性问到,答的一知半解,对于整场面试而言也许会降低很多期望分。

今年春招乃至秋招,博主也被这些问题问到,可能不会直接问你相关概念,一般情况下就是给你一段代码,然后问你输出结果,得到输出结果后再让你解释其中奥妙。如果能迅速正确地解答,对于整场面试而言的话是一个高潮加分点。

好了,让我们快速进入正文~

问题引入

在阅读本篇文章之前,先推荐讲解 event loop 非常好的视频,看完视频后再配合本文将会更佳哈~

在正式介绍事件循环系统之前,先来看看在WHATWG 规范中是怎么定义事件循环机制的,大致流程如下:

  • 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask
  • 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务
  • 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask
  • 最后统计执行完成的时长等信息

以上就是消息队列宏任务的执行过程。

显然,这就包含了一些专有名词,别担心,后文我会逐一介绍,那我们就带着问题愉快地阅读本文吧~(觉得不错,三连支持一下哈)

单线程处理安排好的任务

既然要从“头”开始讲解,那么我们就从比较简单的场景开始啦,比如在大学里面我们会举行班级活动,假设是班主任要求举行一次关于 职业规划与梦想相关的主题班会(老班委了hh),而班长就会和班委们集思广益一起探讨相关任务。

  • 任务 1:确定主题名称 我们的征途是星辰大海
  • 任务 2:写好活动策划书,班委各司其职
  • 任务 3:采购物资与班会地点安排
  • 任务 4:进行复盘总结

而对于单线程处理任务就类似上述活动安排,班主任提出需求,此时线程开始了,班长和班委们一起分配任务,将任务按照顺序依次执行,等所有任务执行完了,此次班会圆满结束,线程会自动退出。可以参考下图来直观地理解下其执行过程:

出现问题

然而,班委们尤其是新任班委,并不一定会将任务一开始就安排的很好,可能在执行的过程中某个班委又提出了新的任务,那么上述情况可能就不太好解决了。

引入循环机制

那么,要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制。

既然有新的任务需要采纳,那么我们就加一个 for 循环嘛,这样线程就会一直循环执行了。

处理其它线程发送过来的任务

在上文中,我们提到了班委完成任务,通过引入循环机制,解决任务并不一定事先就确定好的问题。但是这些任务都是来自于班委组织内部的(即线程内部),如果另外一个线程想让主线程执行一个任务,上文可能又没办法解决了,就比如班委组织活动,但是班级里积极性比较高的非班委同学也想来参与,也提出相关任务安排,这又该怎么解决呢?

下面我们就来看看其他线程是如何发送消息给渲染主线程的,具体形式你可以参考下图:

从上图可以看出,渲染主线程会频繁接收到来自于 IO 线程 的一些任务,接收到这些任务之后,渲染进程就需要着手处理,比如接收到资源加载完成的消息后,渲染进程就要着手进行 DOM 解析了;接收到鼠标点击的消息后,渲染主线程就要开始执行相应的 JavaScript 脚本来处理该点击事件。

那么如何设计好一个线程模型,能让其能够接收其它线程发送的消息呢?

作为班长,收到班级内同学的积极反馈,当然需要采纳意见与想法,一个不错的方式是将这些任务 “存” 起来,我们吃饭都还要排队,不妨将这些任务也存起来,依次排队。

引入消息队列

上述比较好的解决方式就是引入消息队列。熟悉队列的小狮子们应该很好理解,消息队列是一种数据结构,可以存放要执行的任务。它符合队列 “先进先出” 的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取

一图胜千言,有了消息队列机制后,我们就可以改造一下之前的模型了,如下图所示:

处理其它进程发送过来的任务

上文我们解决了非班委内部(即班级内积极的同学)提出的任务,不过有可能我们班级活动会有联谊呢,即可能其它班级也会参与这次活动策划,此时又要麻烦班长大人呐~

那么我们又要怎么解决呢?可以直接参考下图:

从图中可以看出,渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就与上文一样了,这里就不再赘述了。

消息队列中的任务类型

到此,通过图示和班委任务安排故事,你已经知道了主线程是如何接受外部任务的了,接下来我们看看消息队列中的任务类型有哪些。这里面包含了很多内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。

除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。

以上这些事件都是在主线程中执行的,所以在编写 Web 应用时,你还需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题。

如何安全退出

确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。如果设置了,那么就直接中断当前的所有任务,退出线程。


带你了解WebAPI:setTimeout

一提到事件循环,我想小狮子们应该就会联想到 setTimeout,想必都不会陌生,它就是一个定时器用来指定某个函数在多少毫秒之后执行。它会返回一个整数,表示定时器的编号,同时你还可以通过该编号来取消这个定时器。

通过上文,我们知道渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。

举点例子吧,当接收到 HTML 文档数据,渲染引擎就会将 “解析 DOM” 事件添加到消息队列中;当触发了 JavaScript 引擎垃圾回收机制,渲染引擎会将 “垃圾回收” 任务添加到消息队列中。

同样,如果要执行一段异步 JavaScript 代码,也是需要将执行任务添加到消息队列中。

不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,因此,不能将定时器的回调函数直接添加到消息队列中。于是 Chrome 又维护了一个另外一个消息队列,用来存放延迟执行的任务列表。(这里我就叫做 延迟队列 了)

那么,我们就知道了,对于定时器而言,它会等待主线程消息队列处理完之后,才会去拿延迟队列里面的任务,这也就是为什么尽管定时器设置了倒计时,然而实际上并不一定是在这个时间后立即执行了。

使用 setTimeout 的一些注意事项

上文带小狮子们了解了 setTimeout ,那么在使用它的时候有哪些需要注意的嘛?接下来一起来看看吧:

1. 如果当前任务执行时间过久,会影响定时器任务的执行

对于消息队列,如果任务执行时间过久,那么延迟队列里面的任务一直不会处理,势必会影响定时器任务的执行。

2. 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒

例如下述代码,我们进行了嵌套调用:

function cb() { setTimeout(cb, 0); }
setTimeout(cb, 0);

Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。

3. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒

未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量

4. 延时执行时间有最大值

还有一点需要注意下,那就是 Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行。

例如下述代码:

function showName(){
  console.log("小狮子前端Vue")
}
var timeId = setTimeout(showName,2147483648);//超过最大值,调用执行

5. 使用 setTimeout 设置的回调函数中的 this 不符合直觉

如果被 setTimeout 推迟执行的回调函数是某个对象的方法,那么该方法中的 this 关键字将指向全局环境,而不是定义时所在的那个对象。

这个与作用域相关,后期我也会出一篇关于 this 指向的文章,感兴趣的小狮子们可以持续关注哈~

var name= 1;
var myObj= {
  name: 2,
  showName: function(){
    console.log(this.name);
  }
}
setTimeout(myObj.showName,1000) // 输出1

输出结果是 1,因为这段代码在编译的时候,执行上下文中的 this 会被设置为全局 window,如果是严格模式,会被设置为 undefined

怎么解决上述 this 指向问题呢?

比较好的一种解决方式就是通过 bind 改变一下 this 指向,如下:

setTimeout(myObj.showName.bind(myObj), 1000)

宏任务 和 微任务 你又知多少

先来介绍宏任务

上文我们有介绍过,页面中大部分任务都在 主线程 上执行,包括:

  • 渲染事件(如解析 DOM、计算布局、绘制)
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
  • JavaScript 脚本执行事件
  • 网络请求完成、文件读写完成事件

具体来讲的话就是如下几种类型:

  • script(整体代码)
  • setTimeout()
  • setInterval()
  • postMessage
  • I/O
  • UI交互事件

为了协调这些任务有条不紊地在主线程上执行,页面进程引入了 消息队列事件循环机制 ,渲染进程内部会维护多个消息队列,比如延迟执行队列普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为 宏任务

宏任务有什么不足点

上文我们介绍过,页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置

那么,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间

例如我们想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不插入其他的任务,避免影响第二个定时器的执行时间。

通过 Performance 工具,可以看到任务的执行过程,如下图所示:

setTimeout 函数触发的回调函数都是宏任务,如图中,左右两个黄色块就是 setTimeout 触发的两个定时器任务。

观察上图中间浅红色区域,这里有很多一段一段的任务,这些是被渲染引擎插在两个定时器任务中间的任务。

试想一下,如果中间被插入的任务执行时间过久的话,那么就会影响到后面任务的执行了。

因此,宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了。

那么,为了解决实时性的问题,我们就需要了解微任务了。

再来介绍微任务

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

可以看到 Macrotask (宏任务)也就是回调队列上面还有一个 Microtask(微任务)

Microtask(微任务)虽然是队列,但并不是一个一个放入执行栈,而是当执行栈清空,会执行全部 Microtask(微任务)队列中的任务,最后才是取回调队列的第一个 Macrotask (宏任务)

可能到目前为止,你对微任务还不是很理解,没关系,接下来我们会逐一讲解,然后通过例题和一些动画让你加深巩固。

从V8引擎层面来看微任务

当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个 微任务队列。顾名思义,这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给 V8 引擎内部使用的,所以你是无法通过 JavaScript 直接访问的

那么,也就是每个宏任务都关联一个微任务队列,那么微任务产生时机和执行时机又分别是什么呢?我们接下来一起来看看:

微任务的产生时机

在现代浏览器里面,产生微任务有两种方式:

第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。

第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

通过 DOM 节点变化产生的微任务或者使用 Promise 产生的微任务都会被 JavaScript 引擎按照顺序保存到微任务队列中。

微任务何时被执行

通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为 检查点

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说 在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行

结合下面两张图示,应该就能直观理解微任务了: 该示意图是在执行一个 ParseHTML 的宏任务,在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。从图中可以看到,全局上下文中包含了微任务列表。

在 JavaScript 脚本的后续执行过程中,分别通过 PromiseremoveChild 创建了两个微任务,并被添加到微任务列表中。接着 JavaScript 执行结束,准备退出全局执行上下文,这时候就到了检查点了,JavaScript 引擎会检查微任务列表,发现微任务列表中有微任务,那么接下来,依次执行这两个微任务。等微任务队列清空之后,就退出全局执行上下文。

宏任务和微任务关系(总结)

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列

  • 宏任务和微任务之间的关系。 因为在微任务中产生的宏任务也是要插入到消息队列或者是延迟队列的末尾的,这肯定是需要下一次事件循环才有可能被执行的,而微任务在这一次的事件循环之前就会被执行。 因此,无论什么情况下,微任务都早于宏任务执行。

  • 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。所以你在写代码的时候一定要注意控制微任务的执行时长。


通过动画与例题加深巩固

上文,我们花了很长篇幅来介绍宏任务和微任务,大佬或许已经明白了离开了本页面,但是我倒觉得本篇最有价值的部分就在这了(赶快给阅读到这里的自己鼓个掌~)

ok,现在我们正式来做一做例题,面试的时候关于事件循环这一块,面试官一般也会这样考察,GO!

例题一

下面这一道题算是比较基本的题目了,可以先不看下面动画效果,自己测一测,看看预期结果与实际结果有没有差别~

console.log('start')

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

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

console.log('end')

解答

  • 全局代码压入执行栈执行,输出 start
  • setTimeout压入 macrotask 队列,promise.then 回调放入 microtask队列,最后执行 console.log('end'),输出 end
  • 调用栈中的代码执行完成(全局代码属于宏任务),接下来开始执行微任务队列中的代码,执行promise回调,输出 promise1, promise回调函数默认返回 undefined, promise状态变成 fulfilled ,触发接下来的 then 回调,继续压入 microtask队列,此时产生了新的微任务,会接着把当前的微任务队列执行完,此时执行第二个 promise.then回调,输出 promise2
  • 此时,microtask 队列 已清空,接下来会会执行 UI 渲染工作(如果有的话),然后开始下一轮 event loop, 执行 setTimeout的回调,输出 setTimeout

最后结果如下:

start
end
promise1
promise2
setTimeout

例题二

下面这道题是特别特别经典的一道题了!

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');

解答

总体思路就是:先执行宏任务(当前代码块也算是宏任务),然后执行当前宏任务产生的微任务,然后接着执行宏任务

  • 从上往下执行代码,先执行同步代码,输出 script start
  • 遇到 setTimeout ,现把 setTimeout 的代码放到宏任务队列中
  • 执行 async1(),输出 async1 start, 然后执行 async2(), 输出 async2,把 async2() 后面的代码 console.log('async1 end')放到微任务队列中
  • 接着往下执行,输出 promise1,把 .then() 放到微任务队列中;注意 Promise 本身是同步的立即执行函数,.then是异步执行函数
  • 接着往下执行, 输出 script end。同步代码(同时也是宏任务)执行完成,接下来开始执行刚才放到微任务中的代码
  • 依次执行微任务中的代码,依次输出 async1 endpromise2, 微任务中的代码执行完成后,开始执行宏任务中的代码,输出 setTimeout

最后结果如下:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

例题三

再来一道好题,这道题跟上面题目不同之处在于,执行代码会产生很多个宏任务,每个宏任务中又会产生微任务

console.log('start');
setTimeout(() => {
    console.log('children2');
    Promise.resolve().then(() => {
        console.log('children3');
    })
}, 0);

new Promise(function(resolve, reject) {
    console.log('children4');
    setTimeout(function() {
        console.log('children5');
        resolve('children6')
    }, 0)
}).then((res) => {
    console.log('children7');
    setTimeout(() => {
        console.log(res);
    }, 0)
})

解答

  • 从上往下执行代码,先执行同步代码,输出 start
  • 遇到setTimeout,先把 setTimeout 的代码放到宏任务队列①中
  • 接着往下执行,输出 children4, 遇到setTimeout,先把 setTimeout 的代码放到宏任务队列②中,此时.then并不会被放到微任务队列中,因为 resolve 是放到 setTimeout中执行的
  • 代码执行完成之后,会查找微任务队列中的事件,发现并没有,于是开始执行宏任务①,即第一个 setTimeout , 输出 children2,此时,会把 Promise.resolve().then放到微任务队列中。
  • 宏任务①中的代码执行完成后,会查找微任务队列,于是输出 children3;然后开始执行宏任务②,即第二个 setTimeout,输出 children5,此时将.then放到微任务队列中。
  • 宏任务②中的代码执行完成后,会查找微任务队列,于是输出 children7,遇到 setTimeout,放到宏任务队列中。此时微任务执行完成,开始执行宏任务,输出 children6

最后结果如下:

start
children4
children2
children3
children5
children7
children6

本文参考

最后

文章产出不易,还望各位小伙伴们支持一波!

往期精选:

小狮子前端の笔记仓库

leetcode-javascript:LeetCode 力扣的 JavaScript 解题仓库,前端刷题路线(思维导图)

小伙伴们可以在Issues中提交自己的解题代码,🤝 欢迎Contributing,可打卡刷题,Give a ⭐️ if this project helped you!

访问超逸の博客,方便小伙伴阅读玩耍~

学如逆水行舟,不进则退