Signal 带来的架构问题思考
2026/7/2 1:36:42 网站建设 项目流程

开源高性能金融图表,欢迎使用
体验:https://363045841.github.io/KLineChartQuant/
Github:https://github.com/363045841/KLineChartQuant
NPM:https://www.npmjs.com/package/@363045841yyt/klinechart
项目持续更新中!欢迎提出宝贵建议!如果对您有帮助可以给个 Star!

一个 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 的提示覆层上去。

ChartDataManagerDataBufferKLineDataStore_fetchAndMergeChartDataManagerDataBufferKLineDataStore_fetchAndMergependingPrependedCount = 0 ❌设上了,但 data 那边已经跑完了merge(incoming)dataSignal.set(merged) ① data 信号先触发data.subscriberonKLineBufferChanged_loadHint.show(0, ...) → count=0, return_prependSignal.set(count) ② prepend 信号晚到prepend.subscriberpendingPrependedCount = count

这是个信号时序问题。

第一次修复:交换顺序

直觉反应是把 prepend 信号设值的时机提前到 data 信号之前。于是把 merge() 拆成了两步——合并数据和触发信号分开:

constresult=this._store.merge(incoming)// 只合并,不触发this._prependSignal.set(result.prependedCount)// prepend 先this._store.notify()// data 后
ChartDataManagerDataBufferKLineDataStore_fetchAndMergeChartDataManagerDataBufferKLineDataStore_fetchAndMerge只合并,不触发信号pendingPrependedCount = count ✓merge(incoming)_prependSignal.set(count) ① prepend 先prepend.subscriberpendingPrependedCount = count ✓notify()dataSignal.set(merged) ② data 后data.subscriber_loadHint.show(count, ...) → 正确了

测试通过,E2E 交互也没有问题了,但这不是解决问题的根本手段,维护成本和心智负担依旧很重。

架构问题

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

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

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

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

activateBuffer()
line 118: buf.data.subscribe()

loadKLineSymbols()
line 724: buf.prepend.subscribe()

pendingPrependedCount
共享可变变量

onKLineBufferChanged()
读 pendingPrependedCount

用一个共享变量跨信号传递信息,本质上是"用副作用做通信"。这在小型原型里能跑,但在一个正在持续迭代的工程里,这就是一颗定时炸弹。

重构成 DataChange

思考了一轮,决定把"变更描述"编码进信号载荷本身。

核心思路很简单:data 信号不再只发一个数组,而是发一个包含数组和变更元数据的结构体:

interfaceDataChange{data:ReadonlyArray<unknown>prependedCount:number// 这次更新头部新增了多少根 K 线}

这样 merge() 可以一次性发出所有信息,消费者一个回调就能拿到全部上下文:

merge(){// ... 合并数据,计算 prependedCount ...this._dataSignal.set({data:[...merged],prependedCount})// 原子化信号,直接带上count数据}
ChartDataManagerKLineDataStore_fetchAndMergeChartDataManagerKLineDataStore_fetchAndMerge一个信号,携带全部信息确定性的,不依赖信号顺序merge(incoming)_dataSignal.set({ data, prependedCount })data.subscriber 收到 DataChangechange.prependedCount ✓compensatePrepend(count)_loadHint.show(count, ...)

vs 旧架构对比:

data 信号
只传数组

prepend 信号
只传数字

pendingPrependedCount
共享变量

DataChange 信号
{ data, prependedCount }

onKLineBufferChanged不再需要从共享变量读值:

// 改前:依赖跨信号协调constcount=this.pendingPrependedCount// 可能还没设上// 改后:直接从变更载荷读constcount=change.prependedCount// 确定性的

同时删掉了:

  • _prependSignal——不再需要独立的 prepend 信号
  • pendingPrependedCount——不再需要共享变量
  • _prependUnsub——不再需要单独的订阅清理
  • notify()方法——merge 直接触发信号,不再分两步走

改动涉及 6 个文件,但总代码量几乎没变(+64/-61 行)。TypeScript 类型系统保证了所有消费端都被更新到位。

几点感想

不要把一件事拆成两个信号。如果一个逻辑事件产生两条影响,把这两条影响打包成一个整体传递出去,而不是拆成两个独立信号让消费者自己去拼。信号是给消费端提供确定性的,不是给消费端出谜题的。AI 编码也要注意此类隐性架构问题。

共享可变变量做协同,看着方便,长期有毒。pendingPrependedCount最初可能只是"暂时放一下",但随着代码演化,它变成了 data 和 prepend 两个订阅者之间唯一的沟通渠道。没有类型标注、没有文档说明、没有运行时检查。此类问题在 AI 编码过程中也要主动去消解架构债务。

信号时序依赖是隐式契约。隐式契约的特点是——直到有人打破它,你都不知道它存在。如果一段代码的正确性依赖于 A 在 B 之前执行,但代码里没有任何一处显式表达这个顺序,那就应该重构。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询