一个 Bug 引发的架构追问
事情从一个不起眼的 bug 开始。我们的 K 线图有一个"增量加载提示"功能:向左滚动加载更多历史数据时,新来的 K 线上方会闪过一层蓝色半透明覆盖,告诉用户"这块是刚加载的"。
但这个东西经常出现问题。
先解释一个关键概念:prependedCount 是每次增量加载时,插入到数据数组左侧的新 K 线数量。K 线图的数据按时间从左到右排列,最新数据在右边。当你向左滚动想看更早的历史,系统会请求更早时间的数据,这些数据会被 prepend(插到已有数据的最前面)。比如之前有 500 根 K 线,这次加载了 20 根更早的,那 prependedCount 就是 20——提示覆盖层的宽度也是根据这个数字算出来的。
Canvas高性能渲染需要自己实现渲染后端,这种难以直观定位的问题最快的方式就是在关键链路上加日志,一看,加载区域覆层宽度计算参数 prependedCount 始终是 0。
代码逻辑是:数据加载完后先触发 data 信号,data 订阅者从 pendingPrependedCount 读值——但这个时候 prepend 信号还没触发,pendingPrependedCount 还是 0。等 prepend 信号触发、pendingPrependedCount 被更新成正确值时,DOM 渲染已经结束了,渲染了宽度为 0 的提示覆层上去。
sequenceDiagram
participant Fetch as _fetchAndMerge
participant Store as KLineDataStore
participant Buf as DataBuffer
participant Mgr as ChartDataManager
Fetch->>Store: merge(incoming)
Store-->>Buf: dataSignal.set(merged) ① data 信号先触发
Buf->>Mgr: data.subscriber
Note over Mgr: pendingPrependedCount = 0 ❌
Mgr->>Mgr: onKLineBufferChanged
Mgr->>Mgr: _loadHint.show(0, ...) → count=0, return
Fetch->>Buf: _prependSignal.set(count) ② prepend 信号晚到
Buf->>Mgr: prepend.subscriber
Mgr->>Mgr: pendingPrependedCount = count
Note over Mgr: 设上了,但 data 那边已经跑完了
这是个信号时序问题。
第一次修复:交换顺序
直觉反应是把 prepend 信号设值的时机提前到 data 信号之前。于是把 merge() 拆成了两步——合并数据和触发信号分开:
const result = this._store.merge(incoming) // 只合并,不触发
this._prependSignal.set(result.prependedCount) // prepend 先
this._store.notify() // data 后
sequenceDiagram
participant Fetch as _fetchAndMerge
participant Store as KLineDataStore
participant Buf as DataBuffer
participant Mgr as ChartDataManager
Fetch->>Store: merge(incoming)
Note over Store: 只合并,不触发信号
Fetch->>Buf: _prependSignal.set(count) ① prepend 先
Buf->>Mgr: prepend.subscriber
Mgr->>Mgr: pendingPrependedCount = count ✓
Fetch->>Store: notify()
Store-->>Buf: dataSignal.set(merged) ② data 后
Buf->>Mgr: data.subscriber
Note over Mgr: pendingPrependedCount = count ✓
Mgr->>Mgr: _loadHint.show(count, ...) → 正确了
测试通过,E2E 交互也没有问题了,但这不是解决问题的根本手段,维护成本和心智负担依旧很重。
架构问题
仔细想想,这个所谓的"修复"只是把问题往下游推了一层。当前的设计依赖一个微妙的隐含契约:
- 一个逻辑事件(merge 数据)被拆成两个独立信号(data + prepend)
- 两个信号必须按特定顺序被消费,否则数据不一致
- 两个信号之间的协调靠一个共享可变变量(
pendingPrependedCount)
整个代码里其实没有一个地方显式写了"prepend 订阅者必须在 data 订阅者之前运行"。这个顺序完全是靠"谁先注册订阅"和"信号在代码里出现的先后顺序"隐式保障的。新来一个开发者,或者有人顺手调整了订阅注册顺序,这个 bug 就会再次出现。
而且两个订阅者散落在相隔 600 行的不同方法里——activateBuffer 注册 data 订阅,loadKLineSymbols 注册 prepend 订阅。要理解它们之间的依赖关系,得在编辑器里来回跳。
block-beta
columns 1
block:Methods
columns 1
m1["activateBuffer()<br/>line 118: buf.data.subscribe()"]
space
m2["loadKLineSymbols()<br/>line 724: buf.prepend.subscribe()"]
end
block:Shared
s1["pendingPrependedCount<br/>共享可变变量"]
end
block:Consumers
c1["onKLineBufferChanged()<br/>读 pendingPrependedCount"]
end
Methods --> Shared
Shared --> Consumers
style m1 fill:#e3f2fd
style m2 fill:#e3f2fd
style s1 fill:#fff3e0
style c1 fill:#fce4ec
用一个共享变量跨信号传递信息,本质上是"用副作用做通信"。这在小型原型里能跑,但在一个正在持续迭代的工程里,这就是一颗定时炸弹。
重构成 DataChange
思考了一轮,决定把"变更描述"编码进信号载荷本身。
核心思路很简单:data 信号不再只发一个数组,而是发一个包含数组和变更元数据的结构体:
interface DataChange {
data: ReadonlyArray<unknown>
prependedCount: number // 这次更新头部新增了多少根 K 线
}
这样 merge() 可以一次性发出所有信息,消费者一个回调就能拿到全部上下文:
merge() {
// ... 合并数据,计算 prependedCount ...
this._dataSignal.set({ data: [...merged], prependedCount }) // 原子化信号,直接带上count数据
}
sequenceDiagram
participant Fetch as _fetchAndMerge
participant Store as KLineDataStore
participant Mgr as ChartDataManager
Fetch->>Store: merge(incoming)
Store-->>Store: _dataSignal.set({ data, prependedCount })
Note over Store: 一个信号,携带全部信息
Store-->>Mgr: data.subscriber 收到 DataChange
Mgr->>Mgr: change.prependedCount ✓
Note over Mgr: 确定性的,不依赖信号顺序
Mgr->>Mgr: compensatePrepend(count)
Mgr->>Mgr: _loadHint.show(count, ...)
vs 旧架构对比:
block-beta
columns 1
block:Old
columns 2
a["data 信号<br/>只传数组"] space
b["prepend 信号<br/>只传数字"] c["pendingPrependedCount<br/>共享变量"]
a --> c
b --> c
end
block:New
d["DataChange 信号<br/>{ data, prependedCount }"]
end
style Old fill:#fce4ec
style New fill:#e8f5e9
onKLineBufferChanged 不再需要从共享变量读值:
// 改前:依赖跨信号协调
const count = this.pendingPrependedCount // 可能还没设上
// 改后:直接从变更载荷读
const count = change.prependedCount // 确定性的
同时删掉了:
_prependSignal——不再需要独立的 prepend 信号pendingPrependedCount——不再需要共享变量_prependUnsub——不再需要单独的订阅清理notify()方法——merge 直接触发信号,不再分两步走
改动涉及 6 个文件,但总代码量几乎没变(+64/-61 行)。TypeScript 类型系统保证了所有消费端都被更新到位。
几点感想
不要把一件事拆成两个信号。 如果一个逻辑事件产生两条影响,把这两条影响打包成一个整体传递出去,而不是拆成两个独立信号让消费者自己去拼。信号是给消费端提供确定性的,不是给消费端出谜题的。AI 编码也要注意此类隐性架构问题。
共享可变变量做协同,看着方便,长期有毒。 pendingPrependedCount 最初可能只是"暂时放一下",但随着代码演化,它变成了 data 和 prepend 两个订阅者之间唯一的沟通渠道。没有类型标注、没有文档说明、没有运行时检查。此类问题在 AI 编码过程中也要主动去消解架构债务。
信号时序依赖是隐式契约。 隐式契约的特点是——直到有人打破它,你都不知道它存在。如果一段代码的正确性依赖于 A 在 B 之前执行,但代码里没有任何一处显式表达这个顺序,那就应该重构。