跳到正文

帧事务、时序与 effect

从"顿挫"到"丝滑":帧事务、时序与 effect——一个图表引擎的状态模型

现象一句话版:高频拖拽 K 线时,画面偶尔一顿一顿;打开性能面板,高频 pointermove 每次都触发一轮微任务链。修到最后发现,真正的问题不是"事件太频繁",而是多个异步时序在争抢同一份视觉状态。

这篇文章以 KLineChartQuant 引擎的真实重构为主线,讲清楚三个容易被混为一谈的概念:帧事务(Frame Transaction)、时序(Timing)、effect(副作用)。它们不是三套独立机制,而是同一件事的三个侧面:当"状态"和"画面"要严格一致时,谁来、在哪、以什么顺序把状态变成像素。文章会一路走到这次重构的收尾,包括在复查中发现并修掉的两个新问题——因为它们恰好是同一原则的两个反例。


一、先看现象:高频输入如何变成"顿挫"

金融图表有一个典型的交互:按住图表左右拖拽平移 K 线。用户的指针每秒可能产生上百次 pointermove,而屏幕通常只有 60Hz(每帧约 16.7ms)。也就是说,输入频率远高于屏幕刷新频率。

如果每次 pointermove 都立刻把状态广播出去、立刻写 DOM、立刻绘制,会发生什么?

  1. 一帧之内 DOM scrollLeft 被覆盖写几十次,浏览器每次都重新走样式/布局。
  2. 状态层已经是最新值,但 canvas 绘制和容器滚动偏移各自"各跳各的"。
  3. 两条异步链(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),专门为绘制管线设计。它要解决两类问题:

  1. 按事件频率广播:pointermove 直接写普通 Signal,会按事件频率(可能上百 Hz)去通知所有订阅者。
  2. 代际错位:如果同时存在"最新值"和"已发布值"两套视图,不同模块可能读到不同代际的状态。

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 做了两件"看起来无害"的事:

  1. 更新了 _prevFrameRange 缓存(记录上一帧的可见区,用于判断区间是否变化)。
  2. 区间变化时,直接调用了可见区缺口检测,可能触发增量数据加载。

第一眼看,这只是在"顺路做点事"。但它违反了一条硬约束:同一输入必须产生同一输出,且不产生外部副作用。

风险是实打实的:

  • 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"严格一致,又不多做一帧无效工作。


七、可复用的工程清单

如果你也在写高频交互的渲染库,这几点可以直接带走:

  1. 收敛到一个渲染循环:页面最好只有一个 rAF 调度器,一切"要变成画面"的更新注册进来。定期 grep requestAnimationFrame,每一个多余的入口都值得质疑——指标、滚动、动画,任何组件都不该自建视觉循环。
  2. 一帧一个不可变快照:绘制、交互、几何推导共用同代际快照,杜绝"新旧混读"。
  3. derive 必须纯:快照推导函数里不允许出现缓存写入、数据加载等副作用;把它们推迟到成功绘制之后。判断标准:同一输入重跑多次,结果必须一致且无外部影响。
  4. 状态即时,视觉按帧:业务状态即时更新保证跟手;只有视觉输出按帧提交。
  5. 用确认机制切断回流:程序写入 DOM 触发的事件,要靠"记录实际写入值 + 匹配"来消费,避免程序自己触发自己。
  6. 区分副作用的频率:每帧变的走帧事务,低频离散的走 effect;effect 里不要注册 rAF。

关于帧预算与任务分片:单帧内如果计算超过 16.7ms 会掉帧。本文的"合帧"解决的是"重复与错位",不是"单帧超预算"——后者需要拆帧、后台 worker、requestIdleCallback 等手段,是另一个话题。

本页内容