Signal 带来的架构问题思考

0 阅读5分钟

一个 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 交互也没有问题了,但这不是解决问题的根本手段,维护成本和心智负担依旧很重。

架构问题

仔细想想,这个所谓的"修复"只是把问题往下游推了一层。当前的设计依赖一个微妙的隐含契约:

  1. 一个逻辑事件(merge 数据)被拆成两个独立信号(data + prepend)
  2. 两个信号必须按特定顺序被消费,否则数据不一致
  3. 两个信号之间的协调靠一个共享可变变量(pendingPrependedCount

整个代码里其实没有一个地方显式写了"prepend 订阅者必须在 data 订阅者之前运行"。这个顺序完全是靠"谁先注册订阅"和"信号在代码里出现的先后顺序"隐式保障的。新来一个开发者,或者有人顺手调整了订阅注册顺序,这个 bug 就会再次出现。

而且两个订阅者散落在相隔 600 行的不同方法里——activateBuffer 注册 data 订阅,loadKLineSymbols 注册 prepend 订阅。要理解它们之间的依赖关系,得在编辑器里来回跳。

block-beta
  columns 1
  block:Methods
    columns 1
    m1[&#34;activateBuffer()<br/>line 118: buf.data.subscribe()&#34;]
    space
    m2[&#34;loadKLineSymbols()<br/>line 724: buf.prepend.subscribe()&#34;]
  end
  block:Shared
    s1[&#34;pendingPrependedCount<br/>共享可变变量&#34;]
  end
  block:Consumers
    c1[&#34;onKLineBufferChanged()<br/>读 pendingPrependedCount&#34;]
  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[&#34;data 信号<br/>只传数组&#34;] space
    b[&#34;prepend 信号<br/>只传数字&#34;] c[&#34;pendingPrependedCount<br/>共享变量&#34;]
    a --> c
    b --> c
  end
  block:New
    d[&#34;DataChange 信号<br/>{ data, prependedCount }&#34;]
  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 之前执行,但代码里没有任何一处显式表达这个顺序,那就应该重构。