
这段时间我的电脑风扇转得特别快不是因为在跑模型而是因为一个实时数据看板。WebSocket 一秒钟推二三十条行情进来每一条都会触发组件更新、旧 DOM 注销和新 DOM 挂载CPU 火焰图烧成了一条连续的红线。后来我把问题收敛成一句话既然显示器并不需要每一毫秒都看到新数据为什么我的渲染循环要每一毫秒都跟着数据跳HyperFrames 这个名字就是从这个疑问里长出来的。当时我做的方案不算什么惊天动地的发明核心就是把“逻辑更新”和“绘制提交”从同一个 16.7ms 的帧窗口里拆开用一批合并过的“超帧”去替代一帧一帧的盲目重绘。我给它起名叫 HyperFrames既是做状态快照也是做渲染批处理。这篇文章不聊玄学只讲它怎么落地、代码怎么写、坑在哪里以及什么样的项目值得上这套东西。如果你也在做高频表格、Canvas 图表、或者滚动驱动的复杂动画这篇应该能让你少走弯路。1. 传统帧循环到底浪费在哪HyperFrames 的出发点1.1 一帧里真正该干的事和不该干的事浏览器一帧的周期是 16.7ms但这个时间并不是给你一个人用的。它要拆给 JavaScript 执行、样式计算、布局、绘制、合成器还有浏览器自己的一些后台任务。问题在于绝大多数普通业务代码并不会主动关心这个分配遇到数据到就更新遇到事件就 render结果就是你把 16.7ms 里的每一个间隙都填满了。我一开始犯的错误很典型WebSocket 每 50ms 推一次数据我就在 onmessage 里直接 setState然后 React 再去 diff、再去更新 DOM。数据推送本身是 20fps如果每次更新都引发一次完整的 reconcile 和 commit主线程负担是很重的。更麻烦的是当同时有多个组件订阅同一个数据源时一次推送可能触发十几个 setState每个 setState 都让 React 重新调度一次。你以为你写了 20 次渲染实际上浏览器为了把这些更新塞进连续帧里可能做了 40 次布局、80 次样式计算。那段时间我拆了一下 Performance 面板发现一个非常反直觉的现象真正绘制 DOM 的时间很小浪费最多的是等待、排队和重复计算样式。明明用户看到的只是表格里一个数字在跳浏览器却要为这个数字反复走完整渲染管线。HyperFrames 要做的第一件事就是把这些“一帧一跳”的精细动作合并成“一小段时间内只跳一次”的粗粒度动作。1.2 超帧的本质把 16.7ms 的窗口变成可伸缩的批次在传统帧循环里requestAnimationFrame是你唯一的节拍器。你在这个回调里做什么浏览器就认为这一帧需要做什么。但实际业务场景里很多更新并不需要真的在一个帧周期内完成。比如一个折线图的数据点在 500ms 内从 100 涨到 105中间经历了 10 帧你需要的只是终点和几个关键转折点而不是每一帧都从 100 涨到 100.5 就开始重绘。所以 HyperFrames 的思路是不再以“帧”为最小渲染单位而是以“超帧窗口”为单位。一个超帧窗口可能覆盖 30ms、50ms甚至在某些非交互场景下覆盖 100ms。在这个窗口内收到的所有更新请求先进入一个待处理队列等窗口关闭时再统一合并、去重、排序最后在真正的 rAF 回调里一次性地完成渲染提交。你可以把它理解成电梯运行的逻辑不是每来一个人就立即跑一趟而是等人差不多了再统一送一次。电梯的运行频率变低了但每个乘客的等待时间依然在可接受范围内。对于 Web 渲染来说这意味着主线程有更多空闲去处理布局、绘制、合成整体的帧稳定性反而会好很多。我还做了一张小表来对比传统方式和 HyperFrames 的差异放在团队评审会上当时争议不小维度传统 rAF 直更HyperFrames 超帧更新粒度每次事件触发一次渲染窗口内合并多次更新渲染次数高可能超过屏幕刷新率低通常与窗口频率一致主线程占用分散且脉冲式集中但更可控输入到输出延迟低但帧间不稳定略高但可配置适合场景鼠标拖拽、关键帧动画高频数据流、非交互图表这张表的结论是HyperFrames 不是什么场景都能用但它能解决一类特定的性能问题——数据更新频率远高于用户视觉感知频率的场景。2. 最小实现我用 200 行代码搭的超帧调度器2.1 调度的核心数据结构HyperFrames 的调度器核心其实不复杂我用 TypeScript 写了个最小版本只有三个数据结构一个pendingQueue用来收集窗口内的更新任务一个renderQueue用来存放已经合并完、等待 rAF 执行的任务再加一个taskMap做去重。先看核心接口这里只保留最必要的部分type TaskId string; interface Task { id: TaskId; execute: () void; priority?: number; } interface HyperframeOptions { windowMs: number; // 超帧窗口时长 maxTasksPerFrame?: number; // 每帧最多执行的任务数防止长帧 onError?: (e: Error) void; } class HyperframeScheduler { private pendingTasks new MapTaskId, Task(); private renderQueue new MapTaskId, Task(); private timer: ReturnTypetypeof setTimeout | null null; private rafId: number | null null; private windowMs: number; private maxTasksPerFrame: number; constructor(options: HyperframeOptions) { this.windowMs options.windowMs; this.maxTasksPerFrame options.maxTasksPerFrame ?? Infinity; } schedule(task: Task) { // 同一个 id 的更新直接覆盖不重复入队 this.pendingTasks.set(task.id, task); if (!this.timer) { this.timer setTimeout(() this.flushPending(), this.windowMs); } } private flushPending() { if (this.timer) { clearTimeout(this.timer); this.timer null; } // 把所有 pending 任务整体挪到 renderQueue for (const [id, task] of this.pendingTasks) { this.renderQueue.set(id, task); } this.pendingTasks.clear(); // 已经有 rAF 在排队就不再重复请求 if (this.rafId null) { this.rafId requestAnimationFrame(() this.processRenderQueue()); } } private processRenderQueue() { this.rafId null; let count 0; const start performance.now(); for (const [id, task] of this.renderQueue) { if (count this.maxTasksPerFrame) break; try { task.execute(); } catch (e) { this.options.onError?.(e); } this.renderQueue.delete(id); count; // 每帧执行超过 8ms 就主动让帧避免卡死 if (performance.now() - start 8) break; } // 如果还有剩余任务下一帧继续 if (this.renderQueue.size 0) { this.rafId requestAnimationFrame(() this.processRenderQueue()); } } clear() { if (this.timer) clearTimeout(this.timer); if (this.rafId ! null) cancelAnimationFrame(this.rafId); this.pendingTasks.clear(); this.renderQueue.clear(); this.timer null; this.rafId null; } }这段代码看起来简单但有几个边界要特别注意。第一pendingTasks用Map而不是数组天然带了去重能力同一个数据 key 即使推了十次最终只保留最后一次。第二flushPending只做“移动队列”的动作真正的任务执行延迟到 rAF这保证了数据合并是同步的、渲染提交是异步的。第三processRenderQueue里面做了 8ms 的帧预算检查防止一个超帧窗口攒下太多任务导致某一次渲染把整帧拖垮。2.2 合并更新与渲染提交的时序逻辑为什么需要pendingTasks和renderQueue两层队列直接把任务放进渲染队列不行吗我一开始也偷懒把所有任务直接塞进一个数组rAF 中扫描一次。但你推演一下就知道问题在 rAF 还没执行前如果又来了新任务它会在下一次 rAF 被执行就把窗口的概念抹掉了。HyperFrames 的窗口意义在于在窗口关闭前任务可以被持续覆盖和合并窗口关闭那一刻才生成一个不可变的渲染批次。这个时序逻辑很像操作系统的中断合并或者视频编码里的关键帧加增量帧窗口相当于一个收集阈值到达阈值后统一动作。我用一个简单的 timeline 展示一下实际运行过程0msWebSocket 推送第一笔行情schedule(row-3, update)被调用pendingTasks里多了row-3。15ms两条新行情到达其中一条还是针对row-3旧任务被覆盖。50ms定时器触发flushPending()把所有 pending 任务挪进renderQueue并请求 rAF。62ms浏览器在下一帧开头执行processRenderQueue()把合并后的任务一次执行完。从用户视角看最后一笔更新的延迟是 62ms比传统方式的 15ms 高但完全在可接受范围内。而主线程在这 62ms 里只做了一次真正的渲染提交而不是三次。我还加了一个maxTasksPerFrame的概念主要是为了防止在极端情况下比如首次加载时有 10000 个历史数据点需要 patch。如果没有这个限制一个超帧批次可能要用 300ms 来渲染那种卡顿比频繁小更新更让人抓狂。分段执行虽然把总时长拉长了但每一帧都有机会插入鼠标响应和滚动事件的回调用户不会觉得页面死了。3. 三个实战场景数据表格、Canvas 图表、滚动动画3.1 场景一实时行情表格入参从每秒 30 次变成每秒 5 次渲染我最初做 HyperFrames 就是为了解决实时行情表格。那是一个虚拟滚动的表格大约 500 行每行 12 列其中 6 列数字会随行情变动。WebSocket 推送频率是每秒大约 15 到 30 条增量消息涉及的行数可能只有 20 到 50 行。传统写法是每条消息来了就直接修改对应的行数据然后flushSync更新 DOM。当时表格用的是虚拟滚动库它内部有自己的一套渲染机制每次数据变化都可能触发列表重新测量即使视觉上只是某个单元格的数值变了。用 HyperFrames 重构后我在schedule时把task.id设为rowId - columnKey。这样同一个单元格在窗口内即使被推送了 5 次最终只需要更新一次。波动非常频繁的“最新价”字段被合并后的真实渲染频率降到了大约每秒 5 到 8 次。网页的 FPS 从 28 恢复到稳定的 55 到 60CPU 占用从 90% 降到 35%。这个场景里最值钱的一点是表格数据的视觉连续性并不靠高频渲染维持。数字从 100 跳到 101 再跳到 102用户只需要看到 102 就行了中间那两个中间值只有量化算法需要人眼用不到。所以 HyperFrames 不是砍了功能它砍的是冗余。3.2 场景二Canvas 粒子系统与折线的批量绘制表格处理完我又把 HyperFrames 搬到了 Canvas。我的项目里有个 Canvas 绘制实时时序折线的功能每一秒会追加 60 个数据点同时需要把网格、坐标轴、当前游标都画出来。以前我是在每个数据点到达后就调一次draw()Canvas 的绘制状态上下文来回切换fillRect、stroke、arc密集交错性能一塌糊涂。用 HyperFrames 之后我把采集到的数据点先放进数组窗口结束时一次性把所有新点追加到折线路径然后调用一次ctx.stroke()。这里还有个小技巧如果数据点超过 Canvas 宽度允许的分辨率那么在窗口内做降采样只保留极值点和转折点而不是把每一个点都塞进 path。scheduler.schedule({ id: chart-series-1, execute: () { const points takeWindowedSamples(buffer, width); chart.appendPoints(points); chart.render(); }, });这样同一个 Canvas 图表在 500ms 内可能只重绘 8 到 10 次而不是 30 次。对 CPU 的压力小很多。更重要的是Canvas 的绘制是确定性的重绘次数越低越不容易出现因为频繁清屏导致的闪烁。但要注意Canvas 场景里 HyperFrames 的窗口不能设太大。因为图表的游标或者 tooltip 如果延迟超过 100ms用户会明显感觉到鼠标和小地图数据之间的漂移。我的经验是图表主体数据窗口可以用 60ms 到 80ms而鼠标交互部分必须走独立的直接更新通道不进 HyperFrames。3.3 场景三滚动驱动动画的时间线合并滚动驱动的动画是另一个典型场景。页面上有十几个区块每个区块都根据滚动位置计算transform位移、透明度、旋转角度。以前的写法是window.addEventListener(scroll, () { requestAnimationFrame(updateAllBlocks); });看起来没什么问题但滚动事件一触发就会立刻把updateAllBlocks排到当前帧末尾。如果这一帧本来就是组件更新、新图片解码、网络回调挤在一起那updateAllBlocks的执行时间会非常靠后甚至拖到下一帧。更糟糕的是滚动事件触发的频率可能远高于屏幕刷新率尤其是高刷新率鼠标或触控板这会导致一帧内updateAllBlocks被调度多次。HyperFrames 在滚动场景里的调整方式是把滚动位置这个单一数据源作为 task 的唯一 id。滚动事件回调里只更新一个latestScrollY窗口结束后再一次性计算全部区块的状态。这样即使滚动每秒触发 120 次真正做样式计算的次数也不会超过你的屏幕刷新率太多。window.addEventListener(scroll, () { latestScrollY window.scrollY; scheduler.schedule({ id: scroll-y, execute: () applyScrollEffects(latestScrollY), }); }, { passive: true });注意schedule里用的 id 是固定的这样窗口内无论触发了多少次滚动事件最终只有最新一次滚动位置会被执行。这个模式对任何“以最新值为准”的高频事件都适用。三个场景走下来HyperFrames 真正解决的问题其实是降低渲染频率提高单次渲染的利用率。它不属于那种“银弹”级别的性能优化但对高频数据类页面确实有立竿见影的效果。4. 踩坑记录超帧不是简单加个 setTimeout4.1 窗口越大卡顿越不规律第一个坑是我自己挖的。刚开始做 HyperFrames 的时候我为了追求极致合并率把窗口设置成了 120ms。结果页面确实不频繁重绘了但出现了一种更难受的体验一切操作都像隔着一层水点击按钮后要等一会儿才响应滚动页面时内容跳着走。因为 120ms 的窗口覆盖了将近 8 个屏幕刷新周期用户的输入事件和窗口结束时间如果刚好错开所有操作都延迟至少 120ms。这种延迟比掉帧还让人抓狂掉帧只是看着难受延迟是感觉整个页面“不跟手”。后来我把窗口默认值改成了 48ms大概 3 个浏览器帧。48ms 的好处是它仍然能合并大部分高频推送同时延迟上限控制在 50ms 左右这个是交互设计里一个比较公认的“无感知”阈值。如果你的页面里存在大量直接交互建议窗口不要超过 50ms宁可多渲染几次也不要让用户觉得你在故意磨蹭。4.2 单帧批量提交导致的“三帧卡顿”把多个任务合并到一个批次里执行表面上减少了渲染次数但如果不控制单批次的任务量还是会把某一帧直接打崩。我遇到过一次很经典的“三帧卡顿”第一次 rAF 执行了 200 个任务耗时 100ms主线程被占满。浏览器强制跳过接下来 6 到 8 帧然后用户看到的是页面先冻住再直接跳到新状态中间完全没有任何过渡。解决方式就是我在最小实现里加入的maxTasksPerFrame和 8ms 帧预算。如果一个批次的任务太多就把它切成几个分片每个分片执行不超过 8ms剩下任务留到下一帧继续。这样做虽然让一个超帧的完全渲染时间变长了但单帧的耗时始终可控浏览器不会因为一次过度工作而“罢工”。这里有个非常反直觉的地方一次执行 50 个任务可能比一次性执行 200 个任务的平均耗时更低。因为 200 个任务的那一下会触发浏览器进入“连续跳过多个帧”的保护模式而 50 个任务分四帧执行每帧都能正常走完布局和绘制用户感知到的反而是更顺滑的。4.3 组件框架里的响应式陷阱如果你在 React 或者 Vue 项目里接入 HyperFrames会遇到框架自身响应式系统的干预。以 React 为例在一个超帧批次里你可能调用了多次setStateReact 18 之前并不会自动批量处理 setTimeout 里的状态更新所以每次setState都会触发一次渲染这就让 HyperFrames 的合并效果打了折扣。我在项目里用了两个办法一是把数据更新放到真正执行的前一步不要在 execute 里直接改 React 状态二是在 execute 里用unstable_batchedUpdates包一层或者干脆使用useSyncExternalStore让组件直接订阅 HyperFrames 的批次结果。这个坑最容易出现在“看上去降低了渲染频率实际性能却更差”的案例里。检查方法很简单在 Performance 面板看 React 的 commit 次数。如果 commit 次数没有因为你批量的任务而下降那说明响应式系统还在每任务一渲染需要调整 HyperFrames 和框架的接缝位置。4.4 生命周期与内存销毁HyperFrames 调度器本质上是全局的定时器加 rAF如果你在组件卸载时忘记调用clear()就会出现很隐蔽的内存泄漏。定时器可能在组件销毁后继续运行队列里残留的 execute 还在更新已经不存在的 DOM 节点。正确的处理方式是在每个页面的入口初始化调度器页面切走时统一 clear。如果同一个调度器要给多个组件共享最好形成一种“发布订阅”模式组件完成更新后主动上报“已销毁”调度器在 flush 时跳过这些 id。我自己的习惯是把 HyperFrames 调度器封装成一个 hook组件卸载时自动执行 clearconst schedulerRef useRefHyperframeScheduler | null(null); if (!schedulerRef.current) { schedulerRef.current new HyperframeScheduler({ windowMs: 48 }); } useEffect(() { return () schedulerRef.current?.clear(); }, []);别小看这一步我当时线上出现页面切后台后 CPU 仍然飙高查了半天就是调度器没清理队列里堆了几千个僵尸任务。5. 实测数据与调参逻辑什么时候该上 HyperFrames5.1 我在同一台机器上的前后数据对比为了不让自己感觉良好我把 HyperFrames 上线前后做了对比。测试环境是公司的标准测试机MacBook Pro M1 MaxChrome 最新版。测试页面是一个 500 行实时表格加两个 Canvas 图表WebSocket 模拟每秒 30 条行情推送持续 60 秒。指标传统 rAF 直更HyperFrames窗口 48ms平均 FPS29.657.2长任务50ms次数28434最大化页面 CPU 占用96%41%首屏可交互时间2.1s1.9s总渲染提交次数约 1800 次约 770 次渲染提交次数减少了接近 60%但 FPS 反而不降反升。这个数据也印证了我前面的判断浏览器帧稳定的关键不是少干活而是不要把活集中在一帧里干完。HyperFrames 把高频但轻量的任务合并成低频但可控的任务整体负载反而更低。5.2 如何判断你的场景适不适合超帧不是所有页面都适合用 HyperFrames我把适用条件总结成三个问题你照着问一遍就好数据更新频率是否明显高于用户视觉感知频率如果每 100ms 推送一次但用户只关心每秒变化几次那就有合并空间。如果是一个鼠标拖动调节的滑块用户每拖动一个像素都希望看到即时反馈那就别合并。渲染过程是否能增量拆分表格按行更新、图表按点追加、列表按项 patch这些可以增量。如果每次变化都必须整体重绘那超帧的收益主要只在节流层面效果会打折扣。你是否愿意接受 30ms 到 50ms 的确定性延迟HyperFrames 本质上是用延迟换取稳定性。业务上如果对延迟极其敏感比如倒计时、秒表、交互动画建议不要用统一窗口而是给这些任务走直通通道。我后来把 HyperFrames 改成了支持多通道的模式默认通道走 48ms 的窗口另一个interaction通道直接执行不合并。这样表格和图表数据用超帧鼠标悬浮提示条和拖拽逻辑不用。5.3 后续还能往哪个方向扩展HyperFrames 目前在我项目里已经稳定跑了几个月但我认为它还能继续演进。一个方向是把任务优先级做成类似调度器的动态优先级什么类型的更新优先合并什么类型必须及时处理。另一个方向是把超帧窗口做成自适应根据主线程的繁忙程度自动调整窗口大小主线程空闲时窗口收窄主线程繁忙时窗口放宽。还有一个更激进的想法把超帧的批次上下文传给 Canvas 的离屏渲染层让数据处理、降采样、路径构建都放到 Web Worker 里主线程只负责最后提交。这样 HyperFrames 的合并就不只是 DOM 层的合并而是整个渲染管线的合并。就我自己而言HyperFrames 最让我满意的不是性能数字而是它逼着我把“逻辑更新”和“渲染提交”彻底分开了。这个分离带来的收益远大于窗口本身。如果你手里也有类似的高频更新页面建议先别急着写完整框架复制前面那段调度器加几个日志看看到底有多少次重复渲染被你忽略了。看到数字的那一刻你大概就知道超帧这个东西值不值了。