开源高性能金融图表,欢迎使用
体验: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 的提示覆层上去。
这是个信号时序问题。
第一次修复:交换顺序
直觉反应是把 prepend 信号设值的时机提前到 data 信号之前。于是把 merge() 拆成了两步——合并数据和触发信号分开:
constresult=this._store.merge(incoming)// 只合并,不触发this._prependSignal.set(result.prependedCount)// prepend 先this._store.notify()// data 后测试通过,E2E 交互也没有问题了,但这不是解决问题的根本手段,维护成本和心智负担依旧很重。
架构问题
仔细想想,这个所谓的"修复"只是把问题往下游推了一层。当前的设计依赖一个微妙的隐含契约:
- 一个逻辑事件(merge 数据)被拆成两个独立信号(data + prepend)
- 两个信号必须按特定顺序被消费,否则数据不一致
- 两个信号之间的协调靠一个共享可变变量(
pendingPrependedCount)
整个代码里其实没有一个地方显式写了"prepend 订阅者必须在 data 订阅者之前运行"。这个顺序完全是靠"谁先注册订阅"和"信号在代码里出现的先后顺序"隐式保障的。新来一个开发者,或者有人顺手调整了订阅注册顺序,这个 bug 就会再次出现。
而且两个订阅者散落在相隔 600 行的不同方法里——activateBuffer注册 data 订阅,loadKLineSymbols注册 prepend 订阅。要理解它们之间的依赖关系,得在编辑器里来回跳。
用一个共享变量跨信号传递信息,本质上是"用副作用做通信"。这在小型原型里能跑,但在一个正在持续迭代的工程里,这就是一颗定时炸弹。
重构成 DataChange
思考了一轮,决定把"变更描述"编码进信号载荷本身。
核心思路很简单:data 信号不再只发一个数组,而是发一个包含数组和变更元数据的结构体:
interfaceDataChange{data:ReadonlyArray<unknown>prependedCount:number// 这次更新头部新增了多少根 K 线}这样 merge() 可以一次性发出所有信息,消费者一个回调就能拿到全部上下文:
merge(){// ... 合并数据,计算 prependedCount ...this._dataSignal.set({data:[...merged],prependedCount})// 原子化信号,直接带上count数据}vs 旧架构对比:
onKLineBufferChanged不再需要从共享变量读值:
// 改前:依赖跨信号协调constcount=this.pendingPrependedCount// 可能还没设上// 改后:直接从变更载荷读constcount=change.prependedCount// 确定性的同时删掉了:
_prependSignal——不再需要独立的 prepend 信号pendingPrependedCount——不再需要共享变量_prependUnsub——不再需要单独的订阅清理notify()方法——merge 直接触发信号,不再分两步走
改动涉及 6 个文件,但总代码量几乎没变(+64/-61 行)。TypeScript 类型系统保证了所有消费端都被更新到位。
几点感想
不要把一件事拆成两个信号。如果一个逻辑事件产生两条影响,把这两条影响打包成一个整体传递出去,而不是拆成两个独立信号让消费者自己去拼。信号是给消费端提供确定性的,不是给消费端出谜题的。AI 编码也要注意此类隐性架构问题。
共享可变变量做协同,看着方便,长期有毒。pendingPrependedCount最初可能只是"暂时放一下",但随着代码演化,它变成了 data 和 prepend 两个订阅者之间唯一的沟通渠道。没有类型标注、没有文档说明、没有运行时检查。此类问题在 AI 编码过程中也要主动去消解架构债务。
信号时序依赖是隐式契约。隐式契约的特点是——直到有人打破它,你都不知道它存在。如果一段代码的正确性依赖于 A 在 B 之前执行,但代码里没有任何一处显式表达这个顺序,那就应该重构。