谈及 Event Loop,我们往往会想到消息队列、宏任务、微任务、延迟队列等概念,但实际上,一轮完整的 Event Loop 还涉及到更新渲染时机以及 requestAnimationFrame、requestIdleCallback 的执行等内容。
本文将基于 WHATWG 的 HTML Living Standard 中关于 Event Loop 的规范 探究一轮完整的 Event Loop 对应的处理模型,并由此引申出 Event Loop 与浏览器渲染之间的联系。
👀 注:规范中基于不同的 user agent 对 Event Loop 作了 Window/Worker/Worklet 等不同的分类,本文所描述的 Event Loop 指代的是页面对应的 window agent 的 Window Event Loop。
相关术语
-
Task:宏任务。
-
Task Queue:消息队列/任务队列。
- Event Loop 根据不同的 task source 可以有一个或多个 Task Queue,例如鼠标、键盘等事件有单独的 Task Queue,目的是确保用户交互响应的及时性。
-
Microtask:微任务。
-
Microtask Queue:微任务队列。
-
Rendering Opportunities:渲染机会,表示当前帧是否有机会执行更新渲染、界面更新。
-
Microtask Checkpoint:微任务的检查点,一个布尔值类型的变量,用于确保同一时间只能有一个微任务执行。
-
Idle Period:每一帧中的「空闲周期」,用于执行 Idle Callbacks 而不影响用户交互响应、动画、帧的合成等高优先级任务,避免造成延迟。
一轮完整的 Event Loop
Event Loop 的作用在于协调事件、用户交互、脚本、渲染、网络等不同的任务,确保页面有条不紊地运行。
根据 HTML Living Standard 中所描述的一轮完整的 Event Loop 流程大致如下(已略去部分非关键步骤):
-
设置
oldestTask
和taskStartTime
为 null。 -
若 Task Queue 非空,则:
-
将 Task Queue 中「最老的任务(oldestTask)」设置为「当前运行中的任务(currently running task)」,并将其从 Task Queue 中移除。
-
执行
oldestTask
。 -
将 currently running task 设置为 null。
-
-
执行 Microtask Checkpoint。
-
若
checkpoint
为 true,则 return,否则设置checkpoint
为 true。 -
若 Microtask Queue 非空,则:
-
将「最老的微任务(oldestMicrotask)」设置为「当前运行中的任务(currently running task)」,并将其从 Microtask Queue 中移除。
-
执行
oldestMicrotask
。 -
将 currently running task 设置为 null。
-
以上步骤循环执行,直至 Microtask Queue 为空。
-
设置
checkpoint
为 false。
-
-
❗️ 注意:
-
本步骤的执行仍在 Task 的执行范围内,即「Task 执行结束前」。
-
当「执行上下文栈」为空时,也会执行 Microtask Checkpoint 清空 Microtask Queue,此种场景大多发生在「回调函数」执行后,例如事件回调等,规范里称之为「clean up after running script」。
-
Microtask 执行过程中产生的 Microtask 会在本步骤得到执行。
-
-
-
设置
hasARenderingOpportunity
为 false。 -
更新渲染(update the rendering)。
-
「浏览上下文(Browsing Context)」是否有「渲染机会」取决于屏幕刷新率、页面性能、页面可见性等限制。
- 🌰 e.g. 当前帧有足够的剩余时间。
-
若当前有渲染机会,则:
-
设置
hasARenderingOpportunity
为 true,设置「上一个 render opportunity 时间」为 taskStartTime。 -
若 Browsing Context 没有可视的 effects 且 requestAnimationFrame 对应的 callbacks map 为空,则将本次更新渲染视为「非必要的渲染」并跳过。
-
触发 resize、scroll 事件。
-
评估媒体查询并报告相关 changes。
-
更新动画并发送相关的动画事件。
-
处理全屏事件。
-
执行「requestAnimationFrame 回调列表」。
- 根据 request id 顺序执行。
-
更新交叉检测(intersection observations)的数据并触发 IntersectionObserver 回调。
-
标记「绘制时间(paint timing)」(首次渲染则作为 FP,包括 FCP 的计算也是基于该时间)。
-
执行更新渲染,更新用户界面。
- 在主线程会执行样式计算、布局(构建 Layout Tree)、分层(构建 Layer Tree)、图层绘制(输出「待绘制列表」)、将「待绘制列表」commit 至合成线程配合 GPU 进行光栅化并将内容绘制至显卡的「前缓冲区」中,等待显示器刷新时将「前缓冲区」中的数据呈现至屏幕上。
-
- 判断当前帧是否存在「空闲周期」
-
当前帧是否存在「空闲周期」取决于页面可见性、Task / Microtasks 执行时长、渲染时长、Task Queue / Microtask Queue 是否为空等因素。
-
存在两种情况:
-
当前帧存在「更新渲染」,通过「获取 Deadline 算法」得到当前帧的剩余时间,再决定是否通过「启动空闲周期算法」和「调用空闲回调算法」执行 requestIdleCallback 回调。
-
当前帧不存在「更新渲染」且 Task Queue / Microtask Queue 均为空,则安排连续的且长度为 50ms 的空闲周期。
-
至此,一轮 Event Loop 就结束了。
那么问题来了,每轮 Event Loop 是否都有更新渲染?requestAnimationFrame、requestIdleCallback 的具体执行机制是怎样的?DOM 操作和 DOM 渲染是同步还是异步?以及 setTimeout / setInterval 等定时器又是如何执行的?以上这些问题将在下文一一分析。
每轮 Event Loop 是否都有更新渲染?
先说结论:每轮 Event Loop 不一定都有更新渲染。
Event Loop 是否有更新渲染取决于当前帧是否有「渲染机会」,而「渲染机会」又受屏幕刷新率、页面性能、页面可见性等因素的制约。
通常 60Hz 的刷新率对应每一帧的时间约为 16.7ms。以下几种场景都有可能导致当前帧没有「渲染机会」:
-
Task 执行时间过长,超过 16.7ms。
-
Microtask Queue 任务过多或单个 Microtask 执行时间过长,导致最终执行时长超过 16.7ms。
-
页面不可见时,「渲染机会」会被降低至每秒 4 次甚至更少。
需要注意的是,有「渲染机会」并不意味着会有真正的页面渲染,还取决于该 loop 前面所执行的 Tasks 中是否有相关的 DOM 操作等产生可视的 effects,并且「更新渲染」还包含了页面渲染之外的其他处理(resize / scroll / 动画事件触发、媒体查询评估、requestAnimationFrame 回调执行等)。
requestAnimationFrame 回调的执行
由「一轮完整的 Event Loop」可知 requestAnimationFrame 回调(以下简称「rAF 回调」)是在「更新渲染」步骤中有渲染机会时、位于「执行更新渲染(更新用户界面)」之前执行。通俗点讲,就是在「下一次重绘之前」执行,因此 rAF 回调通常用于代替 setTimeout 实现 JS 动画。
嵌套的 rAF 回调将会在「下一帧执行更新渲染前」执行。
值得注意的是,非活跃 Tab 标签页中的 rAF 回调会被暂停执行,目的是为了提升性能、减少耗电。
那么,在活跃 Tab 标签页中的 rAF 回调有没有可能在当前帧不执行?
实际上这种情况比较常见,当 Tasks / Microtasks 的执行时间过长以致于阻塞当前帧或后续帧时,rAF 回调就无法在当前帧得到执行,因此我们应尽量避免执行时间过长的 JS 代码。
requestAnimationFrame() 也常被用于模拟计算 FPS,亦即将每秒 rAF 回调的执行次数作为当前的 FPS,也可通过对「一段时间内的该 FPS 值」进行监控,作为一个页面卡顿、掉帧的可参考的体验/性能指标。
❗️ 注意:若以显示器的 VSync 信号作为分割点,rAF 回调会作为下一帧的开始执行。但这不在本文讨论范围内,本文仅在「Event Loop」这一维度探究 rAF 回调的执行时机。
requestIdleCallback 回调的执行
requestIdleCallback 回调(以下简称「rIC 回调」)会在当前帧存在「空闲周期」时并在「执行更新渲染后」(即画面帧的处理、渲染、合成后)被执行,而是否有「空闲周期」取决于页面可见性、Task / Microtasks 执行时长、渲染时长、Task Queue / Microtask Queue 是否为空等因素。
👀 注:此处的「空闲」特指「主线程空闲」。
rIC 回调的执行存在两种情况:
-
当前帧存在「更新渲染」,通过「获取 Deadline 算法」得到当前帧的剩余时间,再决定是否通过「启动空闲周期算法」和「调用空闲回调算法」执行 requestIdleCallback 回调。如下图所示:
- )
-
当前帧不存在「更新渲染」且 Task Queue / Microtask Queue 均为空,则安排连续的且长度为 50ms 的空闲周期。如下图所示:
-
为什么是「50ms」?
- 假设用户点击一个 button,页面需在 100ms 内给出反馈才不会有延迟感(来源于一项人机交互的研究成果)。因此主线程需要在 100ms 以内同时处理「其他事务(idle callback)」和「用户事件响应」,平分下来就是各占 50ms,因此超过 50ms 的任务也被成为「Long Task(长任务)」。
- 即使在 idle callback 开始执行时接收到一个高优先级的「用户交互事件」,只要「用户事件响应」不超过 50ms,用户就不会感受到延迟。
关于 rIC 回调的一些执行细节:
-
rIC 回调会被传递一个
IdleDeadline
对象,可通过IdleDeadline.timeRemaining()
获取当前「空闲周期」的剩余时间。-
此特性可用于判断「空闲周期」的时间是否足够,不够则使用 requestIdleCallback() 调度自身至下一个「空闲周期」,避免同一个 callback 在多个「空闲周期」被执行多次。
function doWork(deadline) { if (deadline.timeRemaining() <= 5) { // 当前「空闲周期」不足 5ms,则重新发布自身, // 确保在一个大于 5ms 的「空闲周期」中执行 requestIdleCallback(doWork); return; } // do work... }
-
-
为了避免 rIC 回调长时间得不到执行,可设置
requestIdleCallback()
的options.timeout
确保 rIC 回调在指定的超时时间得不到执行时作为 Task 排队执行。
❗️ 注意:当页面不可见时,「空闲周期」会被降频,例如降低至 10s/次的频率而非连续触发,目的是为了减少设备功耗。
DOM 操作是同步还是异步?
同样先说结论:DOM 操作是同步的,但 DOM 渲染是异步的。
从 Event Loop 的角度分析,在 Task / Microtask 中所执行的 DOM 操作对 DOM 对象是同步生效的,而 DOM 的渲染则需在「更新渲染」步骤执行(非立即执行),因此 DOM 的渲染是异步的。
实际上,对于 DOM 的操作仅仅是修改了 DOM 对象,没有经过样式计算、布局、重绘、合成等操作是无法将 DOM 操作呈现至用户界面上的。
🌰 e.g. 以下例子借用 requestAnimationFrame()
说明了 DOM 操作的同步与 DOM 渲染的异步:
<h1 id="title">Title ...</h1>
const $title = document.getElementById("title");
$title.innerHTML = "Updated title ...";
requestAnimationFrame(() => {
// 下一次「更新渲染」前触发
// 此时 DOM 对象已更新,但界面仍未更新
alert($title.innerHTML);
requestAnimationFrame(() => {
// 下下次「更新渲染」前触发
// 此时 DOM 对象和界面均已更新
alert($title.innerHTML);
});
});
此外,部分在布局变更时涉及元素几何属性的访问或实时计算的 DOM 操作例如访问宽高、访问 offsetXxx
/scrollXxx
/clientXxx
、调用 window.getComputedStyle(element)
/element.getBoundingClientRect()
等,会导致「强制同步布局(Forced Synchronous Layouts)」,即主线程会立即进行样式计算&布局等操作以获取实时的几何属性。
当此类操作在页面中频繁发生时,会产生不必要的布局开销,严重时会造成页面卡顿、丢帧,成为性能瓶颈。避免「强制同步布局」可通过样式的集中修改(使用类)、分离读写、批量/脱离布局流操作 DOM、设置独立图层等。
setTimeout 又是如何执行的?
上述一轮完整的 Event Loop 并没有涉及 setTimeout / setInterval 等 Timers 回调。实际上规范中也有大致提及 setTimeout / setInterval 的相关执行机制,只不过这两者属于 Web APIs 更多涉及 Runtime(例如 Chromium)的具体实现细节。
在 Chromium 中,setTimeout / setInterval 的回调任务会被添加至一个「延迟任务队列」中,该「延迟任务队列」是一个以任务到期时间作为优先级排序的「优先队列」,即越快到期的任务排在越前面且会越早被执行。
在以下两种情况会执行「延迟任务队列」中到期的任务:
-
Event Loop 每执行完 Task Queue 中的一个 Task,会从队头依次执行「延迟任务队列」中到期的任务。
- Task 及其对应 Microtasks 的执行均会影响到期任务的“执行准确性”,且靠后的到期任务也会受靠前的到期任务所影响。
-
若主线程处于睡眠状态,则会有**「唤醒任务」根据调用操作系统的定时器函数唤醒主线程**,进而从队头依次执行「延迟任务队列」中到期的任务并设置新的「唤醒任务」。
- 到期任务的“执行准确性”主要受操作系统的定时器函数准确性以及靠前的到期任务的影响。
❗️ 注意:
-
「延迟任务队列」本质并非一个「队列」,而是一个「hashMap」。
-
定时器的嵌套层级超过 5 时,其执行间隔会被强制为至少 4ms。
-
🌰 e.g.
function fn() { console.log(performance.now()); setTimeout(fn, 0); } setTimeout(fn, 0);
-
- 非活跃 Tab 标签页的定时器执行最小间隔为 1000ms,目的是为了减少耗电。
总结
本文从 WHATWG 关于 Event Loop 的规范出发,描述了完整的一轮 Event Loop 所涉及的关键步骤:
-
执行 Task Queue 的 oldestTask。
-
执行 Microtask Checkpoint,清空 Microtask Queue。
-
根据「渲染机会」判断是否进入「更新渲染」步骤,执行以下操作:
-
触发 resize / scroll 事件、媒体查询、动画事件、全屏事件、IntersectionObserver 回调以及标记绘制事件等。
-
执行 requestAnimationFrame 回调。
-
执行更新渲染,更新用户界面。
-
判断当前帧是否存在「空闲周期」,决定是否执行 requestIdleCallback 回调。
然后,根据上述的 Event Loop 步骤,分别探究以下几个渲染相关的问题/机制:
-
每轮 Event Loop 是否都有更新渲染?
- 每轮 Event Loop 不一定都有更新渲染,取决于「渲染机会」,受屏幕刷新率、页面性能、页面可见性等因素制约。
-
requestAnimationFrame 回调的执行
-
rAF 回调会在 Event Loop 有「渲染机会」时且在「执行更新渲染(更新用户界面)」之前执行,因此可用 rAF 回调代替 setTimeout 实现 JS 动画。
-
嵌套的 rAF 回调将会在「下一帧执行更新渲染前」执行,非活跃 Tab 标签页中的 rAF 回调会被暂停执行,Tasks / Microtasks 执行过久有可能导致 rAF 在当前帧不执行。
-
-
requestIdleCallback 回调的执行
-
rIC 回调会在当前帧存在「空闲周期」时并在「执行更新渲染后」(即画面帧的处理、渲染、合成后)被执行,受页面可见性、Task / Microtasks 执行时长、渲染时长、Task Queue / Microtask Queue 是否为空等因素制约。
-
rIC 回调可在「更新渲染」之后的「空闲周期」执行,也可在无「更新渲染」时 50ms 的「空闲周期」中执行。
-
rIC 回调可通过判断帧剩余时间避免多次执行,也可设置
options.timeout
避免长时间得不到执行。
-
-
DOM 操作是同步还是异步?
-
DOM 操作是同步的,但 DOM 渲染是异步的。
-
应避免「强制同步布局」造成的性能问题,可通过样式的集中修改(使用类)、分离读写、批量/脱离布局流操作 DOM、设置独立图层等方式。
-
-
setTimeout 又是如何执行的?
-
在 Chromium 中,setTimeout / setInterval 的回调任务会被添加至一个「延迟任务队列」中,该「延迟任务队列」是一个以任务到期时间作为优先级的「优先队列」。
-
「延迟任务队列」的执行时机是在 Task 执行完之后,从队头依次执行「延迟任务队列」中到期的任务,且在主线程睡眠时会有「唤醒任务」唤醒主线程。
-
setTimeout / setInterval 的执行不准确,且执行间隔受嵌套层级、标签页是否活跃等因素影响。
-
References: