
hyperframes 这套东西最初并不是什么正经开源项目而是我们团队在跑一个数据大屏项目的时候被活生生逼出来的。那个项目要求一边跑着 50 多个实时图表一边还要保证地图上的飞线动画、数字滚动、雷达扫描这些特效在低端笔记本上不卡成 PPT我们试过硬调 CSS transition、试过 throttled rAF、也试过直接砍功能最后发现问题的核心不在动画本身而在“每一帧里我们到底干了多少活儿”。hyperframes 就是一套让我能把每一帧的开销算得清清楚楚、把渲染拆成可中断小任务的调度思路。这篇文章不会给你讲一个多么天花乱坠的框架而是把 hyperframes 背后的帧预算、分层渲染、任务抢占这些关键设计拆开配合可运行的代码示例聊一聊怎么在真实业务里把它落地。适合那些被 Web 复杂交互动效和可视化卡顿折磨过、又不想靠堆硬件解决问题的前端同学。1. 整体设计思路拆解hyperframes 不是动画库而是一套“帧预算”管理哲学1.1 从一次“掉帧事故”说起我复盘过一个很典型的线上事故某个业务页面加了实时折线图之后整页滚动都开始掉帧。当时大家第一反应是“图表库太吃性能”换了一个更轻量的图表库结果 FPS 从 25 提到 28依然不理想。后来我们打了 Performance 面板发现真正的问题出在滚动事件里一个很不起眼的getBoundingClientRect()调用——位置不同导致强制回流每次滚动都会触发一整条样式重算的连锁反应。而图表库渲染闪烁只是受害者不是凶手。这个经历让我意识到绝大多数卡顿都不是某一个库单独造成的而是整个事件循环里堆在“每一帧”里的任务太多太乱。我们平时写动画通常只关心“启动动画”和“结束回调”很少关心这一帧里到底跑了多少个scroll监听、多少个setState、多少次 DOM 读改写。hyperframes 的出发点就是把“帧”本身当作一个有限的资源桶来管理类似服务器里的 CPU 时间片每个动画、每个数据更新、每个读写操作都必须在分配给你的时间片内完成做不完就放到下一帧绝不硬撑。1.2 核心概念把 16.67 毫秒当成一条流水线我们知道普通 60Hz 屏幕下一帧的时间大概是 16.67ms1000 / 60。这是浏览器渲染一帧的总预算脚本执行、样式计算、布局、绘制、合成全都在这段时间里完成。hyperframes 做的第一件事就是把这段时间切成三段脚本任务段开发者自己的 JS 逻辑包括数据处理、状态更新、动画 tick。渲染提交段样式和布局的强制同步、requestAnimationFrame回调等。浏览器空闲余量段留给 GC、浏览器内部调度、以及非紧急任务。这个切分不是真的在时间线上画三条硬边界——浏览器根本不提供这么精准的 API——而是在代码层面建立一种“自觉”。hyperframes 强制你给每个任务预估一个cost运行时再通过真实执行时间动态调整。比如一个滚动缓动任务你可以给它 2ms 预算一个大数据表格的单元格更新可以给它 8ms 预算。当一帧内所有任务的预估成本加在一起超过 16.67ms 的时候调度器就会启动“降级”策略将低优先级任务推迟而不是让整帧爆掉。用一句大白话总结普通动画库关心的是“怎么把动画画完”hyperframes 关心的是“这一帧有没有余量画下一件事”。2. 核心细节解析与实操要点帧调度器、分层渲染和 LOD 降级到底怎么工作2.1 三个核心机制帧预算、分层渲染、LOD 降级hyperframes 不是单靠一个 rAF 循环就跑完所有事。它内部有三个互相配合的机制。帧预算Frame Budget是最基础的一层。它维护着一个全局时钟每次requestAnimationFrame回调触发时记录当前时间戳然后计算出距离上次回调已经过去了多久。如果发现实际帧间隔大于 16.67ms比如因为后台 Tab 切回来或者有一次长时间 GC调度器会进入“负债恢复”模式接下来几帧会主动压缩低优先级任务的预算把欠下的渲染开销慢慢补回来而不是在同一帧里狂补所有计算导致二次卡顿。分层渲染Layered Rendering是把页面上的动态内容按“变化频度”分成不同层级。高频层比如数字滚动、拖拽跟随、进度条它们每帧都可能变化中频层比如图表 data 更新几百毫秒一次低频层比如主题切换、布局重排几秒甚至几十秒才发生一次。hyperframes 会为每个层级分配独立的虚拟帧率比如高频层跑 60fps中频层跑 12fps低频层跑 2fps。这样能大幅减少无效渲染一个图表动画在 12fps 下和 60fps 下用户感知差异极小但 CPU 开销能省下 80%。LOD 降级Level of Detail Degradation则是最后一道安全网。当设备 CPU 长时间处于高负载或者电池电量低、系统开启了省电模式时hyperframes 会根据设备帧率自动降低各个层的最大帧率。比如把高频层从 60fps 降到 30fps把中频层从 12fps 降到 6fps。这个降级是平滑渐变的不会让动画突然跳变而是像水流变小一样慢慢降下来。如果你在代码里明确标记了某些动效是“必需的”比如倒计时结束的红点闪烁那么这部分可以豁免降级保证业务强提示不被吞掉。2.2 调度器的优先级与抢占为什么高优先级任务不能“永占”hyperframes 的调度器会维护一个任务队列每个任务有priority、budget、interruptible三个属性。每帧从高到低扫一遍先执行高优先级且必须连续完成的任务再执行中优先级可中断任务最后才轮得到低优先级“锦上添花”型任务。这里有个实操中非常容易踩的坑如果把某些任务标成priority: high且interruptible: false等于告诉调度器“这个任务一旦开始别打断我”。比如一个地图飞线的路径插值计算计算量很大如果不可中断可能会独占一整帧。hyperframes 的做法是把这类任务“分片”chunked每次只计算一段路径保存中间状态下一帧再继续算。所以你写代码的时候一定要把“大任务”拆成“小可恢复单元”否则调度器再聪明也没办法帮你插入其他任务。我们内部总结了一个优先级分配的经验表贴合常见业务场景任务类型优先级建议预算是否可中断用户拖拽反馈极高2ms否滚动位置同步高3ms是数字滚动/倒计时高2ms是图表数据刷新中4ms是列表渐入渐出中3ms是背景粒子运动低2ms是埋点统计上报低1ms是这套表格不是拍脑袋定的。拖拽反馈一旦中断用户能立刻感觉到“手跟不上了”必须让渡最大资源埋点和粒子运动稍微慢几帧用户根本感知不到。把预算和优先级定好后调度器在帧压力大的时候会自动“丢卒保车”。2.3 API 设计的最小可用集不折腾三五个方法是核心hyperframes 的对外 API 刻意保持精简核心就这几个createFrameRunner(options)创建一个基于 rAF 的运行实例支持配置targetFps、budgetRatio、autoDegrade。task(name, options)注册一个任务包含execute、priority、budget、interruptible。layer(name, fps)创建一个渲染层并指定它的虚拟帧率。frameMetrics()返回当前帧的实际执行耗时、任务列表、降级状态等用于性能面板展示。我见过有的团队一上手就把 API 设计得很宏大又是事件总线又是插件机制结果项目走到一半自己都绕晕了。hyperframes 最初的设计原则就是“只有调度的东西归调度器管真正的业务渲染依旧用你熟悉的 DOM 操作、Canvas 或 WebGL”。它不接管具体图表的绘制只管理这些绘制发生在哪一帧、占用多少时间。这样接入一个老项目时不需要重写视图层只需要把原来散落的动画逻辑包装成 task 就行。3. 实操过程与核心环节实现用 hyperframes 从零搭出流畅的实时监控面板3.1 第一步定义你的帧预算和运行参数假设我们要做一个实时监控面板包含一张折线图、一个数字滚动板、一组闪烁状态灯。先安装并初始化 hyperframes如果你用的是内部版思路完全一样核心代码也可以基于 rAF 自己封装npm install hyperframes然后创建运行实例import { createFrameRunner } from hyperframes; const runner createFrameRunner({ targetFps: 60, budgetRatio: 0.7, // 用 70% 的预算给脚本任务 autoDegrade: true, // 允许根据设备状态自动降级 });budgetRatio是一个值得解释的参数。很多人误以为设成 1.0 就代表“把所有帧时间都用来执行脚本”这是错的。浏览器每一帧除了执行 JS还要处理样式布局、绘制合成、垃圾回收。把脚本预算塞到 100%留给浏览器的余量就没了反而会因为 GC 或 layout 抢占而导致更大的卡顿。我一般建议从0.6~0.7起步如果是纯 Canvas 绘制不触发大量 DOM 布局可以放宽到0.8。这个值本质上是“你对自己代码可控性的信心指数”。3.2 第二步注册一个可中断任务并拆成“小步快跑”先看一个反面教材把整张折线图的数据更新放在一个任务里一次做掉“计算新数据 重绘所有点 更新坐标轴”。这个任务一旦开始可能要跑 12ms导致这一帧其他任务全被挤掉。hyperframes 里的正确写法是把更新拆成两段第一段负责计算数据差异第二段负责把差异绘制到 Canvas 上。import { task } from hyperframes; // 数据计算任务可中断预算 3ms task(data-diff, { priority: high, budget: 3, interruptible: true, execute(ctx) { // 只计算最近一次 batch 的增量数据 // ctx.frameIndex 是当前帧的序号 // ctx.remaining 是当前帧剩余预算 this.diffResult this.calculateIncrement(ctx.payload); if (!this.diffResult) { return; // 没有增量就立即结束 } } }); // Canvas 绘制任务分片绘制每帧只画 120 个点 task(chart-paint, { priority: high, budget: 4, interruptible: true, execute(ctx) { const start this.nextPointIndex || 0; const end Math.min(start 120, this.totalPoints); for (let i start; i end; i) { const p this.diffResult[i]; ctx2d.lineTo(p.x, p.y); } this.nextPointIndex end; if (end this.totalPoints) { this.nextPointIndex 0; // 本轮绘制完成 } } });你可能会问为什么绘制 120 个点而不是一口气画完因为在 4ms 预算内画 120 个lineTo是安全的但画 1200 个就会超。分片之后即便某一帧因为 GC 导致剩余预算只剩 1ms调度器也不会把你拦腰砍断到一半而崩溃——因为任务知道自己的进度下一帧接着画就行。这里的关键是必须在任务实例上保存nextPointIndex这样的进度状态不能依赖全局变量否则多个任务交错执行时会互相污染。3.3 第三步把不同节奏的内容放进不同渲染层降低无效时间数字滚动板每秒变化 10 次而折线图每秒最多只变化 2 次。如果都用 60fps 去驱动一半的帧都在做“没有数据变化的重绘”。用 hyperframes 的层机制可以这样区分import { layer } from hyperframes; const numberLayer layer(digits, { fps: 30 }); const chartLayer layer(chart, { fps: 4 }); runner.addLayer(numberLayer); runner.addLayer(chartLayer); layer(digits).task(ticker, { execute() { updateOnScreenDigits(); // 这里只是更新 DOM 文本 } }); layer(chart).task(chart-update, { execute() { requestChartRefresh(); // 合并数据后重绘 Canvas } });设置成 4fps 的折线图人眼看起来依旧会觉得它是“实时”的因为折线本身是连续变化的4fps 的重绘已经能覆盖数据变化的感知阈值。实测下来把图表和数字区做层拆分后整页动画相关的 CPU 占用直接降了一半。这个优化对低端机特别重要你把核心高频任务省下的时间分配到用户真正和页面交互的拖拽、滚动上体验会瞬间提升。3.4 第四步接入性能埋点让每一帧的开销都可量化没有汇报机制的调度器都是黑盒。hyperframes 提供frameMetrics()但真正好用的是把它接进日志系统。我会在每个任务执行结束后记录实际耗时与预算偏差runner.onFrameEnd((metrics) { // metrics: { frameId, totalTime, jank: true, overspendTasks: [] } if (metrics.jank) { logger.warn(frame over budget, { frameId: metrics.frameId, totalTime: metrics.totalTime, overspendTasks: metrics.overspendTasks.map(t t.name), }); } });然后把这些数据聚合成 Web Performance 指标或埋点到监控平台。设定一个阈值当一帧实际耗时超过 20ms 的比例大于 5% 时就该动手优化了。因为 16.67ms 是 60fps 的标准预算超过 20ms 意味着掉到了 50fps 以下用户已经能察觉到轻微顿挫。通过这种埋点我能在版本迭代时及时发现新增代码是否破坏了帧预算而不是等产品经理跑过来说“页面变卡了”。4. 常见问题与排查技巧实录跑了半年之后踩过的真坑4.1 问题速查表先对照现象再动手我把日常排查过程中遇到的高频问题整理成了一张表基本可以覆盖 hyperframes 落地时的 80% 情况现象可能原因排查手段通常解决方式动画时有规律地每 1~2 秒卡一下低优先级大任务周期触发导致某几帧超支在onFrameEnd里看overspendTasks把大任务拆成更小的分片或调整到低频率层后台切换到前台后连续卡 2 秒后台被节流的 rAF 积压了大量任务看 metrics.frameId 是否出现跳跃调度器从后台恢复时先清空过期的低优先级任务滚动页面时动画偶尔卡顿scroll 监听和 hyperframes 任务互相抢帧Performance 面板看长任务用 passive scroll 监听减少无谓的 preventDefault数字滚动在低端机上变慢任务实际耗时远超预算看任务内部是否有强制同步布局把 DOM 读写分离先统一读再统一写两个动画任务先后执行导致闪烁后一个任务覆盖了前一个的中间状态查看任务执行顺序用层隔离两个动画或给任务增加dependsOn依赖关系开启降级后任务迟迟恢复不到满帧autoDegrade设置过于激进看降级状态是否一直没还原调高降级恢复系数或手动在 UI 可见时重置降级这张表做出来后团队排查问题从“靠感觉试”变成了“先拉指标再定位”效率高了一个量级。如果你没用过 hyperframes也可以把它当作一个自检清单来参考因为大多数问题其实是通用性的前端性能问题只是你在普通代码里可能根本发现不了。4.2 几个我踩过的经典坑预算不是越紧越好过度优化也是病第一个坑把budgetRatio调得过低。有一版优化我为了给浏览器留余量把脚本预算压到 0.4结果动画没卡了反而所有任务都被强制分片本该一帧完成的滚动缓动变成了 6 帧完成手感变得异常“肉”。后来我把预算调回 0.65并给“用户拖拽反馈”任务单独开了“紧急特权”——这类任务不消耗普通预算直接从紧急帧池里扣。这相当于给关键交互开了一条专用通道效果立竿见影。第二个坑任务内部发生未捕获异常导致调度器认为任务成功执行完但实际啥也没画。hyperframes 的默认行为是在异常时吞掉并继续下一帧避免一个任务毁掉整个循环但也正因为这样异常信息很容易被静默。我后来在每个任务的execute里都套了一层try...catch并把异常抛到全局方便 Sentry 收集。否则你的画面上会出现一段不明所以的空窗但控制台干干净净。第三个坑让多个层都注册了pointermove监听。本来分层是为了降低无效重绘但如果每一层都监听鼠标位置再各自触发任务底层其实还是在重复计算。正确的做法是全局只保留一个pointermove监听把鼠标位置写入runner.context各层任务在下一帧从 context 里读取。这再次体现了“帧内状态共享”的价值。4.3 和 React、Vue 等框架共存的经验核心是让框架别碰帧调度一开始我很担心 hyperframes 和 React 的调度机制会不会打架。React 18 有并发特性startTransition会让更新低优先级化而 hyperframes 有自己的 rAF 循环两套调度同时存在确实可能有微妙冲突。我的经验是让 hyperframes 只负责“非框架渲染”的部分比如 Canvas 动画、DOM 文本的直接更新、基于 WebGL 的绘制而 React 组件状态更新仍走 React 自己的调度。在 React 层面只暴露“当前动画进度”这样的粗粒度状态用requestAnimationFrame驱动的动画不要去setState而是直接操作ref里的 DOM。比如数字滚动组件我不在滚动过程中每秒 30 次setState而是初始化时就拿到 DOM 节点由 hyperframes 任务直接更新textContent。等滚动结束后再通过一次setState告诉 React“动画完成同步一下最终值”。这样做的好处是React 的协调器完全不用参与高频动画它可以安静地处理其他业务更新。至于 Vue思路类似。Vue 的响应式系统在频繁触发时会依赖收集并更新视图我建议把高频变化的数据存成 plain object不要放进reactive或ref里而是通过自定义事件手动触发层任务。这样能避免 Vue 为每个临时值都创建依赖关系从而产生大量无意义的小更新。5. 写在最后的实操体会我在实际使用 hyperframes 的过程中最强烈的感受是它逼着我重新思考“动画到底应该怎么组织”。以前写 CSS animation 和 JS 定时器都是“点对点”地解决问题这里卡了加 hack那里卡了减动画。有了帧预算这个概念之后一切都变成了一道算术题这一帧有多少余量这个任务花多少钱。项目上线半年线上卡顿类工单从每月 30 多条降到了个位数而且剩下的几条大多集中在老旧的 IE 兼容部分——那又是另一个故事了。最后再分享一个小技巧如果你暂时不想引入任何依赖也可以按 hyperframes 的思路徒手写一个极简调度器——一个 rAF 循环、一个任务数组、一个累计耗时记录再加上两个if判断。真正值钱的是这套思考框架而不是某个特定实现。我后来在别的非前端场景比如 Electron 桌面端的内存监控、甚至视频处理的任务切分里也沿用了同样的预算思想。框架会过时可换但“把每一帧当作资源来管理”的思路不会。