你可能不知道的 Event Loop

1,500 阅读12分钟

谈及 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 流程大致如下(已略去部分非关键步骤):

  1. 设置 oldestTasktaskStartTime 为 null。

  2. 若 Task Queue 非空,则:

    • 将 Task Queue 中「最老的任务(oldestTask)」设置为「当前运行中的任务(currently running task)」,并将其从 Task Queue 中移除。

    • 执行 oldestTask

    • 将 currently running task 设置为 null。

  3. 执行 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 会在本步骤得到执行。

  4. 设置 hasARenderingOpportunity 为 false。

  5. 更新渲染(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 进行光栅化并将内容绘制至显卡的「前缓冲区」中,等待显示器刷新时将「前缓冲区」中的数据呈现至屏幕上
  1. 判断当前帧是否存在「空闲周期」
  • 当前帧是否存在「空闲周期」取决于页面可见性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);
  });
});
rAF 证实 DOM 操作的同步性与渲染的异步性

此外,部分在布局变更时涉及元素几何属性的访问或实时计算的 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