深入浅出事件循环机制

一、事件循环基础

由于JavaScript是一种单线程的编程语言,因此JavaScript中的所有任务都需要排队依次完成。但这样的设计明显会有很大的一个问题,那就是如果碰到一个需要耗费很多的时间完成的事件时,很有可能会造成线程的阻塞问题。因此,JavaScript的开发者就将所有的任务分为两种来解决这种问题:

  • 同步任务(在主线程中只有前面的代码执行完毕后,后面的才能执行)
  • 异步任务(从主线程提出,进入任务队列执行,等执行完毕后通知主线程,这个异步任务可以执行了,才会进入主线程执行)

如图所示:
image

因此总的来说,JavaScript的执行机制主要是以下三步:

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
  3. 一旦主线程的栈中的所有同步任务执行完毕,系统就会读取任务队列,选择需要首先执行的任务然后执行。

而在此过程中,主线程要做的就是从任务队列中去实践,执行事件,执行完毕,再取事件,再执行事件…这样不断取事件,执行事件的循环机制就叫做事件循环机制。(需要注意的的是当任务队列为空时,就会等待直到任务队列变成非空。)

其基本的逻辑如下:

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

详情也可以看我的这篇文章:

JavaScript异步编程基础

二、深入事件循环

上面这种认识也是我最初学习JavaScript所有的认识,但随着自己的学习的不断深入,我开始意识到了这里面的东西没有我所想的这么简单。

首先我们来看一段代码:

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

setTimeout(function() {
console.log('setTimeout1');
}, 100);

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

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


console.log('script end');

输出为:

1
2
3
4
5
6
script start
script end
promise1
promise2
setTimeout2
setTimeout1

我们可以看到,Promise都在setTimeout前面执行了,这是为什么呢?

我们要知道的是事件循环并不仅仅只有事件队列,而是具有至少两个队列,除了事件,还要保持浏览器执行的其他操作,而这些操作也被任务,并且分为两类:macrotask(宏任务)和microtask(微任务),在ECMAScript中,microtask称为jobs,macrotask称为task。

那它们到底有什么区别呢?

  • macrotask(宏任务),可以理解是每次执行栈执行的代码就是一个宏任务。主要包括创建主文档对象、解析 HTML、执行主线(或全局)JavaScript
    代码,更改当前 URL 以及各种事件,如页面加载、输入、网络事件和定时器事件。从浏览器的角度来看,宏任务代表一个个离散的、独立工作单元。运行完任务后,浏览器可以继续其他调度,如重新渲染页面的 UI 或执行垃圾回收。
  • microtask(微任务)是更小的任务。微任务更新应用程序的状态,但必须在浏览器任务继续执行其他任务之前执行,浏览器任务包括重新渲染页面的UI。微任务的案例包括 promise
    回调函数、DOM 发生变化等。微任务需要尽可能快地、通过异步方式执行,同时不能产生全新的微任务。微任务使得我们能够在重新渲染UI之前执行指定的行为,避免不必要的UI重绘,UI重绘会使应用程序的状态不连续。

需要的值的是,ECMAScript 规范没有提到事件循环。不过,事件循环在 HTML 规范中有详细说明,里面也讨论了宏任务和微任务的概
念。ECMAScript 规范提到了处理 promise 回调(http://mng.bz/fOlK)的功能(类似于微
任务)。虽然只有 HTML 规范中定义了事件循环,但其他环境(如 Node.js)也都在使用它。

也就是说事件循环的实现至少应该包含有一个用于红事件的队列和一个用于微事件的队列。这使得事件循环能够根据任务类型进行优先处理。我们还可以看看下面这张图,来看看在存在宏事件与微事件的情况下,事件循环的执行顺序到底是怎么一回事:

输入图片说明
图转自《JavaScript忍者秘籍第二版》

总结起来也就是以下几步:

  1. 执行一个宏任务(栈中没有就从事件队列中获取)
  2. 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  3. 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  4. 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  5. 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

也正是由于上述的规则,在上面的那段代码中,首先会执行主代码块这个宏事件,然后再将微事件Promise执行,最后再去执行第二个宏事件setTimeout。

三、事件循环与渲染

前面我们提到,微事件会在宏事件与页面渲染之间执行,那么是事件循环与页面渲染到底存在着怎样的联系呢?

首先我们要知道的是浏览器通常会尝试每秒渲染60次页面,从而达到每秒60帧的数组。而60fps通常是检验体验是否平滑流畅的标准,比方在动画里——这意味着浏览器会尝试在 16ms 内渲染一帧。需要注意上图的的“更新渲染”是如何发生为何
事件循环内的,因为在页面渲染时,任何任务都无法再进行修改。这些设计和
原则都意味着,如果想要实现平滑流畅的应用,我们是没有太多时间浪费在处
理单个事件循环任务的。因此,在理想情况下,单个任务和该任务附属的所有微任务,都应在 16ms 内完成。

因此出于这种考虑,可能会出现以下的三种情况:

  1. 在另一个 16ms 结束前,事件循环执行到“是否需要进行渲染”的决策环节。因
    为更新 UI 是一个复杂的操作,所以如果没有显式地指定需要页面渲染,浏览
    器可能不会选择在当前的循环中执行 UI 渲染操作。
  2. 在最后一次渲染完成后大约 16ms,时间循环执行到“是否需要进行渲染”的决
    策环节。在这种情况下,浏览器会进行 UI 更新,以便用户能够感受到顺畅的
    应用体验。
  3. 执行下一个任务(和相关的所有微任务)耗时超过 16ms。在这种情况下,浏览
    器将无法以目标帧率重新渲染页面,且 UI 无法被更新。如果任务代码的执行不耗费过多的时间(不超过几百毫秒),这时的延迟甚至可能察觉不到,尤其当页面中没有太多的操作时。反之,如果耗时过多,或者页面上运行有动画时,用户可能会察觉到网络卡顿而不响应。在极端的情况下,如果任务的执行超过几秒,用户的浏览器将会提示“无响应脚本”的恼人信息。

参考资料: