Signal 带来的架构问题思考

Signal 带来的架构问题思考
开源高性能金融图表欢迎使用体验https://363045841.github.io/KLineChartQuant/Githubhttps://github.com/363045841/KLineChartQuantNPMhttps://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, ...) → count0, return_prependSignal.set(count) ② prepend 信号晚到prepend.subscriberpendingPrependedCount count这是个信号时序问题。第一次修复交换顺序直觉反应是把 prepend 信号设值的时机提前到 data 信号之前。于是把 merge() 拆成了两步——合并数据和触发信号分开constresultthis._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 交互也没有问题了但这不是解决问题的根本手段维护成本和心智负担依旧很重。架构问题仔细想想这个所谓的修复只是把问题往下游推了一层。当前的设计依赖一个微妙的隐含契约一个逻辑事件merge 数据被拆成两个独立信号data prepend两个信号必须按特定顺序被消费否则数据不一致两个信号之间的协调靠一个共享可变变量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:ReadonlyArrayunknownprependedCount: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不再需要从共享变量读值// 改前依赖跨信号协调constcountthis.pendingPrependedCount// 可能还没设上// 改后直接从变更载荷读constcountchange.prependedCount// 确定性的同时删掉了_prependSignal——不再需要独立的 prepend 信号pendingPrependedCount——不再需要共享变量_prependUnsub——不再需要单独的订阅清理notify()方法——merge 直接触发信号不再分两步走改动涉及 6 个文件但总代码量几乎没变64/-61 行。TypeScript 类型系统保证了所有消费端都被更新到位。几点感想不要把一件事拆成两个信号。如果一个逻辑事件产生两条影响把这两条影响打包成一个整体传递出去而不是拆成两个独立信号让消费者自己去拼。信号是给消费端提供确定性的不是给消费端出谜题的。AI 编码也要注意此类隐性架构问题。共享可变变量做协同看着方便长期有毒。pendingPrependedCount最初可能只是暂时放一下但随着代码演化它变成了 data 和 prepend 两个订阅者之间唯一的沟通渠道。没有类型标注、没有文档说明、没有运行时检查。此类问题在 AI 编码过程中也要主动去消解架构债务。信号时序依赖是隐式契约。隐式契约的特点是——直到有人打破它你都不知道它存在。如果一段代码的正确性依赖于 A 在 B 之前执行但代码里没有任何一处显式表达这个顺序那就应该重构。