深入JavaScript事件循环

391 阅读9分钟

JavaScript 事件循环

一、事件循环

事件循环不仅仅包含事件队列,而是具有至少两个队列,除了事件,还要保持浏览器执行的其他操作。这些操作被称为任务,并且分为两类:宏任务(或通常称为任务)和微任务。首先看下什么是宏任务和微任务。

  1. 宏任务:包括创建主文档对象、解析 HTML、执行主线(或全局)JavaScript 代码,更改当前 URL 以及各种事件,如页面加载、输入、网络事件和定时器事件。
  2. 微任务:微任务是更小的任务。微任务更新应用程序的状态,但必须在浏览器任务继续执行其他任务之前执行,浏览器任务包括重新渲染页面的 UI。微任务的案例包括 promise 回调函数、DOM 发生变化等。微任务需要尽可能快地、通过异步方式执行,同时不能产生全新的微任务。

事件循环的实现至少应该含有一个用于宏任务的队列和至少一个用于微任务的队列。

事件循环基于两个基本原则:

  • 一次处理一个任务
  • 一个任务开始后直到运行完成,不会被其他任务中断

看下面的这张图,可以很好理解上面两点原则:

事件循环将首先检查宏任务队列,如果宏任务等待,则立即开始执行宏任务。直到该任务运行完成(或者队列为空),事件循环将移动去处理微任务队列。如果有任务在该队列中等待,则事件循环将依次开始执行,完成一个后执行余下的微任务,直到队列中所有微任务执行完毕。注意处理宏任务和微任务队列之间的区别:单次循环迭代中,最多处理一个宏任务(其余的在队列中等待),而队列中的所有微任务都会被处理。

上面的介绍比较抽象,可能看了也不知道具体是什么,以及它们之间怎么执行的,那么,下面就通过例子来看看。

<button id="firstButton"></button>
<button id="secondButton"></button>
<script>
 const firstButton = document.getElementById("firstButton");
 const secondButton = document.getElementById("secondButton");
 firstButton.addEventListener("click", function firstHandler(){
  Promise.resolve().then(() => {
   /*Some promise handling code that runs for 4 ms*/
  });  ⇽--- 立即对象promise,并且执行then方法中的回调函数
  /*Some click handle code that runs for 8 ms*/
 });
 secondButton.addEventListener("click", function secondHandler(){
  /*Click handle code that runs for 5ms*/
 });
/*Code that runs for 15ms*/
</script>

我们假设发生以下行为:

  • 第 5ms 单击 firstButton
  • 第 12ms 单击 secondButton
  • firstButton 的单击事件处理函数 firstHandler 需要执行 8ms。
  • secondButton 的单击事件处理函数 secondHandler 需要执行 5ms。

当运行 15ms 后,此时的宏任务队列中firstHandlersecondHandler,因为分别在 5ms 和 12ms 的时候点击了按钮,因为此时的微任务队列为空,所以事件循环执行宏任务队列的下一个队列,也就是firstHandler,这时候创建并立即兑现promise,其中的 then 回调函数进入微任务队列。当第一个点击事件执行完成后,立即执行 promise 对象成功的回调函数,而第二个点击事件 secondHandler 还继续在宏任务队列中等待。当微任务队列里任务为空时,事件循环才会开始重新渲染页面,继续执行第二个按钮点击任务。

二、计时器使用

浏览器提供两种创建计时器的方法:setTimeoutsetInterval。浏览器还提供了两个对应的清除计时器方法:clearTimeoutclearInterval。这些方法都是挂载在window对象(全局上下文)的方法。

  • setTimeout启动一个计时器,在指定的延迟时间结束时执行一次回调函数,返回标识计时器的唯一值
  • setInterval启动一个计时器,按照指定的延迟间隔不断执行回调函数,直至取消。返回标识计时器的唯一值

在事件循环中执行计时器,看下面一个例子:

<button id="myButton"></button>
<script>
 setTimeout(function timeoutHandler(){
  /*Some timeout handle code that runs for 6ms*/
 }, 10);  ⇽--- 注册10ms后延迟执行函数
 setInterval(function intervalHandler(){
  /*Some interval handle code that runs for 8ms*/
 }, 10);  ⇽--- 注册每10ms执行的周期函数
 const myButton = document.getElementById("myButton");
 myButton.addEventListener("click", function clickHandler(){
  /*Some click handle code that runs for 10ms*/
 });  ⇽--- 为按钮单击事件注册事件处理器
 /*Code that runs for 18ms*/
</script>

假设,用户在 6ms 的时候点击了按钮,那么这时候宏任务队列中就有两个事件,一个是执行 JS 主线程代码,另一个是单击事件,到了 10ms 的时候,延迟计时器到期,间隔计时器的第一个时间间隔触发,这时候,宏任务队列就有四个任务,分别是:执行 JS 主线程代码、单机事件、延迟计时器到期事件、间隔计时器触发事件,因为此时主线程代码还没有执行结束;到了 18ms 的时候,主线程任务结束,由于没有微任务,所以执行下一个宏任务,就是按钮单击事件;等到了 20ms 的时候,间隔计时器有一次触发,但此时间隔计时器的实例已经在队列中等待执行,所以该触发中止,浏览器不会创建两个相同的间隔计时器。单击事件在 28ms 的时候执行结束,这时候轮到延迟计时器执行了。从这个地方可以看出,我们只能控制计时器何时加入队列中,而无法控制何时执行,因为延迟计时器在 10ms 之后执行,而现在 28ms 的时候才开始执行。延迟计时处理器需要执行 6ms,将会在第 34ms 时结束执行。在这段时间内,第 30ms 时另一个间隔计时器到期。这一次仍然不会添加新的间隔计时器到队列中,因为队列中已经有一个与之相匹配的间隔计时器。

间隔计时处理器在第 34ms 时开始执行,此时距离添加到队列相差 24ms。又一次强调传入 setTimeout(fn, delay)和 setInterval(fn, delay)的参数,仅仅指定计时器添加到队列中的时间,而不是准确的执行时间。

间隔计时处理器需要执行 8ms,当它执行时,另一个间隔计时器在 40ms 时到期。此时,由于间隔处理器正在执行(不是在队列中等待),一个新的间隔计时任务添加到任务队列中,应用程序继续执行

需要记住的重要概念是,事件循环一次只能处理一个任务,我们永远不能确定定时器处理程序是否会执行我们期望的确切时间。间隔处理程序尤其如此。在这个例子中我们看到,尽管我们预定间隔在 10、20、30、40、50、60 和 70ms 时触发,回调函数却在 34、42、50、60 和 70ms 时执行。在本例中,少执行了两次回调函数,有几次回调函数没有在预期的时间点执行。

下面看个例子练习一下:

运行下面代码,2s 之后会输出什么?
setTimeout(function () {
  console.log('Timeout ')
}, 1000)
setInterval(function () {
  console.log('Interval ')
}, 500)
// Interval Timeout Interval Interval Interval

对于上面的答案应该没有什么问题吧,这里解释一下:setInterval 方法调用处理器的间隔至少是固定的间隔,直到显式地清除间隔计时器。而 setTimeout 方法,仅在设定的超时时间结束后调用一次回调函数。在本例中,第一个 setInterval 回调在第 500ms 时调用一次。随后 setTimeout 函数在 1000ms 时调用,另一个 setInterval 立即执行。另外两次 setInterval 分别在 1500ms 和 2000ms 时执行。

再看下一个例子:

运行下面代码,2s 之后会输出什么?
const timeoutId = setTimeout(function () {
  console.log('Timeout ')
}, 1000)
setInterval(function () {
  console.log('Interval ')
}, 500)
clearTimeout(timeoutId)
// Interval Interval Interval Interval

解释:setTimeout 回调函数还没有机会执行就被清除了,因此在本例中仅执行 4 次 setInterval 回调。

延迟执行 setTimeout 和 间隔执行 setInterval 的区别

间隔执行看起来像一个延迟执行的定期重复,但其实它两的差异并不如此,看下面的一段代码:

setTimeout(function repeatMe(){
 /* Some long block of code... */
 setTimeout(repeatMe, 10);
}, 10);   ⇽--- 注册延迟任务,每10ms重新执行自身
setInterval(() => {
 /* Some long block of code... */
}, 10);  ⇽--- 注册周期任务,每10ms执行一次任务

两段代码看起来功能是等价的,但实际未必。很明显,setTimeout 内的代码在前一个回调函数执行完成之后,至少延迟 10ms 执行(取决于事件队列的状态,等待时间只会大于 10ms);而 setInterval 会尝试每 10ms 执行回调函数,不关心前一个回调函数是否执行。

三、总结

下面通过一道题目结束事件循环,运行下面的代码,会打印出什么结果?

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

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

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

async1()

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

console.log('script end')

正确答案:

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

这边做一个简单解释:一开始宏任务队列中只有执行 JS 主线程代码任务,所以一开始按序输出script startasync1 start应该没有问题,setTimeout会放入到宏任务队列,等待下一个事件循环执行,这时候执行到await async2(),会打印出async2,但是 await 会返回一个 Promise,这里相当于 Promise.then,所以会将其放入到微任务队列中, 所以会跳出 async1 往后执行,执行到 new Promise 的时候打印出promise1,并且立即兑现,但是它 then 中的回调函数会被放入到微任务队列,然后执行到最后,打印出script end

这时候,执行 JS 主线程代码任务结束,会去执行微任务队列中的任务,由上面可知,这时候有两个微任务,执行第一个的时候,返回到 async1 中接着执行,输出async1 end执行完 async1 后,执行第二个微任务,也就是 new Promise 的回调函数,这时候输出promise2,最后,微任务队列执行完,事件循环开始执行下一个宏任务队列中的任务,也就是 setTimeout 的回调函数,最后输出setTimeout

这里顺便说一下,题目中使用 0 作为超时时间。如果关注事件循环是如何工作的,就会知道这并不意味着将在 0ms 时执行回调。使用 0,意味着通知浏览器尽快执行回调,但与其他微任务不同,在回调之前可以执行页面渲染。