又双叒叕聊到了浏览器的事件循环

186 阅读8分钟

遥想当年,我刚出道的时候。。。。。
啊,,不是,出戏了,是当年刚做牛马的时候。浏览器的事件循环还是一道很火的面试题。那时的我,每次都被这个题问的是哑口无言。事后都会后悔不已,想着回去了一定给这个东西研究明白,下次给面试官说的哑口无言。然鹅,,理想很丰满,现实很骨感。那既然嘴笨说不出口,就写下来吧。
话说回来聊这个字,其实也好几年没有面试了,属实是有贼心没贼胆了。所以说,真的跟面试官聊---那倒没有。但是,写文章也算是聊嘛。跟谁聊不是聊呢,对吧。
好,接下来,进入正题
哦对了,还有一句话,为了 尽我可能解释清楚这玩意到底是啥,文章可能有点啰嗦。想看面试回答的,可以跳到最后。Thanks♪(・ω・)ノ。


首先要了解的,当然是浏览器为什么要搞一个事件循环

这个可就说来话长了,话说那时宇宙未开,天地之间鸿蒙一片。。。咳咳,扯远了。
首先我们要了解一下浏览器的设计,浏览器是一个多进程多线程的软件,每当我们打开一个新的标签页,就会开启一个新的进程。为什么我说标签页就是一个进程呢,请看VCR, 不对,请看下图: 1730773217654.jpg
好,现在我们知道标签页就是一个进程了,得有线程来干活呀,当组长了不能当光杆司令呀。
那领导说了,你组长咋啦,比别人少啥了,你也得给我干活!!!
于是标签页就给自己创建了一个渲染主线程来干活。这可给他忙坏了啊。什么解析HTMLDOM树,什么解析css形成渲染树,还得执行js,还得渲染页面等等等等。总之一句话:忙啊!!!(通义千问还挺好的,给他开了个worker线程来辅助)
但是就这么忙了,还有人不图他好,还给他使绊子。(如果使用同步代码,会遇到什么问题,所有情景均属夸张)
情景一: 这天啊,就有个人来处理任务了,他要发个请求给服务端,然后拿到结果之后再处理一下页面。本来以为很快的一件事,结果硬生生给这家伙拖了一个小时,给主线程急的啊,边打游戏边流汗啊。(长任务等待,白白浪费资源)
情景二: 又有一天,主线程正忙着呢,领导突然来了,说这有个事,很紧急,你必须先给我处理这个事。这给主线程为难的啊,左右为难啊,就差喊陈平安了。(任务调度问题)

这发现了问题,就得解决问题呀,主线程就说了,你们,都给我去排队,谁都别想插队,我给你们划分了一个排队区(消息队列),都在这排队,我挨个处理。哎,这就是我们所说的事件循环(Event Loop),在浏览器源代码里面也叫消息循坏(Message Loop)。
这才解决一个问题呀,另一个问题还存在呢。有的人就搁那耗着也不是办法啊。得领导出面解决。领导说,行,我偷摸给你多安排几个跟班(网络线程,io线程)。你看到有那种任务(setTimeout, setInterval, 事件处理,网络请求)来的,你就安排这几个跟班来做。做好了告诉你结果你给后续收个尾,这样可以吧。
那可太好了,领导
于是,异步就产生了,而异步的结果,也被有条理的封装成回调函数,最终变成了一个任务也乖乖的排在消息队列中。
看到这,很多同学可能疑惑了,不对啊,我知道的消息队列有什么宏任务队列,微任务队列,你这咋就一个消息队列啊?
别着急,我们往下讲


消息队列的分类

在介绍这个之前,我们先看一下官方规范的介绍

  1. 每个任务都有一个任务类型,同一个类型的任务必须在一个队列里面,不同的任务分属于不同的队列,在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行
  2. 浏览器必须准备好一个微任务队列,微任务队列中的任务优先于所有的其他任务 W3C解释

在规范中,并没有明确指出,渲染主线程需要一个宏任务队列,而随着现代浏览器越来越复杂,宏任务队列的这个观念也在逐步舍弃。比如我知道的,就有:

  1. 延时队列:用于存放计时器到达后的回调任务,优先级
  2. 交互队列:用于存放用户操作后产生的事件处理任务,优先级
  3. 微任务队列:用户存放需要最快执行的任务,优先级最高

通常添加微任务的方式有Promise, MutationObserver, queueMicrotask

测试一下延时队列和交互队列的优先级:

function delay(duration) {
    const now = Date.now();
    while (Date.now() - now < duration) {}
}

setTimeout(() => {
    console.log("延时队列setTimeout")
}, 2000)

const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
    delay(5000);
    console.log("点击事件");
});
// 进入页面之后点击button,会看到延迟五秒之后,setTimeout在点击事件之后打印出

有了官方的指导,我们可以知道两件事:

  1. 对于单一的消息队列中的任务,没有优先级,谁先进谁先出
  2. 对于各种不同的消息队列,有一定的优先级,除了微任务队列优先级最高之外,由浏览器自主决定哪个消息队列优先级高,当前队列所有任务执行完毕才会继续执行后续队列的任务。

还有一件事,其实是实验出来的,微任务队列中添加微任务,新添加的微任务会添加到当前的微任务队列中,以下代码可以测试出这个结果

// 延时队列,优先级弱于微任务队列,即使比微任务先添加也会后执行
 setTimeout(() => {
    console.log("setTimeout");
}, 0);
// 微任务,添加到微任务队列执行
Promise.resolve().then(() => {
    console.log("promise1");
    // 微任务中添加微任务,执行顺序仍然优先于先添加的setTimeout
    Promise.resolve().then(() => {
      console.log("promise2");
    });
    Promise.resolve().then(() => {
      console.log("promise3");
    });
});
// 执行结果
promise1
promise2
promise3
setTimeout

结语

自己总结的两个面试题,仅供参考

  1. JS中的异步是怎么回事

回答:

  1. JS是一门单线程语言,这是因为它是运行在浏览器的渲染主线程上的,而每个页面的渲染主线程只有一个
  2. 在渲染主线程上不止有JS在运行,同时,渲染主线程还需要执行渲染页面,用户交互等任务。所以如果使用同步的方法来处理JS代码,就会大概率造成主线程阻塞,导致后续其他任务无法执行,这样就会造成问题,比如有类似网络请求的任务,主线程在请求发出到拿到结果这段时间都是在闲着的,白白浪费资源,同时后续的任务无法执行,页面不会更新,给用户造成卡顿,卡死的现象
  3. 所以浏览器需要使用异步的方式避免这个问题,具体做法是当遇到某些任务的时候,比如计时器,网络请求,用户交互等,主线程会将该任务交给其他线程去处理,自己会立即结束当前的任务,继续执行后续的任务,当其他线程完成时,将之前传给他的回调函数包装成任务,加入到消息队列中,等待主线程调度执行
  1. 什么是浏览器的事件循环

回答:

  1. 事件循环是浏览器渲染主线程的工作方式(先唬一下)
  2. 在开启一个新的标签页之后,会在渲染主线程开启一个不会结束的死循环,每一次循环从消息队列中取出一个任务并执行,当然一个标签页进程会包含其他的线程,其他的线程只需要在合适的时候将任务加入到消息队列的末尾即可。(事件循环其实已经解释完了)
  3. 在W3C最新的解释中,每个任务都应该有一个类型,同类型的任务必须在同一个队列,不同的任务可以分属为不同的队列。不同的消息队列有不同的优先级,按官方规范,微任务队列必须具有最高的优先级,需要优先执行,而其他的消息队列则可以由浏览器自主决定哪个优先执行,比如在 chrome中交互队列的优先级是高于延时队列的。(据我的经验,这条回答如果自己不说,下一个问题一般就是微任务和宏任务)
  1. JS的计时器能做到精确计时吗? (从别人那看的,但是可以回答的跟事件循环相关)

不能精准计时,受事件循环的影响,计时器的回调函数只能在主线程空闲的时候运行,也就是说,假如浏览器的计时线程给出的事件也是准确,那么只是保证指定时间之后将回调函数包装成任务放在队列中等待执行,而这时我们并不知道前面还有多少任务因此会有偏差