ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入理解事件循环:requestAnimationFrame与Web Workers的调度奥秘

深入理解事件循环:requestAnimationFrame与Web Workers的调度奥秘

要理解requestAnimationFrame和Web Workers在JavaScript 事件循环里到底站在什么位置,光靠"宏任务/微任务两条队列"的认知是远远不够的。我最近帮人排查一个实时图表卡顿的问题,折磨了两天,最后发现根源恰恰是对这两块调度的理解有偏差:一边是掉帧的动画还在用setInterval硬撑,一边是几十万条数据全在主线里同步 parse,把整个界面卡成了幻灯片。这篇文章是事件循环主题的下篇,专门补齐这两个关键角色——一个和渲染管线深度绑定,一个跑在主线程之外的第二条时间线上。如果你已经知道await、Promise这些微任务的基本规则,但面对"为什么动画会掉帧""为什么大数据量计算会卡UI"这些问题时仍觉得隔着一层纸,那这篇就是为你写的。

1. 事件循环的一个完整 tick:宏任务、微任务与渲染之间的真实顺序

很多教程讲到事件循环,一般就停在这张图:执行一个宏任务,然后把微任务队列清空,再取下一个宏任务。这套模型应付"理解Promise.then和setTimeout谁先执行"完全够用,但一旦涉及动画、滚动、绘制,它少了一个关键组成部分——浏览器还必须在某个时间点把内存里的 DOM 状态真正画到屏幕上。

1.1 为什么宏任务之后必须插入"渲染判断"

我们平时说的宏任务,包括脚本整体、setTimeout、setInterval、用户交互事件、postMessage等;微任务则是Promise.then、queueMicrotask、MutationObserver这些。真实浏览器的执行流程是:执行一个宏任务,然后立刻把微任务队列清空,清完之后并不会马上取下一个宏任务,而是先做一个判断——当前帧是否需要渲染。

这里的"渲染"不是一次全量重绘,而是浏览器根据 DOM 变化、样式变化、动画注册情况决定要不要走一遍更新样式、布局、绘制、合成这些管线。如果页面不可见,或者这一帧根本没有感觉需要更新的内容,浏览器可能直接跳过,继续执行下一个宏任务。而requestAnimationFrame的回调,就恰恰被安排在这个"渲染判断"之前的步骤里。

我用银行柜台来类比:宏任务像叫号办理业务,微任务像办完一笔业务之后顺手确认到账的小动作,rAF 则像每个营业日结束前的日终盘点——盘点完,账本才真正落到纸上。也就是说,rAF 不在宏任务队列里,也不在微任务队列里,它是渲染阶段的一个独立回调阶段。

1.2 用一段代码验证真实的执行顺序

下面这段脚本可以在一个可见页面的普通标签页里跑一下,观察输出顺序:

setTimeout(() => console.log('定时器任务'), 0); Promise.resolve().then(() => console.log('微任务')); requestAnimationFrame(() => console.log('动画帧回调')); console.log('脚本本体');

大多数浏览器里的输出顺序是:

脚本本体 微任务 动画帧回调 定时器任务

脚本本体先打印是因为它本身属于当前宏任务的一部分;微任务紧跟其后,因为当前宏任务结束之后立即清空了微任务队列;动画帧回调则发生在接下来的渲染阶段;而定时器任务属于下一个宏任务,排到了最后。如果你在无头浏览器或者某些特别的环境里跑结果稍有差异,多半是"是否需要渲染"的判断导致的,但在常规页面里这个顺序非常稳定。

理解了这一点,很多之前模糊的概念能立刻对上号:为什么setTimeout(fn, 0)有时候会感觉比 rAF 还慢?因为setTimeout是按系统时钟排到宏任务队列的,它不关心渲染;而 rAF 直接插进渲染流程中。天然就有先后关系。

2. requestAnimationFrame 的调度秘密:它为什么不只是"更聪明的 setTimeout"

很多人写动画或者节流的时候,习惯setTimeout(fn, 1000 / 60),仿佛 60fps 就是 16.7ms 一次。这个想法在十年前还算凑合,在现在各种高刷新率屏幕、后台标签页节能机制下,问题会越来越多。rAF 之所以被设计出来,是为了解决一个非常本质的问题:你的回调应该在每一帧真正渲染到屏幕之前被调用,而不是在某个随便的时刻。

2.1 触发时机与系统时钟无关,只跟显示帧率有关

requestAnimationFrame(callback)的 callback 会收到一个高精度时间戳参数(DOMHighResTimeStamp),它表示从页面加载到现在经过的毫秒数,精度比Date.now()高得多。有了这个参数,你在动画循环里应该用它计算帧间时间差(delta),而不是依赖自己记录的日期戳。

let lastTimestamp = 0; let position = 0; function animate(timestamp) { // timestamp 是由 rAF 传入的高精度时间 const delta = lastTimestamp ? timestamp - lastTimestamp : 0; lastTimestamp = timestamp; position += delta * 0.1; // 每毫秒移动 0.1 像素 canvas.style.transform = `translateX(${position}px)`; requestAnimationFrame(animate); } requestAnimationFrame(animate);

注意,rAF 的触发频率会跟随显示器的刷新率。在 60Hz 的屏幕上大约是每帧 16.7ms,在 120Hz 屏幕上可能只有 8.3ms。如果你还按"每帧移动 1 像素"的旧思路写动画,同一段动画在不同刷新率的设备上速度会差一倍;而用时间戳按比例计算位移,才能真正做到"在 60Hz 和 120Hz 上看到同样速度的动画"。

2.2 后台标签页的静默暂停:rAF 和定时器的最终分水岭

这一点是最容易被忽视的坑。当页面切到后台标签页或最小化时:

  • requestAnimationFrame的回调会完全停止执行,因为后台页面资源有限,浏览器不会为看不见的页面渲染帧。
  • setTimeout/setInterval虽然也会被节流,但主流浏览器通常还会以每秒 1 次的低频继续触发,并不会完全停止。

这个机制拆出来看非常合理:后台页面不需要动画,停下省电;但定时器可能还关联着埋点上报、状态同步、心跳检测这类逻辑,所以保留低频执行。问题在于,很多开发者在 rAF 里写了"如果页面切走后回来,动画继续从上次的位置走"的逻辑,却发现回来后 position 跳了一大截。原因就是 rAF 暂停期间 callback 根本没跑,一旦恢复,它会在下一帧继续执行,而时间戳却已经跳走了。解决方案是在动画暂停、恢复时检测document.visibilityState,必要时重置lastTimestamp,避免用一次巨大的 delta 把动画瞬间推到终点。

2.3 同一帧里多次调用 rAF,回调不会自动去重

还有一个细节容易踩:在同一帧内连续调用多次requestAnimationFrame注册回调,这一帧的渲染阶段会逐一执行全部回调,并不是只执行一次。所谓"rAF 能节流",指的是它把执行时机统一到了渲染前,但并不会替你合并调用次数。

所以我们在做滚动、缩放这类高频事件的节流时,一般要自己加一个 pending 标志:

let ticking = false; window.addEventListener('scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { updatePosition(); // 真正读取滚动位置并更新 UI ticking = false; }); });

这个模式的价值在于:无论 scroll 事件在这一帧里触发了 10 次还是 100 次,updatePosition在每一帧最多执行一次,因为ticking会拦住后续的重复注册。这正是"用 rAF 把高频事件压到帧级别"的典型用法。顺便提一句,这种场景也可以用requestIdleCallback做更宽松的调度,但涉及 DOM 位置更新还是 rAF 更稳妥,因为它在绘制前执行,结果不会滞后于视觉帧。

2.4 rAF 回调里绝不能做的事

rAF 回调是整个页面渲染前的最后一段代码,它一旦执行太慢,直接影响的是用户的视觉体验。你可以在里面改样式、写 canvas、做轻量计算,但不要在里面跑几十万次循环、不要做大规模 JSON 解析、不要发起同步网络请求。帧预算就那么多——60Hz 下一帧只有 16.7ms,浏览器本身还要分一部分时间做样式计算和绘制,留给 JS 的时间通常不足 10ms。一旦超支,掉帧、卡顿、输入延迟都会接连出现。凡是重计算,一律交给接下来要讲的 Web Workers。

3. Web Workers 的独立事件循环:离主线程越远,越要理解通信成本

如果说 rAF 是站在渲染管线上做调度,那 Web Workers 就是彻底跳出主线程事件循环之外的另一套系统。Worker 有自己独立的线程、独立的 JavaScript 运行环境、独立的事件循环。它跟主线程之间没有共享的 DOM,也没有共享的全局对象,所有人只能通过消息机制沟通。

3.1 Worker 里也有事件循环,但它不画帧也没有 DOM

很多人第一次写 Worker 时下意识想在self.onmessage里访问document,结果直接报错。Worker 的全局上下文是DedicatedWorkerGlobalScope,里面有self、postMessage、onmessage、fetch、setTimeout这些能力,但没有window、document、rAF。它的渲染管线在另一套系统里,毕竟它根本不负责画东西到屏幕。

但 Worker 里确实有一套完整的事件循环:message事件是它的主要宏任务来源;Promise.then、queueMicrotask这些微任务机制同样存在。也就是说,你在 Worker 里写的异步代码,执行顺序也遵守"宏任务→清空微任务"的规则,只不过它处理的不是 UI 任务,而是计算任务。

// main.js const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); worker.postMessage({ numbers: [1, 2, 3, 4, 5] }); worker.onmessage = (event) => { console.log('主线程收到结果:', event.data); }; // worker.js self.onmessage = (event) => { const { numbers } = event.data; const sum = numbers.reduce((acc, n) => acc + n, 0); self.postMessage({ sum }); };

3.2 postMessage 的通信成本到底有多大

这是 Worker 使用中最容易被低估的部分。postMessage默认采用结构化克隆算法,意思是你要传给 Worker 的数据会被复制一份,而不是直接把内存引用递过去。复制一个几兆字节的对象,开销相当可观;如果数据里还带着复杂的嵌套结构,克隆时间也很可观。某些情况下,克隆成本甚至可能超过你把这块计算放在主线程同步执行的耗时。

好在有可转移对象可以规避复制。像ArrayBuffer、MessagePort、ImageBitmap这类对象,可以通过 transfer 列表把"所有权"转移给目标线程,整个过程是零拷贝,代价是原线程里的这个对象会变成 detached,无法再使用。

// 假设要传一个 10MB 的数据区给 Worker const buffer = new ArrayBuffer(10 * 1024 * 1024); // 不转移:结构化克隆,复制一份,buffer 在主线程仍然可用 worker.postMessage({ buffer }); // 转移:零拷贝,主线程的 buffer 立即被 detach worker.postMessage({ buffer }, [buffer]); // 此时不能再访问 buffer,会抛出 TypeError

实际使用时,如果你高频地给 Worker 传大数据(比如每帧都在传一份点云数据),一定要用 transfer 方式,否则光是克隆就能把性能压倒肉眼可见的级别。

3.3 三种 Worker 各管一摊事,别用错

Worker 类型共享范围典型使用场景
Dedicated Worker只归属一个页面实例复杂计算、数据解析、图片像素处理
Shared Worker同源下的多个页面/tab 共享同一个实例多页面共享状态、广播通知
Service Worker站点级别的网络代理缓存资源、离线能力、消息推送

日常业务接触最多的还是 Dedicated Worker。Shared Worker 适合"多个页面需要共享一个计算实例"的场景,但它和页面之间的连接管理、生命周期处理相对繁琐,如果不是确实需要跨页面共享状态,不建议一开始就上。Service Worker 本质上也是一个 Worker,但它主要工作在网络层,它的事件循环模型关注的是fetch、push、install这些事件,通常被归到 PWA 的范畴里聊。

3.4 在 Worker 里做动画相关的事情需要注意的边界

Worker 里没有 rAF,但这不代表它完全和渲染沾不上边。通过OffscreenCanvas,你可以把一个 canvas 的全部绘制工作移到 Worker 里执行,主线程只保留一个很薄的容器层,这对 Canvas 密集绘制(比如粒子特效、图像滤镜)是质的提升。只是有一点:在 Worker 里没有requestAnimationFrame,如果要做逐帧动画,你需要用主线程的 rAF 每帧发一条消息通知 Worker "该画下一帧了",或者用setInterval顶着用——但定时器在高刷新率场景下的精度不够,动画节奏会变差。

所以一个更平衡的架构是:主线程用 rAF 驱动帧循环,Worker 负责每帧的数据计算或 canvas 绘制,消息作为"发令枪"。这种组合方式下面的章节会用一个完整案例展开。

4. 把两条时间线拧成一股绳:动画渲染与后台任务协作的真实案例

现在把 rAF 和 Worker 放到一起看。你会发现它们实际上是互补的:rAF 保证了"画"的节奏,Worker 保证了"算"的空间。一个实时图表项目如果量大,单线程做数据处理时必然卡住主线程的渲染;把计算丢给 Worker 后,主线程只需要在每帧消费"已经算好的最新结果"。但通信是异步的,消息到达的时机和渲染帧并不能天然对齐,所以需要一个合并节奏的中间层。

4.1 一个实时数据可视化的最小骨架

假设场景是:后端每秒钟推送数千个数据点,前端要做实时曲线绘制。数据点要经过校准、过滤、坐标变换才能画到 canvas 上,这些计算放主线程会导致图表卡顿。于是我们把它拆到 Worker:

// main.js const worker = new Worker('/data-worker.js'); let latestBatch = null; let rafId = null; worker.onmessage = (event) => { // 只保留最新一批数据,别急着立刻绘制 latestBatch = event.data; // 如果当前帧还没有安排渲染,则在下一帧绘制 if (rafId === null) { rafId = requestAnimationFrame(renderFrame); } }; function renderFrame(timestamp) { rafId = null; if (!latestBatch) return; // 绘制 curve drawChart(latestBatch, timestamp); latestBatch = null; } worker.postMessage({ cmd: 'start' });
//>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进