Event-Loop 总结理解

202 阅读10分钟

为什么 和 是什么

正是有需求, 才有实现。那么事件循环的需求是什么呢?

其实这个是跟浏览器相关,这个说法不太严谨,应该是说跟用到 JavaScript 的 user agent 有关。这样也可以解释,NodeJS 中也有事件循环了。目前,我们分析,还是聚焦在浏览器上分析吧。

浏览器有很多事件需要进行交互的,交互对象有可能是用户(比如输入),有可能是浏览器本身,有可能是服务器。这些交互过程或结果是依赖 JavaScript 来完成的。如用户填写表单,点击提交按钮,浏览器会触发 DOM 点击事件,而点击事件绑定了对应的 JavaScript 代码进行校验。这个过程 JavaScript 是被动被调用的。

摘抄于JavaScript 事件循环:从起源到浏览器再到 Node.js

whatwg标准:

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.

我们可以发现事件循环本质上是 user agent (如浏览器端) 用于协调用户交互(鼠标、键盘)、脚本(如 JavaScript)、渲染(如 HTML DOM、CSS 样式)、网络等行为的一个机制

所以,需求就是因为 user agent 需要一个机制去协调用户交互和其他一些事件的处理,所以,事件循环就应运而生了。

怎么做

以上,我们知道了,事件循环的出因和它是什么了。那它与 JavaScript 的关系是怎样的呢?

由于读 JavaScript 事件循环:从起源到浏览器再到 Node.js ,对其中的外部队列有些疑惑:JavaScript 同步代码的执行(如 console.log)我认为不属于外部队列,也不属于内部队列。跟我认知产生冲突,所以以下,我将用我所知的知识进行总结。

摘抄于JavaScript 事件循环:从起源到浏览器再到 Node.js

外部事件源:

  • DOM 操作 (页面渲染)
  • 用户交互 (鼠标、键盘)
  • 网络请求 (Ajax 等)
  • History API 操作
  • 定时器 (setTimeout 等)

HTML 标准中明确指出一个事件循环由一个或多个外部队列,而每一个外部事件源都有一个对应的外部队列。不同事件源的队列可以有不同的优先级(例如在网络事件和用户交互之间,浏览器可以优先处理鼠标行为,从而让用户感觉更加流程)

内部事件源:

  • Promise [5] 的成功 (.then) 与失败 (.catch)
  • MutationObserver

内部队列(Microtask Queue),即 JavaScript 语言内部的事件队列,在 HTML 标准中,并没有明确规定这个队列的事件源

事件循环是 user agent(浏览器)用于调度的一种机制,那它和 JavaScript 的关系,在浏览器方面来说的话,应该跟 JS 引擎有关。

我认为外部事件源是由不同引擎发出的,如页面渲染是渲染引擎发出的,DOM 操作,单纯从操作这个动词来看,是 JS 引擎发出的。所以外部事件源中,某一些是 JS引擎发出的,然后调用到对应的 JS 。所以此时此处就应该深入到 JS 中去解释了。

很多文章都已经得出浏览器中的事件循环的结论:

  1. 先执行 Task,
  2. 然后执行 microTask,
  3. 如果该 microTask 产生了 一个 microTask,那么继续执行,直到微任务队列为空,
  4. 然后继续循环,从1开始

由此可见,该事件循环应该是由 JS 引擎来维护的(纯从现象结论去猜测,并无相关证明)。

所以,从大的方面看来:事件循环是 user agent 调度的机制;从细微方面看(如浏览器端):事件循环是 js 引擎对 JS 调度的机制。

那么从 NodeJS 看来,事件循环又是怎样的呢?

摘抄于JavaScript 事件循环:从起源到浏览器再到 Node.js

如果说浏览端是将 JavaScript 集成到 HTML 的事件循环之中,那么 Node.js 则是将 JavaScript 集成到 libuv 的 I/O 循环之中。

Nodejs 的 user agent 应是 JS引擎(或许这样说不严谨):因为它没有浏览器调度的事件(如渲染,网络等)。它增加 I/O 的操作。

摘抄于JavaScript 事件循环:从起源到浏览器再到 Node.js

至于内在的差异,有一个很重要的地方是 Node.js (libuv)在最初设计的时候是允许执行多次外部的事件再切换到内部队列的,而浏览器端一次事件循环只允许执行一次外部事件。

这里的说法,我是赞同的。从上面的事件循环的结论看来,就知道,浏览器端是一个Task,一个Task这样子运行的。浏览器端这样做也是情有可原的,单线程,为了保证不会混乱(如多处对同一个DOM元素进行操作)。注意:该行为在 NodeJS 11版本后(包含11)已经跟浏览器端同步了 。下文中额外提醒的知识点的章节中,就可以体现到这个知识点了。

摘抄于JavaScript 事件循环:从起源到浏览器再到 Node.js

Node.js 官方这个所谓事件循环过程,其实只是完整的事件循环中 Node.js 的多个外部队列相互之间的优先级顺序。

以下,对 NodeJS 事件循环的各个外部队列(Task)说明一下:

  1. timer:第一阶段的原因在 libuv 的文档 [8] 中有描述 —— 为了减少时间相关的系统调用(System Call)

  2. pengding callbacks :由于 poll 可能 block 住事件循环,所以应当有一个外部队列专门用于执行 I/O 的 callback ,并且优先级在 poll 以及 prepare to poll 之前。

    另外我们知道网络 IO 可能有非常多的请求同时进来,如果该阶段如果无限制的执行这些 callback,可能导致 Node.js 的进程卡死该阶段,其他外部队列的代码都没法执行了。所以当前外部队列在执行一定数量的 callback 之后会截断。由于截断的这个特性,这个专门执行 I/O callbacks 的外部队列也叫 pengding callbacks

  3. idle, prepare 对应的是 libuv 中的两个叫做 idle 和 prepare 的句柄。由于 I/O 的 poll 过程可能阻塞住事件循环,所以这两个句柄主要是用来触发 poll (阻塞)之前需要触发的回调

  4. poll connections,data... 可能会 block 住事件循环,所以应当有一个外部队列专门用于执行 I/O 的 callback ,并且优先级在 poll 以及 prepare to poll 之前(即 pending callbacks)

  5. check 用于执行 setImmediate 事件的。

    摘抄于JavaScript 事件循环:从起源到浏览器再到 Node.js

    setImmediate 出现在 check 阶段是蹭了 libuv 中 poll 阶段之后的检查过程(这个过程放在 poll 中也很奇怪,放在 poll 之后感觉比较合适)

  6. Close callbacks

抄个代码:

// 代码来源于:[JavaScript 事件循环:从起源到浏览器再到 Node.js](https://mp.weixin.qq.com/s/IhaHIh-G1wQg8Tdc0nxP4Q) 
const fs = require('fs');

setImmediate(() => {
  console.log('setImmediate');
});

fs.readdir(__dirname, () => {
  console.log('fs.readdir');
});

setTimeout(()=>{
  console.log('setTimeout');
});

Promise.resolve().then(() => {
  console.log('promise');
});

// v12.x
// promise
// setTimeout
// fs.readdir
// setImmediate

建议好好读 JavaScript 事件循环:从起源到浏览器再到 Node.js 的 NodeJS 部分,非常好。

额外的话题 rAF 和 rIC

rAF(requestAnimationFrame)rIC(requestIdleCallback)

rAF 这个方法就是,你希望执行一个动画,并且要求浏览器,要在下次重绘前调用指定的回调函数去更新动画

rIC 当当前浏览器处于空闲状态时,执行指定的函数

抄个代码

// 代码来源于:[一位摸金校尉决定转行前端](https://mp.weixin.qq.com/s/fwmDnUXvuLy77V0chjyELw) 
setTimeout(() => {
  console.log("setTimeout1");
  requestAnimationFrame(() => console.log("rAF1"));
})
setTimeout(() => {
  console.log("setTimeout2");
  requestAnimationFrame(() => console.log("rAF2"));
})

Promise.resolve().then(() => console.log('promise1'));
console.log('global');
// global
// setTimeout1 setTimeout2 rAF1
// rAF2

连结果解释也抄下来:

摘抄于 一位摸金校尉决定转行前端

setTimeout1setTimeout2作为2个task,使用默认延迟时间(不传延迟时间参数时,大概会有4ms延迟),那么大概率会在同一帧调用。

rAF1rAF2则一定会在不同帧的render前调用。

所以,大概率我们会在同一帧先后调用setTimeout1setTimeout2rAF1,再在另一帧调用rAF2

我们剩下个 rIC 没说,这个从它的解释中可以看到,它在一帧中的位置是处于 render 后的(或没有,假如这一帧中没有剩余时间的话),假如 render 后,这一帧(1s / 60fps = 16.6ms)还有剩余时间,那就会触发 rIC。所以它是一个低优先级的,不确定什么时候会被触发的方法。

额外的额外的话题 一帧

为什么浏览器显示会卡顿?

刚才我们在聊 rAFrIC 的时候,有谈过 一帧 ,也就是屏幕刷新率 60Hz 的时候(有些屏幕刷新率更高,那么对应的一帧时间也会变化),每 16.6 ms 将会渲染一次画面,这时候人肉眼看上去,是流畅的。

我们接着了解一下 一帧 的时间里,浏览器会干哪些活。大概就是 Task 和 render 。

摘抄于 一位摸金校尉决定转行前端

其中task被称为宏任务,就像下墓后我们要做的事一样。

包括setTimeoutsetIntervalsetImmediatepostMessagerequestAnimationFrameI/ODOM 事件等。

render指渲染页面。

那么如果 一帧 的时间内,Task 全占据了,render 只能往后靠了,这个时候,这 一帧 没有渲染到页面。如果丢失的帧数不多,那我们可能感觉不出来,但如果多个 一帧render 都没挤得过 Task ,这时候就会有明显的卡顿现象了。

所以,render 没执行 -> 掉帧 -> 卡顿。

那么这个 一帧 跟本文主题 Event-Loop 有什么关系呢?

以上可知,在浏览器中 Event-Loop 的行为是:

  1. 先执行 Task,
  2. 然后执行 microTask,
  3. 如果该 microTask 产生了 一个 microTask,那么继续执行,直到微任务队列为空,
  4. 然后继续循环,从1开始

而,观察到 一帧 所做的事大概是 Taskrender

上面介绍得知,Task 就是 浏览器中的 Event-Loop 中的 Task ,那 microTask 呢?

其实在 Taskrender 中,就是 microTask 。类似夹心饼干,上层 Task ,夹心 microTask ,下层 render

那么,这里我有一个小疑问,一位摸金校尉决定转行前端 文中介绍了,Task 耗时过长,导致 render 在这 一帧 中没有执行,那么 microTask 耗时过长的话,是否也会同样的表现呢? 我觉得表现是相同的。不过由于对 microTask 没深入了解,所以答案不一定是准确的。期待以后可以了解。

此外,一帧 并不是只运行一个 Task 就将时间让给 render 的,一帧 会运行多个 Task ,所以 一帧 中可能会完成多轮 Event-Loop

一个额外提醒的知识点

定时器(setTimeout , setInterval)的回调函数是一个独立的宏任务,如:

console.log('start');

setTimeout(() => {
  console.log('time1');
  Promise.resolve().then(() => console.log('p1'));
}, 1000)

setTimeout(() => {
  console.log('time2');
  Promise.resolve().then(() => console.log('p2'));
}, 1000)

console.log('end');
// result: start end time1 p1 time2 p2
  1. 执行完 script 宏任务
  2. 检查微任务队列,发现微任务队列是空的
  3. 取出第一个 setTimeout 的回调事件,执行;执行完毕后,宏任务就执行完毕了
  4. 此时微任务队列已经不为空了,依次取出微任务执行,直到微任务队列为空
  5. 取出第二个 setTimeout 的回调事件,执行
  6. 此时微任务队列已经不为空了,依次取出微任务执行,直到微任务队列为空。

参考

JavaScript 事件循环:从起源到浏览器再到 Node.js

一位摸金校尉决定转行前端