ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

HyperFrames:Web 高频渲染性能优化实践

HyperFrames:Web 高频渲染性能优化实践

这段时间我的电脑风扇转得特别快,不是因为在跑模型,而是因为一个实时数据看板。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 Map<TaskId, Task>(); private renderQueue = new Map<TaskId, Task>(); private timer: ReturnType<typeof 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 展示一下实际运行过程:

  1. 0ms:WebSocket 推送第一笔行情,schedule('row-3', update)被调用,pendingTasks里多了row-3。
  2. 15ms:两条新行情到达,其中一条还是针对row-3,旧任务被覆盖。
  3. 50ms:定时器触发,flushPending()把所有 pending 任务挪进renderQueue,并请求 rAF。
  4. 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 到 60,CPU 占用从 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. 踩坑记录:超帧不是简单加个 setTimeout

4.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 为例,在一个超帧批次里你可能调用了多次setState,React 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,组件卸载时自动执行 clear:

const schedulerRef = useRef<HyperframeScheduler | null>(null); if (!schedulerRef.current) { schedulerRef.current = new HyperframeScheduler({ windowMs: 48 }); } useEffect(() => { return () => schedulerRef.current?.clear(); }, []);

别小看这一步,我当时线上出现页面切后台后 CPU 仍然飙高,查了半天就是调度器没清理,队列里堆了几千个僵尸任务。

5. 实测数据与调参逻辑:什么时候该上 HyperFrames

5.1 我在同一台机器上的前后数据对比

为了不让自己感觉良好,我把 HyperFrames 上线前后做了对比。测试环境是公司的标准测试机,MacBook Pro M1 Max,Chrome 最新版。测试页面是一个 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,我把适用条件总结成三个问题,你照着问一遍就好:

  1. 数据更新频率是否明显高于用户视觉感知频率?如果每 100ms 推送一次,但用户只关心每秒变化几次,那就有合并空间。如果是一个鼠标拖动调节的滑块,用户每拖动一个像素都希望看到即时反馈,那就别合并。
  2. 渲染过程是否能增量拆分?表格按行更新、图表按点追加、列表按项 patch,这些可以增量。如果每次变化都必须整体重绘,那超帧的收益主要只在节流层面,效果会打折扣。
  3. 你是否愿意接受 30ms 到 50ms 的确定性延迟?HyperFrames 本质上是用延迟换取稳定性。业务上如果对延迟极其敏感,比如倒计时、秒表、交互动画,建议不要用统一窗口,而是给这些任务走直通通道。

我后来把 HyperFrames 改成了支持多通道的模式:默认通道走 48ms 的窗口,另一个interaction通道直接执行不合并。这样表格和图表数据用超帧,鼠标悬浮提示条和拖拽逻辑不用。

5.3 后续还能往哪个方向扩展

HyperFrames 目前在我项目里已经稳定跑了几个月,但我认为它还能继续演进。一个方向是把任务优先级做成类似调度器的动态优先级,什么类型的更新优先合并,什么类型必须及时处理。另一个方向是把超帧窗口做成自适应,根据主线程的繁忙程度自动调整窗口大小:主线程空闲时窗口收窄,主线程繁忙时窗口放宽。

还有一个更激进的想法:把超帧的批次上下文传给 Canvas 的离屏渲染层,让数据处理、降采样、路径构建都放到 Web Worker 里,主线程只负责最后提交。这样 HyperFrames 的合并就不只是 DOM 层的合并,而是整个渲染管线的合并。

就我自己而言,HyperFrames 最让我满意的不是性能数字,而是它逼着我把“逻辑更新”和“渲染提交”彻底分开了。这个分离带来的收益远大于窗口本身。如果你手里也有类似的高频更新页面,建议先别急着写完整框架,复制前面那段调度器,加几个日志看看到底有多少次重复渲染被你忽略了。看到数字的那一刻,你大概就知道超帧这个东西值不值了。

返回列表