帧事务、时序与 effect
从"顿挫"到"丝滑":帧事务、时序与 effect——一个图表引擎的状态模型
现象一句话版:高频拖拽 K 线时,画面偶尔一顿一顿;打开性能面板,高频
pointermove每次都触发一轮微任务链。修到最后发现,真正的问题不是"事件太频繁",而是多个异步时序在争抢同一份视觉状态。
这篇文章以 KLineChartQuant 引擎的真实重构为主线,讲清楚三个容易被混为一谈的概念:帧事务(Frame Transaction)、时序(Timing)、effect(副作用)。它们不是三套独立机制,而是同一件事的三个侧面:当"状态"和"画面"要严格一致时,谁来、在哪、以什么顺序把状态变成像素。文章会一路走到这次重构的收尾,包括在复查中发现并修掉的两个新问题——因为它们恰好是同一原则的两个反例。
一、先看现象:高频输入如何变成"顿挫"
金融图表有一个典型的交互:按住图表左右拖拽平移 K 线。用户的指针每秒可能产生上百次 pointermove,而屏幕通常只有 60Hz(每帧约 16.7ms)。也就是说,输入频率远高于屏幕刷新频率。
如果每次 pointermove 都立刻把状态广播出去、立刻写 DOM、立刻绘制,会发生什么?
- 一帧之内 DOM
scrollLeft被覆盖写几十次,浏览器每次都重新走样式/布局。 - 状态层已经是最新值,但 canvas 绘制和容器滚动偏移各自"各跳各的"。
- 两条异步链(DOM 写入链 + 绘制链)的执行先后不确定,某一帧可能出现"K 线画面是新的、容器偏移还是旧的"的错位。
这就是顿挫感与多余微任务的来源。它本质是一个时序问题:散落的异步回调各自为政。
二、浏览器到底怎么"画一帧"
要理解为什么"合帧"是对的,先得看浏览器每一帧的生命周期。现代浏览器(以 Chromium 为例)一帧大致是:
VSync
↓
主线程:处理输入事件 → 执行 rAF 回调 → 计算 Style → Layout → Paint
↓
合成线程:栅格化 → 提交 CompositorFrame
↓
GPU 进程:汇总、绘制、SwapBuffer
↓
对齐下一次 VSync 显示到屏幕几个关键点:
requestAnimationFrame是"渲染帧回调",它跑在 Style/Layout 之前。所以在 rAF 里改 DOM,理论上能赶在本次渲染提交前被合并进去,不会产生额外的强制同步布局。- rAF 与显示器刷新率同步(通常 60Hz,也有 120Hz/144Hz)。它会随 VSync 对齐触发,而不是像
setTimeout那样按定时器堆排队。 - 一帧内注册的 rAF 不会在当帧再执行,会被安排到下一帧。这保证了"链式递归"的动画循环每帧恰好执行一次,不会积压。
- 如果回调本身耗时超过 16.7ms,就会超过帧预算(frame budget),导致掉帧。
所以工程上的正确姿势是:把一切"要变成画面的更新"收敛到唯一一个渲染循环里。这就是帧事务要解决的第一个问题——调度收敛。
三、帧事务(Frame Transaction):高频输入的低通滤波器
3.1 它解决什么
帧事务本质上是一个半通用的 rAF 合帧器(frame coalescer),专门为绘制管线设计。它要解决两类问题:
- 按事件频率广播:
pointermove直接写普通 Signal,会按事件频率(可能上百 Hz)去通知所有订阅者。 - 代际错位:如果同时存在"最新值"和"已发布值"两套视图,不同模块可能读到不同代际的状态。
3.2 核心模型
createFrameTransaction 对外只暴露 pending 写入 与 published 快照,不暴露"中间最新值"。每一帧固定走一个生命周期:
capturing(封存输入)→ deriving(纯推导快照)→ sealing(冻结快照)
→ rendering(绘制 / 副作用)→ publishing(发布快照)→ complete(回 idle)几个关键不变量:
writeInput只合并输入,不触发通知。高频pointermove在帧内反复写入,最终只有最后一个值生效。- 封存(capturing)之后的重入写入一律进下一代。render 或 publish 期间又调
writeInput,不会污染当前帧。 - derive 必须是纯函数:由封存输入推导不可变快照,不写外部 kernel / DOM。
- 快照根对象被冻结(
sealSnapshotRoot),大数组字段靠结构共享,禁止深拷贝——这是性能红线。 flush非 idle 时不嵌套发布:只保留 dirty,由外层完成后再调度下一帧。
伪代码视角:
function flush() {
if (phase !== 'idle') return published // 禁止嵌套
if (!dirty) return published // 无变更则不动
phase = 'capturing'
sealedInput = pending // 封存
phase = 'deriving'
snapshot = derive(sealedInput) // 纯推导
phase = 'sealing'
freeze(snapshot)
phase = 'rendering'
render(snapshot) // 画布 / DOM 副作用
phase = 'publishing'
published.set(snapshot) // 原子发布
}3.3 一帧一个快照的威力
因为订阅者只看到"已发布的不可变快照",所以渲染管线可以用同一份 viewport 快照同时推导可见区间、K 线坐标、十字线位置——它们天然同代际,不会出现"画布用了新滚动、交互还挂着旧滚动"的错位。
3.4 一个真实的反例:derive 里混进了副作用
derive 必须是纯函数——这个约束不是纸面理想,而是有血的教训。
在最初的实现里,ChartRenderer 的帧快照推导函数 prepareFrameData 做了两件"看起来无害"的事:
- 更新了
_prevFrameRange缓存(记录上一帧的可见区,用于判断区间是否变化)。 - 区间变化时,直接调用了可见区缺口检测,可能触发增量数据加载。
第一眼看,这只是在"顺路做点事"。但它违反了一条硬约束:同一输入必须产生同一输出,且不产生外部副作用。
风险是实打实的:
derive在测试里会被同步flush()直接调用,也会在帧事务失败重试时再次执行——于是缺口加载可能被重复触发,甚至在校验还没发生时就开始改数据。- 几何缓存和
_prevFrameRange属于"上一帧的状态",把它们放进"当前帧的纯推导"里,会让快照推导不再幂等。
修复方式很清晰:把副作用从 derive 挪到成功绘制之后。derive 只负责"算这一帧画什么",把"记录区间、更新缓存、检查缺口"推迟到 render 完成、帧确实要上屏之后。这样,纯推导保持纯,副作用落在受控的位置,重试或并发推导都不会重复触发加载。
四、时序(Timing):让"状态"与"画面"同帧提交
4.1 单一时序原则
帧事务解决了"合帧",但还不够。图表里还有第二类异步源:DOM 原生事件。
一个真实的教训:我们把滚动位置交给帧事务后,程序化写入 container.scrollLeft 会在浏览器内异步派发一个 scroll 事件。这个事件又被监听器误判成"用户滚动了",于是:
ChartRenderer rAF
→ 写 container.scrollLeft
→ 浏览器派发 scroll
→ Vue @scroll → handleScrollEvent()
→ InteractionController.onScroll()
→ 再请求下一帧绘制这就形成了一条回流时序:程序自己写入 → 又被自己当作输入 → 多画一帧。虽然多数时候不再造成第一帧错位,但它仍是多余的异步工作,也埋下了"多一条时序"的隐患。
4.2 用"确认"机制区分来源
正确的收口方式不是判断"值相等就不写",而是引入一个单一滚动桥接器(Scroll Bridge)来区分程序写入与用户输入:
- renderer 帧内提交滚动时,桥接器记录浏览器实际接受的值(要考虑子像素钳制,浏览器可能没完全采纳目标值)。
- 唯一的原生
scroll监听收到事件时,若容器当前值与"程序上次提交值"一致 → 判定为回流,直接消费掉,不同步 state、不重绘。 - 值不同 → 判定为用户输入(滚轮、滚动条、触摸板),才同步 state 并请求下一帧。
class ViewportScrollBridge {
commit(target) {
container.scrollLeft = target
this.pending = container.scrollLeft // 记浏览器实际接受的值
}
isExternal(actual) {
if (this.pending === actual) { // 回流 → 消费
this.pending = null
return false
}
this.pending = null
return true // 用户输入
}
}这样,所有"要变成画面"的路径都收敛到同一条帧事务,且按固定顺序:先提交 DOM 滚动,再绘制 canvas。状态层仍然即时更新(保证交互计算跟手),但视觉输出只在同帧原子提交。
4.3 为什么这对 WebGL / WebGPU 都重要
GPU 绘制本身是异步提交的:渲染器在 rAF 里记录绘制命令,真正上屏由合成/GPU 进程稍后完成。如果容器偏移和 GPU 帧不在同一帧提交,错位会被放大。把 DOM 滚动并进同一帧事务后,WebGL、WebGPU、Canvas2D 三个后端都能保证"这一帧的滚动偏移"和"这一帧的像素"严格一致。
4.4 又一个真实的反例:指标可见区自建了第二条 rAF
时序问题不会只有滚动一个出口。复查时发现,指标调度器(IndicatorScheduler)也藏了一条自己的 rAF 链。
它的 visibleRange 变化会触发一个 effect,effect 里又注册一个独立的 rAF,等那个 rAF 跑完再去请求 ChartRenderer 重绘:
viewport 变化
→ IndicatorScheduler 的 rAF
→ invalidateCallback
→ ChartRenderer 的 rAF这等于图表里同时存在两条视觉时序:一条属于 ChartRenderer,一条属于指标调度器。后果是主指标的可见极值至少晚一个视觉帧才更新,而且二者执行顺序不受控,随时可能回到"多一条时序"的老问题。
修复方式与滚动一样:收敛到同一条帧。把指标的可见区状态更新直接放进 ChartRenderer 的活动帧内同步执行,删除指标调度器自建的 rAF 和二次 invalidate。现在 ChartRenderer 是唯一的视觉帧所有者,指标可见区变化与 canvas 绘制在同帧、同步完成。
五、effect:把"状态变化"变成"外界副作用"
5.1 响应式系统的最小模型
帧事务管"画面提交",effect 管"状态 → 外界的映射"。引擎的响应式内核是一个细粒度(fine-grained)系统,三个原语分工明确:
- Signal:一个可写的值容器。读它(调用)就是订阅,写它(
.set)就是发布。 - Computed:从其他 Signal 派生的惰性只读值,依赖不变就不重算。
- Effect:一个自动追踪依赖的函数,所依赖的 Signal 变化时重新执行。
依赖收集用全局追踪栈完成:effect 执行时把自己压入栈顶,任何被读取的 Signal 都会把当前 effect 记入订阅者集合。这就是"零依赖声明、自动追踪"的魔法。
5.2 Push-Pull 算法
现代 Signal 系统普遍采用 Push-Pull 混合算法:
- Push:数据变化时,急切地向下游传播"你脏了"的轻量通知(只标 dirty,不算值)。
- Pull:只有当 computed 真正被读取时才重算并缓存。
这样保证了:响应性不遗漏(Push),效率不浪费(Pull)——没人读的值不会被白算。Vue、Solid、Preact、Angular、Svelte 殊途同归,区别只在优化细节(如 Vue 3.4 用版本号避免值不变时的下游传播)。
5.3 在图表里 effect 怎么用
帧事务和 effect 是互补的,各自管不同的副作用:
- 每帧都要变的东西(canvas 绘制、DOM 滚动位置、指标可见极值)→ 走帧事务,按帧合流。
- 低频、离散的同步(canvas 尺寸跟随容器、WebGL surface 跟随 plot 尺寸、渲染器可见层跟随状态)→ 走 effect,状态一变就同步一次。
一个关键纪律:computed 负责派生,effect 负责副作用,帧事务负责"每帧提交"。三者分离,让"纯派生"保持可测,"副作用"集中,"视觉提交"有序。
这里的取舍值得单独说:effect 本身是低频副作用的正确工具,但它不该注册视觉 rAF。一旦 effect 里出现
requestAnimationFrame,它就从"低频离散同步"悄悄变成了"第二个渲染循环"。判断标准很简单——任何以"把东西画到屏上"为目标的回调,都必须归属到唯一的帧事务,而不是散落在某个 effect 里。
六、一个统一的心智模型
把三个概念串起来,其实就是一个漏斗:
高频输入(pointermove / websocket / 数据流)
↓ writeInput(合并、不通知)
帧事务 capture
↓ derive(纯函数:只算这一帧画什么,无副作用)
不可变快照(同代际)
↓ render:提交 DOM 滚动 + 投影指标 + 绘制 canvas(同帧)
绘制成功之后:更新缓存 + 检查数据缺口
发布快照
↓
effect:低频离散副作用(尺寸、layer、surface)- 时序是"要不要合、在哪合、按什么顺序合"的决策。
- 帧事务是执行这个决策的调度器:高频输入低通滤波成"每帧一个快照",并保证 derive 纯、副作用有序。
- effect 是状态变化后"同步到外界"的副作用的统一入口——但它不负责画,画永远归帧事务。
一句话总结:时序决定"合",帧事务负责"合到一帧"且"纯推导",effect 负责"把低频状态落回外界"。三者一起,才保证高频交互下"状态、画面、DOM"严格一致,又不多做一帧无效工作。
七、可复用的工程清单
如果你也在写高频交互的渲染库,这几点可以直接带走:
- 收敛到一个渲染循环:页面最好只有一个 rAF 调度器,一切"要变成画面"的更新注册进来。定期
grep requestAnimationFrame,每一个多余的入口都值得质疑——指标、滚动、动画,任何组件都不该自建视觉循环。 - 一帧一个不可变快照:绘制、交互、几何推导共用同代际快照,杜绝"新旧混读"。
- derive 必须纯:快照推导函数里不允许出现缓存写入、数据加载等副作用;把它们推迟到成功绘制之后。判断标准:同一输入重跑多次,结果必须一致且无外部影响。
- 状态即时,视觉按帧:业务状态即时更新保证跟手;只有视觉输出按帧提交。
- 用确认机制切断回流:程序写入 DOM 触发的事件,要靠"记录实际写入值 + 匹配"来消费,避免程序自己触发自己。
- 区分副作用的频率:每帧变的走帧事务,低频离散的走 effect;effect 里不要注册 rAF。
关于帧预算与任务分片:单帧内如果计算超过 16.7ms 会掉帧。本文的"合帧"解决的是"重复与错位",不是"单帧超预算"——后者需要拆帧、后台 worker、requestIdleCallback 等手段,是另一个话题。