# 帧事务、时序与 effect

> 这篇文章以 KLineChartQuant 引擎的真实重构为主线，讲清楚三个容易被混为一谈的概念：帧事务（Frame Transaction）、时序（Timing）、effect（副作用）。它们不是三套独立机制，而是同一件事的三个侧面：当"状态"和"画面"要严格一致时，谁来、在哪、以什么顺序把状态变成像素。…

Source: https://kcq.nebutra.com/zh/docs/architecture/engineering/frame-transaction-timing-effect


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

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

***

一、先看现象：高频输入如何变成"顿挫" [#一先看现象高频输入如何变成顿挫]

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

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

1. 一帧之内 DOM `scrollLeft` 被覆盖写几十次，浏览器每次都重新走样式/布局。
2. 状态层已经是最新值，但 canvas 绘制和容器滚动偏移各自"各跳各的"。
3. 两条异步链（DOM 写入链 + 绘制链）的执行先后不确定，某一帧可能出现"K 线画面是新的、容器偏移还是旧的"的错位。

这就是顿挫感与多余微任务的来源。它本质是一个**时序问题**：散落的异步回调各自为政。

***

二、浏览器到底怎么"画一帧" [#二浏览器到底怎么画一帧]

要理解为什么"合帧"是对的，先得看浏览器每一帧的生命周期。现代浏览器（以 Chromium 为例）一帧大致是：

```text
VSync
  ↓
主线程：处理输入事件 → 执行 rAF 回调 → 计算 Style → Layout → Paint
  ↓
合成线程：栅格化 → 提交 CompositorFrame
  ↓
GPU 进程：汇总、绘制、SwapBuffer
  ↓
对齐下一次 VSync 显示到屏幕
```

几个关键点：

* **`requestAnimationFrame` 是"渲染帧回调"**，它跑在 Style/Layout 之前。所以在 rAF 里改 DOM，理论上能赶在本次渲染提交前被合并进去，不会产生额外的强制同步布局。
* **rAF 与显示器刷新率同步**（通常 60Hz，也有 120Hz/144Hz）。它会随 VSync 对齐触发，而不是像 `setTimeout` 那样按定时器堆排队。
* **一帧内注册的 rAF 不会在当帧再执行**，会被安排到下一帧。这保证了"链式递归"的动画循环每帧恰好执行一次，不会积压。
* 如果回调本身耗时超过 16.7ms，就会超过&#x2A;*帧预算（frame budget）**，导致掉帧。

所以工程上的正确姿势是：**把一切"要变成画面的更新"收敛到唯一一个渲染循环里**。这就是帧事务要解决的第一个问题——调度收敛。

***

三、帧事务（Frame Transaction）：高频输入的低通滤波器 [#三帧事务frame-transaction高频输入的低通滤波器]

3.1 它解决什么 [#31-它解决什么]

帧事务本质上是一个&#x2A;*半通用的 rAF 合帧器（frame coalescer）**，专门为绘制管线设计。它要解决两类问题：

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

3.2 核心模型 [#32-核心模型]

`createFrameTransaction` 对外只暴露 **pending 写入** 与 **published 快照**，不暴露"中间最新值"。每一帧固定走一个生命周期：

```text
capturing（封存输入）→ deriving（纯推导快照）→ sealing（冻结快照）
→ rendering（绘制 / 副作用）→ publishing（发布快照）→ complete（回 idle）
```

几个关键不变量：

* **`writeInput` 只合并输入，不触发通知**。高频 `pointermove` 在帧内反复写入，最终只有最后一个值生效。
* **封存（capturing）之后的重入写入一律进下一代**。render 或 publish 期间又调 `writeInput`，不会污染当前帧。
* **derive 必须是纯函数**：由封存输入推导不可变快照，不写外部 kernel / DOM。
* **快照根对象被冻结**（`sealSnapshotRoot`），大数组字段靠结构共享，禁止深拷贝——这是性能红线。
* **`flush` 非 idle 时不嵌套发布**：只保留 dirty，由外层完成后再调度下一帧。

伪代码视角：

```ts
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 一帧一个快照的威力 [#33-一帧一个快照的威力]

因为订阅者只看到"已发布的不可变快照"，所以渲染管线可以用同一份 `viewport` 快照同时推导可见区间、K 线坐标、十字线位置——它们天然同代际，不会出现"画布用了新滚动、交互还挂着旧滚动"的错位。

3.4 一个真实的反例：`derive` 里混进了副作用 [#34-一个真实的反例derive-里混进了副作用]

`derive` 必须是纯函数——这个约束不是纸面理想，而是有血的教训。

在最初的实现里，`ChartRenderer` 的帧快照推导函数 `prepareFrameData` 做了两件"看起来无害"的事：

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

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

风险是实打实的：

* `derive` 在测试里会被同步 `flush()` 直接调用，也会在帧事务失败重试时**再次执行**——于是缺口加载可能被重复触发，甚至在校验还没发生时就开始改数据。
* 几何缓存和 `_prevFrameRange` 属于"上一帧的状态"，把它们放进"当前帧的纯推导"里，会让快照推导不再幂等。

修复方式很清晰：**把副作用从 derive 挪到成功绘制之后**。`derive` 只负责"算这一帧画什么"，把"记录区间、更新缓存、检查缺口"推迟到 `render` 完成、帧确实要上屏之后。这样，纯推导保持纯，副作用落在受控的位置，重试或并发推导都不会重复触发加载。

***

四、时序（Timing）：让"状态"与"画面"同帧提交 [#四时序timing让状态与画面同帧提交]

4.1 单一时序原则 [#41-单一时序原则]

帧事务解决了"合帧"，但还不够。图表里还有第二类异步源：**DOM 原生事件**。

一个真实的教训：我们把滚动位置交给帧事务后，程序化写入 `container.scrollLeft` 会在浏览器内**异步派发一个 `scroll` 事件**。这个事件又被监听器误判成"用户滚动了"，于是：

```text
ChartRenderer rAF
  → 写 container.scrollLeft
  → 浏览器派发 scroll
  → Vue @scroll → handleScrollEvent()
  → InteractionController.onScroll()
  → 再请求下一帧绘制
```

这就形成了一条**回流时序**：程序自己写入 → 又被自己当作输入 → 多画一帧。虽然多数时候不再造成第一帧错位，但它仍是多余的异步工作，也埋下了"多一条时序"的隐患。

4.2 用"确认"机制区分来源 [#42-用确认机制区分来源]

正确的收口方式不是判断"值相等就不写"，而是引入一个**单一滚动桥接器（Scroll Bridge）**来区分**程序写入**与**用户输入**：

* renderer 帧内提交滚动时，桥接器记录浏览器**实际接受**的值（要考虑子像素钳制，浏览器可能没完全采纳目标值）。
* 唯一的原生 `scroll` 监听收到事件时，若容器当前值与"程序上次提交值"一致 → 判定为**回流**，直接消费掉，不同步 state、不重绘。
* 值不同 → 判定为**用户输入**（滚轮、滚动条、触摸板），才同步 state 并请求下一帧。

```ts
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 都重要 [#43-为什么这对-webgl--webgpu-都重要]

GPU 绘制本身是**异步提交**的：渲染器在 rAF 里记录绘制命令，真正上屏由合成/GPU 进程稍后完成。如果容器偏移和 GPU 帧不在同一帧提交，错位会被放大。把 DOM 滚动并进同一帧事务后，WebGL、WebGPU、Canvas2D 三个后端都能保证"这一帧的滚动偏移"和"这一帧的像素"严格一致。

4.4 又一个真实的反例：指标可见区自建了第二条 rAF [#44-又一个真实的反例指标可见区自建了第二条-raf]

时序问题不会只有滚动一个出口。复查时发现，**指标调度器（IndicatorScheduler）也藏了一条自己的 rAF 链**。

它的 `visibleRange` 变化会触发一个 effect，effect 里又注册一个独立的 rAF，等那个 rAF 跑完再去请求 `ChartRenderer` 重绘：

```text
viewport 变化
→ IndicatorScheduler 的 rAF
→ invalidateCallback
→ ChartRenderer 的 rAF
```

这等于图表里同时存在**两条视觉时序**：一条属于 `ChartRenderer`，一条属于指标调度器。后果是主指标的可见极值至少晚一个视觉帧才更新，而且二者执行顺序不受控，随时可能回到"多一条时序"的老问题。

修复方式与滚动一样：**收敛到同一条帧**。把指标的可见区状态更新直接放进 `ChartRenderer` 的活动帧内同步执行，删除指标调度器自建的 rAF 和二次 invalidate。现在 `ChartRenderer` 是唯一的视觉帧所有者，指标可见区变化与 canvas 绘制在同帧、同步完成。

***

五、effect：把"状态变化"变成"外界副作用" [#五effect把状态变化变成外界副作用]

5.1 响应式系统的最小模型 [#51-响应式系统的最小模型]

帧事务管"画面提交"，effect 管"状态 → 外界的映射"。引擎的响应式内核是一个细粒度（fine-grained）系统，三个原语分工明确：

* **Signal**：一个可写的值容器。读它（调用）就是**订阅**，写它（`.set`）就是**发布**。
* **Computed**：从其他 Signal 派生的**惰性**只读值，依赖不变就不重算。
* **Effect**：一个自动追踪依赖的函数，所依赖的 Signal 变化时重新执行。

依赖收集用**全局追踪栈**完成：effect 执行时把自己压入栈顶，任何被读取的 Signal 都会把当前 effect 记入订阅者集合。这就是"零依赖声明、自动追踪"的魔法。

5.2 Push-Pull 算法 [#52-push-pull-算法]

现代 Signal 系统普遍采用 **Push-Pull 混合算法**：

* **Push**：数据变化时，急切地向下游传播"你脏了"的轻量通知（只标 dirty，不算值）。
* **Pull**：只有当 computed 真正被读取时才重算并缓存。

这样保证了：响应性不遗漏（Push），效率不浪费（Pull）——没人读的值不会被白算。Vue、Solid、Preact、Angular、Svelte 殊途同归，区别只在优化细节（如 Vue 3.4 用版本号避免值不变时的下游传播）。

5.3 在图表里 effect 怎么用 [#53-在图表里-effect-怎么用]

帧事务和 effect 是**互补**的，各自管不同的副作用：

* **每帧都要变的东西**（canvas 绘制、DOM 滚动位置、指标可见极值）→ 走帧事务，按帧合流。
* **低频、离散的同步**（canvas 尺寸跟随容器、WebGL surface 跟随 plot 尺寸、渲染器可见层跟随状态）→ 走 effect，状态一变就同步一次。

一个关键纪律：&#x2A;*computed 负责派生，effect 负责副作用，帧事务负责"每帧提交"**。三者分离，让"纯派生"保持可测，"副作用"集中，"视觉提交"有序。

> 这里的取舍值得单独说：**effect 本身是低频副作用的正确工具，但它不该注册视觉 rAF**。一旦 effect 里出现 `requestAnimationFrame`，它就从"低频离散同步"悄悄变成了"第二个渲染循环"。判断标准很简单——任何以"把东西画到屏上"为目标的回调，都必须归属到唯一的帧事务，而不是散落在某个 effect 里。

***

六、一个统一的心智模型 [#六一个统一的心智模型]

把三个概念串起来，其实就是一个漏斗：

```text
高频输入（pointermove / websocket / 数据流）
   ↓  writeInput（合并、不通知）
帧事务 capture
   ↓  derive（纯函数：只算这一帧画什么，无副作用）
不可变快照（同代际）
   ↓  render：提交 DOM 滚动 + 投影指标 + 绘制 canvas（同帧）
绘制成功之后：更新缓存 + 检查数据缺口
发布快照
   ↓
effect：低频离散副作用（尺寸、layer、surface）
```

* **时序**是"要不要合、在哪合、按什么顺序合"的决策。
* **帧事务**是执行这个决策的**调度器**：高频输入低通滤波成"每帧一个快照"，并保证 derive 纯、副作用有序。
* **effect** 是状态变化后"同步到外界"的**副作用的统一入口**——但它不负责画，画永远归帧事务。

一句话总结：&#x2A;*时序决定"合"，帧事务负责"合到一帧"且"纯推导"，effect 负责"把低频状态落回外界"**。三者一起，才保证高频交互下"状态、画面、DOM"严格一致，又不多做一帧无效工作。

***

七、可复用的工程清单 [#七可复用的工程清单]

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

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

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