资讯详情

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

📅 2026/10/16 16:42:04 | 华诺云谱 👁 阅读
深入理解事件循环: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 });//>
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑