前端技术
#事件循环#requestAnimationFrame#微任务#渲染#性能

事件循环中的渲染步骤:微任务过长如何推迟 rAF 与绘制

本文以实时协作界面为贯穿场景,解析浏览器事件循环中任务、微任务、渲染步骤(rAF、样式计算、布局、绘制)的调度顺序,说明微任务过长为何会延迟渲染导致掉帧,并给出避免长微任务、合理使用 rAF 的工程实践。

从一次卡顿的拖拽说起

在一个多人实时协作的白板应用里,用户拖动一张便签时,其他成员的鼠标光标应当平滑跟随。你发现光标移动并不跟手,偶尔会停顿几十毫秒,随后突然跳到一个新位置。更奇怪的是,光标停顿期间,页面上的其他动画,比如加载指示器,也一起卡住。

直觉上,问题可能出在绘制太慢,或者网络延迟。但你把绘制逻辑简化到只剩一个 transform 更新,网络也走本地回环,卡顿依旧。这时需要把目光从“画得多慢”转向“什么时候才轮到画”。浏览器不是每执行完一段 JavaScript 就立刻把结果画到屏幕上,它把工作切分成任务、微任务和渲染步骤,按一套严格的顺序推进。这套顺序里,微任务占据了一个容易被人忽略的位置:它夹在任务和渲染之间,一旦过长,就会把渲染挤到后面,帧率随之下降。

事件循环的宏观结构:任务、微任务与渲染

浏览器的主线程运行在一个事件循环里。HTML 标准把事件循环描述为一个持续运转的机制,它从任务队列里取出一个任务执行,然后处理微任务队列,最后在合适的时机执行渲染步骤。这里的“任务”包括用户点击事件的回调、setTimeout 的回调、网络请求完成后的回调等;微任务则主要来自 Promise 的回调、MutationObserver 以及 queueMicrotask

一个关键约束是:事件循环每次只处理一个任务。任务执行期间,如果产生了新的任务,它们会被排到任务队列末尾,不会打断当前任务。任务执行完毕后,事件循环会清空微任务队列——注意是“清空”,这意味着微任务执行过程中新产生的微任务也会被继续执行,直到队列为空。只有微任务队列清空后,事件循环才可能进入渲染步骤。

渲染步骤并不是每次循环都发生。浏览器会根据显示刷新率、页面可见性、任务负载等因素决定何时渲染。通常,在 60Hz 的屏幕上,浏览器大约每 16.7ms 尝试渲染一次。渲染步骤内部包含一系列子步骤:触发 requestAnimationFrame 回调、样式计算(Style)、布局(Layout)、绘制(Paint)和合成(Composite)。requestAnimationFrame 回调正是在渲染步骤的开头执行,它被设计为在“下一次重绘之前”调用,以便让脚本有机会在绘制前更新动画状态。

下面这张图展示了事件循环一次迭代中,任务、微任务和渲染步骤的先后关系:

flowchart TD
    A[事件循环开始] --> B[从任务队列取出一个任务]
    B --> C[执行任务回调]
    C --> D{微任务队列是否为空?}
    D -- 否 --> E[执行一个微任务]
    E --> D
    D -- 是 --> F{是否到了渲染时机?}
    F -- 否 --> A
    F -- 是 --> G[执行 rAF 回调]
    G --> H[样式计算与布局]
    H --> I[绘制与合成]
    I --> A

注意,微任务的执行是循环进行的,直到队列清空。如果微任务持续产生新微任务,事件循环就会一直停留在“执行微任务”这一步,无法进入渲染步骤。

微任务过长:渲染被推迟的机制

回到白板场景。假设你为了合并来自服务器的批量更新,在收到消息后使用 Promise 链处理数据,并在每个 Promise 回调里更新本地状态。当一次网络消息包含大量操作时,Promise 链可能产生数百个微任务。这些微任务在任务(网络回调)执行完毕后开始运行。

由于微任务队列必须被清空,事件循环会一直执行这些微任务。如果每个微任务只做少量工作,但总数很大,总耗时可能超过 16.7ms。在这段时间里,渲染步骤被推迟,requestAnimationFrame 回调无法按时执行,页面就无法产生新的帧。用户看到的效果就是光标停顿,然后当微任务终于清空、渲染发生时,光标直接跳到最新位置。

这里的关键在于:微任务和渲染步骤之间没有“让路”机制。任务执行后,微任务队列必须清空,这是标准规定的行为。浏览器不会因为微任务太多而提前渲染,也不会在微任务执行中插入渲染。因此,微任务的总耗时直接决定了渲染被推迟的时间。

一个常见的误解是,微任务“很快”,所以不会有问题。但微任务只是“小”,并不保证“快”。如果在一个微任务里执行了复杂的计算,或者递归地产生新微任务,累积时间同样可观。在实时协作界面中,数据同步逻辑往往依赖 Promise,一旦数据量增大,微任务队列可能变得很长。

rAF 的定位:渲染前的最后机会

requestAnimationFrame 回调被安排在渲染步骤的开头。它的设计初衷是让脚本动画与显示刷新率同步,避免使用 setTimeout 时出现的频率不匹配问题。W3C 的动画时序规范指出,requestAnimationFrame 让用户代理能够根据页面是否在前台、CPU 负载等因素决定动画更新频率,从而更合理地利用 CPU。

在事件循环中,rAF 回调的执行时机非常特殊:它位于微任务清空之后、样式计算之前。这意味着,rAF 回调里对 DOM 的修改,会在同一帧的样式计算和绘制中生效。而微任务中修改 DOM,则要等到微任务清空后的渲染步骤才会被绘制,如果微任务过长,这个渲染步骤就被推迟了。

rAF 回调还有一个特点:每一帧最多执行一次。如果页面处于后台标签页,浏览器通常会暂停 rAF 调用以节省资源。这保证了动画在不可见时不会浪费计算。相比之下,setTimeout 即使页面不可见也会继续触发,只是频率可能被限制。

在实际使用中,rAF 回调接收一个时间戳参数,表示上一帧渲染的结束时间。基于这个时间戳计算动画进度,可以避免在高刷新率屏幕上动画过快的问题。MDN 文档特别提醒,不要使用 Date.now() 或自增计数来计算进度,而应使用回调提供的时间戳。

实践:避免长微任务,合理使用 rAF

针对实时协作界面的卡顿,可以从两个方向入手:缩短微任务的总耗时,以及把非关键的更新推迟到 rAF 中。

缩短微任务总耗时

  • 批量处理数据:不要在一次网络回调中为每条消息创建一个 Promise 链。可以把多条消息合并成一个批次,在一个微任务中处理整个批次,减少微任务的数量。
  • 避免微任务递归:如果在一个微任务中又调用了 queueMicrotaskPromise.resolve().then,会不断向队列追加新微任务,导致队列无法清空。这种模式应改为使用任务(例如 setTimeout)或 rAF 来分段处理。
  • 使用任务分割长计算:如果一次数据处理需要较长时间,可以将其拆分成多个任务,每个任务处理一部分,并在任务之间让出渲染机会。例如,使用 setTimeoutMessageChannel 来调度后续工作。

合理使用 rAF

  • 把视觉更新放入 rAF:对于需要每帧更新的动画,如光标移动、滚动指示器,应在 rAF 回调中修改样式。这样能确保更新与渲染同步,避免在微任务中修改 DOM 导致延迟。
  • 合并多次状态更新:如果在一个任务中多次修改了某个元素的样式,浏览器最终只会渲染最后一次修改的结果。因此,可以在 rAF 回调中统一读取最新状态并应用,而不是在每次状态变化时都立即更新 DOM。
  • 避免在 rAF 中执行耗时操作:rAF 回调本身也占用渲染步骤的时间,如果回调中执行了复杂的计算或大量 DOM 操作,同样会拖慢帧率。应尽量让 rAF 回调保持轻量,把计算放在任务或 Web Worker 中。

下面是一个简化的示例,展示如何用 rAF 合并光标位置的更新:

// 伪代码:合并多个光标更新,在 rAF 中统一应用
let pendingCursor = null;
let rafId = null;

function updateCursor(x, y) {
  pendingCursor = { x, y };
  if (rafId === null) {
    rafId = requestAnimationFrame(applyCursor);
  }
}

function applyCursor(timestamp) {
  if (pendingCursor) {
    cursorElement.style.transform = `translate(${pendingCursor.x}px, ${pendingCursor.y}px)`;
    pendingCursor = null;
  }
  rafId = null;
}

在这个示例中,无论 updateCursor 被调用多少次,DOM 的修改只会在 rAF 回调中发生一次。这减少了样式计算和布局的次数,也避免了微任务中的频繁修改。

对比:任务、微任务与 rAF 的调度差异

为了更清晰地理解三种调度方式的差异,下表对比了它们的特点:

调度方式执行时机频率对渲染的影响适用场景
任务(Task)事件循环每次取出一个任务执行由事件源决定,可能频繁任务执行完毕后,微任务清空后才可能渲染用户事件、网络回调、定时器
微任务(Microtask)当前任务执行完毕后,清空整个队列每个任务后都会清空,可能产生大量微任务微任务过长会推迟渲染,导致掉帧Promise 回调、状态同步
rAF渲染步骤的开头,每帧最多一次与显示刷新率同步,后台暂停与渲染同步,适合动画动画、视觉更新、合并 DOM 修改

从表中可以看出,微任务虽然执行频率高,但如果不加控制,会阻塞渲染。rAF 则天然与渲染绑定,适合处理视觉更新。

失败模式与诊断信号

在实际项目中,微任务过长导致的掉帧往往难以直接观察。以下是一些常见的失败模式和可观测信号:

  • 长任务与长微任务:Performance 面板中的 Long Tasks 记录可以显示超过 50ms 的任务。微任务通常不会被单独标记,但它们会包含在任务的总耗时中。如果任务耗时很长,且其中大部分时间在微任务中,就需要检查微任务的数量和复杂度。
  • 帧率下降:使用 requestAnimationFrame 的时间戳可以计算实际帧率。如果帧率明显低于显示刷新率,且 CPU 占用不高,可能是微任务阻塞了渲染。
  • 掉帧与延迟:用户感知的卡顿往往表现为动画不连续或交互响应延迟。可以通过 Chrome DevTools 的 Performance 录制,查看帧的时间线,找出长时间没有新帧的区间。

在诊断时,可以尝试在微任务处理前后记录时间戳,计算微任务的总耗时。如果耗时接近或超过 16.7ms,就需要优化。

边界与不适用场景

并非所有场景都需要担心微任务过长。如果页面没有动画,或者用户不关心平滑度,微任务延迟渲染的影响可能不明显。此外,微任务在某些情况下是必要的,例如需要保证状态一致性的场景。

requestAnimationFrame 也有其边界。它只在页面可见时触发,如果页面被隐藏,动画会暂停。对于需要持续运行的后台任务,应使用 Web Worker 或 Service Worker,而不是依赖 rAF。另外,rAF 回调的执行频率受显示刷新率限制,在 120Hz 屏幕上会执行得更频繁,这要求回调必须足够高效,否则反而会消耗更多 CPU。

最后,不同浏览器的实现细节可能略有差异。HTML 标准定义了事件循环的基本模型,但具体的渲染时机、微任务处理顺序等可能因浏览器而异。本文的讨论基于标准模型,实际开发中应以目标浏览器的行为为准。

回到白板:如何落地

在实时协作白板中,你可以采取以下具体措施:

  1. 将服务器消息的解析和状态更新放在任务中,而不是微任务中。例如,使用 setTimeoutMessageChannel 来调度处理。
  2. 将光标位置、选中框等视觉元素的更新统一放入 rAF 回调,并在回调中读取最新的状态。
  3. 对于复杂的计算(如碰撞检测),使用 Web Worker 在后台线程执行,避免阻塞主线程。
  4. 监控帧率和长任务,建立性能预算,例如保证 95% 的帧在 16.7ms 内完成。

通过这些实践,你可以减少微任务对渲染的阻塞,让白板上的光标和动画保持流畅。理解事件循环中微任务与渲染步骤的关系,是诊断这类性能问题的关键。

资料来源

  1. HTML Standard: Event loops
  2. MDN: requestAnimationFrame
  3. W3C: requestAnimationFrame specification
  4. 事件循环和RAF和微任务和执行机制- Aienming