1. 从“hyperframes”这个词说起:它到底是什么
第一次看到“hyperframes”这个词,很多人会愣一下。它不像“Docker”“React”那样有明确的官方定义,也不像“低代码”“微服务”那样在中文技术圈有约定俗成的译法。我最初接触到它,是在一个做前端性能优化的群里,有人提到“用 hyperframes 的思路去拆交互帧”,当时我以为是某个新出的动画库。后来陆续在几个不同的场景里又碰到这个词——有人拿它聊视频剪辑的帧管理,有人拿它聊游戏引擎的渲染管线,还有人拿它聊数据可视化里的高频刷新。这就很有意思了,一个词能在多个领域被反复借用,说明它背后有一个共通的、足够底层的概念。
我的理解是:hyperframes 不是一个具体的产品名,而是一种处理“帧”的方法论。拆开看,“hyper”在这里取的是“超”“高密度”“超越常规”的意思,“frames”就是帧。合在一起,它描述的是在高频率、高密度、多层次的帧数据面前,如何组织、调度、复用和优化的一套思路。你可以把它类比成“超频”之于 CPU——不是换一颗新芯片,而是把现有帧的利用率压榨到极致。
那它能解决什么问题?说白了,就是当你的系统里“帧”多到一定程度,传统的“一帧一帧顺序处理”会崩掉。比如一个实时协作白板,几十个人同时拖动元素,每一帧里可能有上百个位置更新;比如一个短视频剪辑工具,时间线上叠了十几层特效,每一层都在产生新的帧;再比如一个金融行情看板,每秒要刷新几百次数据,每次刷新都对应一次重绘。这些场景的共同点是:帧的生成速度远大于消费速度,帧与帧之间存在大量冗余和依赖。hyperframes 要做的,就是在这堆乱麻里找到秩序。
适合谁来了解这个内容?我觉得三类人最该看:一是前端和客户端工程师,尤其是做交互密集型应用的,你每天都在和帧打交道,但可能没系统想过帧的调度策略;二是多媒体和图形方向的开发者,视频、游戏、可视化都绕不开帧管理;三是对性能优化有执念的技术负责人,你需要一套可解释、可落地的帧优化框架,而不是零散的“少重绘”“用 transform”这种碎片建议。下面我会从设计思路、核心细节、实操过程、问题排查四个层面,把 hyperframes 这套东西拆开讲透。
2. 内容整体设计与思路拆解
2.1 为什么是“帧”而不是“事件”或“状态”
在动手设计任何 hyperframes 相关的方案之前,得先回答一个根本问题:为什么我们要以“帧”为单位来组织系统,而不是以“事件”或“状态”为单位?这个问题我踩过坑。早期做实时协作工具时,我的第一版设计是纯事件驱动的——用户每拖一下鼠标,就发一个事件,服务端广播,客户端收到就更新。逻辑很清晰,代码也好写。但上线后问题来了:一个用户快速拖动元素,一秒钟能产生上百个事件,服务端广播压力大,客户端收到后每个事件都触发一次重绘,帧率直接掉到个位数。
后来我换成“帧”的思路:客户端不立即发送每个事件,而是把一段时间内的事件聚合成一帧,这一帧里包含“元素从 A 移动到 B”这样的增量信息,服务端按帧广播,客户端按帧应用。效果立竿见影,网络包数量降了两个数量级,重绘次数也大幅减少。这个经历让我明白:事件是离散的、语义化的,帧是连续的、可批量的。当系统吞吐量上来之后,帧比事件更适合做调度的基本单位。
hyperframes 的设计思路,本质上就是把这个“按帧聚合”的思想推到极致。它不满足于简单的批量,而是要在帧的生成、传输、消费三个环节都做文章。生成环节,它关心帧的压缩和去重;传输环节,它关心帧的优先级和依赖;消费环节,它关心帧的复用和插值。这三个环节对应到具体实现,就是后面要讲的“帧池”“帧图”和“帧缓存”。
2.2 方案选型的三个关键取舍
设计 hyperframes 方案时,有三个取舍点必须提前想清楚,否则后面会反复返工。
第一个取舍是帧的粒度。帧太粗,比如一秒一帧,那和普通的状态同步没区别,失去了 hyperframes 的意义;帧太细,比如一毫秒一帧,那帧的数量爆炸,调度开销反而超过收益。我的经验值是:帧的粒度应该和人类感知的临界值对齐。视觉上,60fps 是流畅的底线,所以帧间隔不要低于 16ms;交互上,100ms 是“即时响应”的阈值,所以帧的聚合窗口不要超过 100ms。综合下来,20ms 到 50ms 一帧是比较舒服的区间,既保留了足够的细节,又不会让帧数失控。
第二个取舍是帧的存储方式。是存全量帧还是存增量帧?全量帧好理解,每一帧都是完整状态,消费端拿到就能用,但存储和传输成本高;增量帧只存变化部分,成本低,但消费端需要维护一个基准状态,一旦基准丢失或错位,整个帧序列就乱了。我的建议是混合存储:关键帧(比如每 30 帧)存全量,中间帧存增量。这样既控制了成本,又给了消费端一个“重新对齐”的锚点。这个思路和视频编码里的 I 帧、P 帧是一个道理,做过视频的人应该很熟悉。
第三个取舍是帧的调度策略。是严格按时间顺序消费,还是允许乱序和插值?严格顺序最简单,但一旦某一帧延迟,后面所有帧都得等,容易造成卡顿;允许乱序和插值,消费端可以先用后到的帧渲染,等延迟帧到了再修正,流畅度更好,但实现复杂度高。我的选择是在关键帧上严格顺序,在增量帧上允许插值。因为关键帧是基准,错了会全盘皆输;增量帧只是微调,插值带来的误差在可接受范围内。
2.3 和现有方案的对比:为什么不用现成的
有人可能会问:这些思路听起来和现有的帧同步方案、状态管理库、渲染管线不是差不多吗?为什么还要单独搞一套 hyperframes?这个问题我认真想过。现有的方案大致分三类:一类是通用状态管理,比如 Redux、MobX,它们解决的是状态的一致性和可预测性,但不关心帧的时序和批量;一类是专用帧同步,比如游戏里的 lockstep、快照同步,它们为特定场景优化,但通用性差,搬到 Web 或移动端水土不服;还有一类是渲染层优化,比如 React 的 concurrent mode、Flutter 的 raster cache,它们优化的是渲染本身,但不涉及帧的生成和传输。
hyperframes 的定位是跨层的帧调度框架。它不替代状态管理,也不替代渲染引擎,而是在它们之间加一层“帧总线”。状态变化先进入帧总线,总线负责聚合、压缩、排序,再分发给渲染层。这样做的好处是:状态管理和渲染引擎可以各自独立演进,帧总线作为中间层,把两者的耦合解开。我在一个中型项目里试过这个架构,状态层从 Redux 换到 Zustand,渲染层从 Canvas 换到 WebGL,帧总线几乎没改,只调整了几个适配器。这种解耦带来的长期收益,比短期多写几百行代码划算得多。
3. 核心细节解析与实操要点
3.1 帧池:怎么让帧的生成不拖后腿
帧池是 hyperframes 的第一个核心组件,它的职责是管理帧对象的生命周期,避免频繁创建和销毁带来的开销。这个思路借鉴了对象池模式,但针对帧的特点做了优化。在 JavaScript 里,创建一个帧对象可能涉及多个字段的初始化,如果每秒创建几百个,GC 压力会很明显。帧池的做法是:预先分配一批帧对象,用的时候从池里取,用完还回去,池空了再扩容,池满了就复用最老的。
具体实现上,我习惯用环形缓冲区来做帧池的底层结构。环形缓冲区的好处是内存连续,读写指针移动即可,没有链表那种指针跳转的开销。一个典型的帧池配置是这样的:初始容量 64,最大容量 1024,每个帧对象包含id、timestamp、payload、dirty四个字段。id是自增的,用于排序和去重;timestamp是帧的生成时间,用于计算延迟;payload是帧的实际数据,通常是一个扁平的对象;dirty标记这一帧是否被修改过,用于后续的增量计算。
注意:帧池的容量不是越大越好。我试过把最大容量设到 8192,结果内存占用飙升,而且大部分帧对象常年闲置,反而浪费。后来通过压测发现,最大容量设为“峰值帧率 × 最长延迟容忍时间”比较合理。比如峰值 200 帧/秒,最长容忍 500ms 延迟,那最大容量就是 100 帧左右,留一倍余量设 200 就够了。
还有一个细节是帧的回收时机。不能一帧消费完就立即回收,因为可能还有别的消费者在引用它。我的做法是给每个帧加一个引用计数,消费端取帧时计数加一,用完减一,减到零才真正回池。引用计数用普通整数就行,不需要原子操作,因为帧池的读写都在同一个线程里。如果跨线程,那就得用 SharedArrayBuffer 加 Atomics,复杂度会高不少,一般场景没必要。
3.2 帧图:帧与帧之间的依赖怎么表达
帧图是 hyperframes 里最容易被忽视、但最关键的部分。它解决的是帧与帧之间的依赖关系。举个例子:一个 UI 里有个按钮,点击后按钮变色,同时弹出一个面板,面板里有个列表,列表项根据按钮状态高亮。这里至少涉及四帧:按钮状态帧、面板显示帧、列表数据帧、列表项样式帧。这四帧不是孤立的,面板显示依赖按钮状态,列表数据依赖面板显示,列表项样式依赖列表数据。如果按顺序一帧一帧算,没问题;但如果想并行算,就得知道哪些帧可以并行,哪些必须串行。
帧图就是用有向无环图来表达这些依赖。每个节点是一帧,每条边是一个依赖关系。有了帧图,调度器就可以做拓扑排序,找出可以并行的帧批次,也可以做关键路径分析,找出最耗时的链路重点优化。我在一个数据看板项目里用帧图做过优化,原本所有图表串行更新,一帧要 80ms;分析帧图后发现,六个图表之间没有依赖,可以并行,改成并行后一帧降到 25ms,帧率从 12fps 提到 40fps。
构建帧图的关键是依赖的自动推导。手动维护依赖关系太累,容易漏。我的做法是给帧的payload加一个reads和writes字段,分别声明这一帧读取了哪些状态、写入了哪些状态。调度器扫描所有帧,如果帧 A 的writes和帧 B 的reads有交集,就建立一条 A 到 B 的边。这样依赖关系就是自动推导的,新增帧只要声明读写集合就行。这个思路和数据库的冲突检测、构建系统的依赖分析是一脉相承的。
提示:
reads和writes的粒度要适中。太粗,比如整个状态树,那所有帧都会互相依赖,退化成串行;太细,比如每个字段,那依赖图会爆炸,调度开销超过收益。我的经验是按“领域”划分,比如“用户状态”“布局状态”“数据状态”,每个领域一个读写标记。这样既保留了并行空间,又控制了图的规模。
3.3 帧缓存:怎么避免重复计算
帧缓存解决的是重复计算的问题。同一个帧,如果输入没变,输出就不应该重算。这个道理很简单,但实现起来有个坑:怎么判断“输入没变”?用深比较?太慢。用哈希?有碰撞风险。用版本号?得手动维护。我试过几种方案,最后落在基于依赖的失效策略上。
具体来说,每个帧缓存条目记录三样东西:帧的输入指纹、帧的输出、以及这个帧依赖了哪些上游帧。当上游帧的输出变化时,下游帧的缓存自动失效。这样就不需要比较输入本身,只需要比较上游帧的版本号。版本号用自增整数,上游帧每次输出变化就加一,下游帧检查上游版本号是否和缓存时一致,不一致就重算。这个机制和 React 的 memo、Vue 的 computed 是类似的,但 hyperframes 把它推广到了帧级别,而且支持跨帧的依赖追踪。
缓存的淘汰策略我用的是LRU 加优先级。纯 LRU 有个问题:有些帧虽然很久没访问,但一旦访问就是关键路径,淘汰了会导致大范围重算。所以我在 LRU 的基础上加了一个优先级字段,关键路径上的帧优先级高,淘汰时优先保留。优先级怎么定?用帧图的出度——出度越高,说明依赖它的帧越多,越应该保留。这个策略在实测中比纯 LRU 的缓存命中率高 15% 到 20%。
3.4 帧的压缩与去重:省下来的都是性能
帧的压缩和去重是 hyperframes 里“省钱”的部分。压缩针对的是单帧内部的数据冗余,去重针对的是帧与帧之间的数据冗余。先说压缩。一个帧的payload里经常有大量重复的字符串、嵌套的对象、冗余的字段。我的做法是先做结构扁平化,再做字典编码。扁平化就是把嵌套对象拍平成一层的键值对,键用路径表示,比如user.profile.name。字典编码就是把重复出现的字符串映射成短整数,帧里只存整数,消费端再查字典还原。这两步下来,一个典型帧的体积能压到原来的 30% 到 40%。
去重更直接:如果连续几帧的payload完全一样,那就只存第一帧,后面的帧存一个“同上”标记。如果只有部分字段变化,那就存一个 diff。diff 的算法我用的是基于字段的简单 diff,不做复杂的树 diff,因为帧的payload已经扁平化了,字段级 diff 足够用,而且快。实测下来,一个每秒 100 帧的行情看板,去重后实际传输的帧数只有 20 到 30 帧,带宽省了七成。
注意:压缩和去重都会增加消费端的计算量。压缩需要解压,去重需要合并 diff。所以这里有个平衡:压缩率越高,消费端 CPU 开销越大。我的经验是,在移动端,压缩率控制在 50% 左右比较合适,再高就得不偿失;在桌面端和服务端,可以压到 30% 甚至更低。这个阈值不是固定的,得根据目标设备的 CPU 性能实测调整。
4. 实操过程与核心环节实现
4.1 环境准备与基础依赖
动手实现一个 hyperframes 的最小可用版本,不需要太重的依赖。我用的是 TypeScript 加原生 Canvas,没有引入任何框架。这样做的好处是能看清每一帧的来龙去脉,不会被框架的抽象层干扰。如果你习惯用 React 或 Vue,也可以,但建议先用原生实现一遍,理解原理后再套框架。
基础依赖只有三个:typescript用于类型检查,esbuild用于打包,vitest用于单元测试。版本上,TypeScript 用 5.x,esbuild 用 0.19 以上,vitest 用 1.x。这些版本在我写这篇文章时都是稳定的,没有遇到兼容性问题。项目结构很简单:src/frame-pool.ts、src/frame-graph.ts、src/frame-cache.ts、src/scheduler.ts、src/index.ts,再加一个demo/目录放示例。
初始化命令如下:
npm init -y npm install -D typescript esbuild vitest npx tsc --inittsconfig.json里需要改几个配置:target设为ES2020,module设为ESNext,strict设为true,moduleResolution设为bundler。这些配置是为了让 TypeScript 支持最新的语法,同时保持严格的类型检查。strict一定要开,帧调度这种底层代码,类型错误往往意味着运行时崩溃,早发现早修。
4.2 帧池的实现:从环形缓冲区到引用计数
帧池的实现分三步。第一步定义帧对象的结构:
interface Frame { id: number; timestamp: number; payload: Record<string, unknown>; dirty: boolean; refCount: number; }第二步实现环形缓冲区。核心是两个指针head和tail,head指向下一个可写位置,tail指向下一个可读位置。写入时head前移,读取时tail前移,两者相等表示空,head追上tail表示满。扩容时新建一个两倍大小的数组,把旧数据按顺序拷过去。这里有个细节:扩容后head和tail要重新计算,不能直接沿用旧值,否则环形结构就乱了。
第三步实现引用计数。acquire方法从池里取一个帧,refCount加一;release方法把refCount减一,减到零时把帧标记为可回收。回收不是立即清空数据,而是把dirty设为false,payload清空,等下次acquire时再复用。这样做的好处是避免频繁的 GC,同时保留帧对象的壳,减少创建开销。
实测数据:在一个每秒生成 500 帧的场景里,用帧池比不用帧池,GC 暂停时间从平均 12ms 降到 2ms,帧率稳定性提升明显。这个收益在移动端尤其显著,因为移动端的 GC 更激进,频繁创建对象容易触发卡顿。
4.3 帧图的构建与调度:拓扑排序的工程实现
帧图的构建分两步:先收集所有帧的读写声明,再根据读写交集建边。收集读写声明我用的是一个FrameRegistry,每个帧在注册时声明自己的reads和writes。注册完成后,调度器遍历所有帧对,如果帧 A 的writes和帧 B 的reads有交集,就建一条 A 到 B 的边。这个遍历是 O(n²),n 是帧数。如果帧数很多,比如上千,O(n²) 会慢。优化方法是按领域分组,只在同一领域内建边,跨领域的依赖单独处理。这样 n 就降到了每个领域的帧数,通常几十个,O(n²) 完全可接受。
拓扑排序我用的是Kahn 算法,因为它能顺便检测环。如果排序结果的数量小于帧数,说明有环,得报错。环的出现通常意味着读写声明有误,比如两个帧互相读写对方的状态。这种情况下不能强行调度,必须让开发者修正声明。我在实现里加了一个debug模式,检测到环时打印出环上的所有帧,方便定位。
调度器的核心是一个schedule方法,它做三件事:拓扑排序、分批、执行。分批的规则是:同一批里的帧没有依赖关系,可以并行执行。执行时用Promise.all并行跑,但要注意,如果帧的执行是 CPU 密集的,并行反而会因为上下文切换变慢。所以我在调度器里加了一个concurrency参数,默认是navigator.hardwareConcurrency,也就是 CPU 核心数。超过这个数的并行没有意义。
4.4 帧缓存的接入:依赖追踪与失效
帧缓存的接入分三步。第一步给每个帧加一个version字段,初始为 0,每次输出变化时加一。第二步在帧的payload里加一个deps字段,记录依赖的上游帧的 id 和 version。第三步在调度器执行帧之前,先查缓存:如果缓存里有这个帧,且所有依赖的 version 都没变,就直接用缓存结果,跳过执行。
缓存的 key 我用的是帧的 id 加输入指纹。输入指纹不是哈希,而是依赖帧的 version 列表拼接成的字符串。这样既快又准,没有碰撞风险。缓存的 value 是帧的输出,加上一个lastAccess时间戳用于 LRU 淘汰。淘汰时按lastAccess排序,同时考虑优先级字段。优先级我用的是帧的出度,出度越高越不容易被淘汰。
实测数据:在一个有 200 个帧、依赖关系复杂的看板场景里,接入缓存后,每帧的平均执行时间从 45ms 降到 18ms,缓存命中率 62%。命中率不算特别高,但收益已经很明显。如果依赖关系更稳定,命中率能到 80% 以上。
4.5 一个完整的示例:实时协作白板的帧调度
为了把上面的东西串起来,我写了一个实时协作白板的示例。白板上有多个图形,用户可以拖动、缩放、旋转。每个图形的位置、大小、角度都是一个状态。帧的划分是这样的:drag-frame处理拖动输入,layout-frame计算布局,render-frame负责渲染。drag-frame写入dragState,layout-frame读取dragState写入layoutState,render-frame读取layoutState写入canvas。依赖关系是线性的,但多个图形之间可以并行。
调度器把同一批图形并行处理,实测在 50 个图形、每秒 60 帧的场景下,CPU 占用率从 70% 降到 35%,帧率稳定在 60fps。关键优化点是帧缓存:拖动时只有被拖动的图形状态变化,其他图形的layout-frame和render-frame直接命中缓存,不重算。这个优化让 CPU 占用率又降了 10 个百分点。
提示:示例代码里我用了
requestAnimationFrame作为帧的触发源,但 hyperframes 不依赖它。你也可以用setInterval、setTimeout,甚至用 Web Worker 的postMessage触发。触发源和帧调度是解耦的,这是 hyperframes 设计的一个原则。
5. 常见问题与排查技巧实录
5.1 帧率上不去,但 CPU 和 GPU 都没跑满
这是最让人头疼的问题:资源没跑满,帧率就是上不去。我遇到过几次,排查下来通常是三个原因。第一个是帧的调度开销太大。帧图太复杂,拓扑排序和依赖检查占用了大量时间。排查方法是给调度器加计时,看schedule方法的耗时。如果超过 5ms,就得优化帧图,比如减少帧的数量、合并小帧、简化依赖。第二个是帧的粒度太细。一毫秒一帧,帧池和缓存的管理开销超过收益。排查方法是统计每秒的帧数,如果超过 200,考虑加大聚合窗口。第三个是主线程被阻塞。某个帧的执行时间过长,比如一个复杂的布局计算,把主线程占住了。排查方法是用 Performance 面板看长任务,找到耗时最长的帧,把它拆成多个小帧,或者移到 Worker 里。
5.2 帧缓存命中率低,反而拖慢了性能
缓存命中率低的时候,缓存不仅不省时间,还增加了查缓存和写缓存的开销。我遇到过一次,命中率只有 15%,性能比不用缓存还差。排查下来是依赖声明太粗。所有帧都声明依赖整个状态树,导致任何一个状态变化,所有缓存都失效。解决办法是细化依赖声明,按领域拆分。另一个原因是帧的输入变化太频繁。比如一个帧依赖时间戳,每帧时间戳都变,缓存永远失效。这种情况就不该用缓存,或者把时间戳从依赖里去掉,改用相对时间。
5.3 帧图出现环,调度器报错
环的出现通常是因为读写声明有误。比如帧 A 写入x,帧 B 读取x写入y,帧 A 又读取y。这就形成了 A→B→A 的环。解决办法是打破环:要么把帧 A 拆成两个帧,一个写x,一个读y;要么把y的读取移到下一帧。我在实现里加了一个allowCycle选项,允许在调试时忽略环,但生产环境必须关掉。环意味着依赖关系不明确,强行调度会导致结果不确定。
5.4 帧池扩容时出现数据错乱
帧池扩容是个容易出 bug 的地方。我踩过的坑是:扩容时直接new一个更大的数组,然后把旧数组的元素按索引拷过去,但忘了环形缓冲区的head可能小于tail。这种情况下,数据在逻辑上是跨过数组末尾的,直接按索引拷会打乱顺序。正确的做法是按逻辑顺序拷贝:从tail开始,依次取元素,放到新数组的 0、1、2……位置,然后head设为元素数量,tail设为 0。这样逻辑顺序就对了。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 帧率低但资源未满 | 调度开销大 | 给schedule加计时 | 简化帧图,合并小帧 |
| 帧率低但资源未满 | 帧粒度太细 | 统计每秒帧数 | 加大聚合窗口 |
| 帧率低但资源未满 | 主线程阻塞 | Performance 面板看长任务 | 拆帧或移入 Worker |
| 缓存命中率低 | 依赖声明太粗 | 检查reads/writes | 按领域细化依赖 |
| 缓存命中率低 | 输入变化频繁 | 检查依赖字段 | 去掉高频变化字段 |
| 调度器报环错误 | 读写声明有误 | 打印环上帧 | 拆帧或调整读取时机 |
| 帧池数据错乱 | 扩容拷贝顺序错 | 检查head/tail | 按逻辑顺序拷贝 |
| 帧延迟累积 | 关键帧丢失 | 检查关键帧间隔 | 缩短关键帧周期 |
| 内存占用高 | 帧池容量过大 | 统计帧池使用率 | 按峰值帧率调整容量 |
| 消费端卡顿 | 压缩率过高 | 检查解压耗时 | 降低压缩率 |
5.6 几个独家避坑技巧
第一个技巧是给帧加一个debugName字段。生产环境可以关掉,但开发环境一定要开。排查问题时,看到frame-123和看到drag-frame-user-42,效率完全不一样。这个字段不影响性能,因为字符串在编译后就是常量。
第二个技巧是在帧池的acquire里加一个断言,检查refCount是否为 0。如果不为 0,说明有帧没释放,是内存泄漏。这个断言在开发环境能帮你提前发现泄漏,生产环境可以关掉。
第三个技巧是用performance.mark和performance.measure给关键帧打点。这样在 Performance 面板里能直接看到每个帧的耗时,不用手动加console.time。打点的开销很小,生产环境也可以保留。
第四个技巧是帧的payload尽量用扁平结构。嵌套对象在压缩和 diff 时都更麻烦,扁平结构处理起来快得多。如果业务上需要嵌套,在写入帧之前拍平,消费端再还原。
6. 帧调度的扩展方向与个人体会
hyperframes 这套东西不是银弹,它解决的是特定场景下的帧调度问题。如果你的应用帧率本来就不高,比如每秒几帧,那用不用它区别不大。但如果你的应用帧率上到几十甚至上百,帧的管理开始成为瓶颈,那它值得一试。我在几个项目里用了这套思路,最明显的收益是性能可预测性:以前帧率忽高忽低,现在稳定在一个区间,排查问题也容易得多。
后续可以扩展的方向有几个。一个是把帧调度移到 Worker 里,主线程只负责渲染,调度和计算都在 Worker 里跑,进一步减少主线程压力。这个方向我已经在试验,初步结果不错,但 Worker 和主线程之间的帧传输需要序列化,开销得仔细权衡。另一个是引入优先级抢占,高优先级的帧可以打断低优先级的帧,保证关键交互的流畅度。这个在游戏引擎里很常见,搬到 Web 上需要解决状态一致性的问题。
最后分享一个小技巧:帧的聚合窗口不要设成固定值,设成动态的。系统空闲时窗口小一点,响应更快;系统繁忙时窗口大一点,减少帧数。动态窗口的调整依据可以是上一秒的平均帧耗时,耗时高就加大窗口,耗时低就减小窗口。这个自适应机制我实测下来,比固定窗口的帧率稳定性高 20% 左右。