系统设计 / 任务运行时 · 从一次 run() 到可观测的调度层 / 响应式依赖图:push 标记与 pull 求值 待审核 9 / 10
reactive graph

响应式依赖图:push 标记与 pull 求值

一张报价表单上,改一格 country 要重查税率、重算保费与总价;改一格金额只该动本地那条链,两次后端往返一次都不必再打。手写这套联动的代价是每加一个字段都要重新回答「谁依赖谁」,异步又把问题翻了一倍:慢请求回来时,输入可能已经换过两轮。本系列前面几页把单个任务、队列、编排与协调器逐一看过,最后这一层不再往内核里加东西,只把它编排起来:createReactiveGraph(runtime, options)run()createScope 之上叠出持久的结点身份、结果记忆与脏传播。

1 · signal 与 node

图上只有两种结点。signal 是叶子输入,set(key, value) 写它,值一变就把下游标脏。node 是计算结点,注册时给一个 run(ctx),它的返回值就是这个结点的值。node 的依赖不预先声明:这一次运行里经 ctx.get(key) 读到谁,谁就是它这一代的依赖;上次读过而这次没读的边随即被丢掉,条件分支切换时旧边自动失效。

与引擎的 task 相比,图层加的是身份与记忆。task 是一次性的,run() 一次、走到终态、句柄作废;结点按 key 索引、跨多次执行存活,clean 的结点直接交出上次的值,不再跑 run

读依赖走显式的 ctx.get,而不是同步 signal 库那种全局 currentObserver 指针,原因在 await:一个结点的 run 让出之后,另一个结点的运行会把指针覆盖掉,AsyncLocalStorage 又不在浏览器里。ctx 绑定在结点上,读的归属就记不错。

写与读的成本刻意不对称。setinvalidate 只做标记:沿反向边把后继染色、取消受影响子树里在飞的重算、通知 watcher,一个值都不算。get 才求值,且只求这一次真正需要的那部分。中间的记账靠每个结点的 version,提交值真的变了才加一,「真的变了」由结点的 equals 判定,默认 Object.is。返回新对象的结点必须换成 shallowEqual 之类的比较,否则引用每次都不同、version 每次都升,下一节的剪枝一次也不会发生。

2 · 颜色标记与值相等剪枝

结点有三种颜色。clean 表示缓存可信,直接返回;check 表示某个祖先动过、本结点是否真受影响还没核对;dirty 表示必须重算,从未算过、被 invalidate 点过、上次运行抛错的结点都是这一色。set 的标记沿反向边走,遇到已经是 check 或 dirty 的分支就停止下探,那棵子树在更早一次传播里已经整体标过。

求值时的核对是剪枝所在。一个 check 且有值的结点先把上次读过的每个依赖 pull 一遍,逐个比对「读取时记下的 version」与「此刻的 version」;一个都没变,它就翻回 clean 并交出缓存,run 一次也不跑。这条规则在动态依赖下依然成立:输入全都没变,控制流就不会变,这一次会读的依赖集与上次相同。

于是「上游重算后值相等」有了直接的收益。上游跑完,equals 判出与旧值相等,version 不动,下游核对时看到「无依赖变化」,整条链就此停住。

本页的图取自 packages/job/example/job/devtools.htmluserIdfilter 两个 signal,profileorders 各查一次后端,stats 从 orders 汇总出 count 与 total 并配了 shallowEqualview 读 profile 与 stats 拼成一行字。首次 get('view') 让四个 computed 各跑一次。此后 invalidate('orders') 再读一次 view,orders 与 stats 各多跑一次,view 一次也没跑(lab/facts.test.ts §2):orders 每次返回新数组、version 必升,stats 却算出同一对字段、浅相等成立、version 不动,view 被剪在了 stats 这一层。把 filter 从 all 换成 paid 时 stats 从 3 单变 2 单,version 升,view 才跟着重算。

invalidate 不预先升 version,只把结点染成 dirty。强制刷新因此也享受剪枝:外部缓存过期了要重查,查回来发现数据没变,下游一个都不用动。

图 2-1 · 五结点依赖图的实时状态:结点上写着 version 与 run() 次数,边在被真正读过之后由虚线转实线。可切换 userId 与 filter、点 invalidate(orders),再按 get(view) 观察哪些结点被剪掉。

注 · mountReactiveDevtoolsinspect() 的快照渲染成一块面板,与图 2-1 里那张手画的图读的是同一份数据;快照与事件流的分工见观测与调试。面板是事件驱动的,订阅 graph.onChange 后按动画帧合并重渲染。ReactiveDevtoolsOptions 只有 title 一个字段——它是普通对象类型不是精确类型,多传的字段既不报错也不生效,写配置时容易误以为调上了。

3 · 异步下多出来的机制

同步的 signal 库里,一次重算是一个原子的同步调用,开始与结束之间没有别的代码插得进来。异步结点没有这层保护:一次重算跨很多个 tick,中途还可以被取消。两道机制为此而生。

stale guard 是逐依赖的版本重检。结点在运行期间记下每次 ctx.get 读到的依赖与它当时的 version,提交前逐个比对,任一不符就丢弃结果并抛 CancelledError,由 get() 在新一代重读。它比全局 epoch 精确:无关结点的一次 set 不会误废本结点在飞的重算。

subtree cancel 省的是资源。每个在飞的重算持一个 AbortControllersetinvalidate 时沿反向边把受影响子树里在飞的重算全部 abort;结点内部把 ctx.signal 透传给请求,注定作废的那个往返就能提前断掉。

两者的分工由一处实测划得很清楚:首次运行的结点还没提交过反向边,沿反向边走的取消根本找不到它。冷图上连改四次,风暴期间的取消数为 0,第一次 800ms 的往返完整跑完,再被 stale guard 整份丢弃(lab/facts.test.ts §3)。预热一次之后,同一段风暴才有取消发生。

第三件事是协调窗口。get() 的重读是无条件的,重算一被打断就再读一次,失效不停它就一直读。coalesceMs > 0 让读者在被打断之后先等失效风暴静默这么久,再重读一次。

协调窗口的尺度是一处原以为不必细究的量。example/job/reactive-quote.html 给的窗口是 40ms,而它的演示按钮以 120ms 的间隔连改国家,原本以为请求数下降就是这个窗口的功劳。实测三档窗口跑同一段风暴(失效间隔 120ms、往返 800ms):0 与 40 都是发起 3 次、取消 2 次、返回 1 次,只有 200 才落到发起 1 次、取消 0 次(lab/facts.test.ts §3)。窗口短于失效间隔时,读者每次都在下一次 set 到来之前醒来重读,与完全不协调没有区别。那段演示省下的往返来自 subtree cancel,协调窗口要压过失效间隔才开始起作用。

图 3-1 · 一个 signal 与一个 800ms 异步结点,连改四次的时间轴:上排是 set,下排是请求的发起、取消与返回。可切换 coalesceMs 三档,并在冷启动与预热两种起点之间对照,观察发起数与取消数的差别。

警示 · subtree cancel 省的是往返,正确性由 stale guard 独立保证:即便结点完全不读 ctx.signal,跑完的旧结果也会在提交前因版本不符被丢弃。反向的推论不成立,只靠取消保证不了正确性,首次运行那一轮取消就够不着。

4 · 保险报价表单

三样机制在一张真实表单上是可数的。图有四个输入:country、amount、plan、currency。taxRate 依赖 country,fxRate 依赖 currency,两者各是一次 800ms 的后端往返;premium 是 amount 与 plan 倍率的乘积,tax 是 premium 乘税率,totalBase 是两者之和,quote 换汇并把六个字段打包成一个对象,配 shallowEqual 比较。

改 amount 或 plan,重算只沿 premium 那条本地链走,两个 API 的计数一动不动;改 country,税率 API 多一次,汇率命中缓存(lab/facts.test.ts §4)。这两个数字直接来自「clean 结点交出缓存」这一条规则,没有额外的缓存策略。

连改四次国家(间隔 120ms、往返 800ms)时,税率 API 发起了不止一次,真正返回的只有一次,汇率一次也没查(lab/facts.test.ts §4)。前几次在飞的请求都被后一次 set 沿反向边取消掉了。

一处容易被忽略的副作用:watch 会让被监听的那个 key 变成 eager。set 之后的 notify 会替它主动 pull 一次,不等谁来调 get。lazy 只在没有 watcher 的 key 上成立。watcher 的投递也不参与协调窗口:触发它的那次 set 会再送一次最新值,与正在进行的重读各走各的。

图 4-1 · 保险报价表单:两条异步链与四个派生结点,计数把 API 往返与结点重算分开算。可改任一输入观察哪几个计数在动,或点连改国家观察在飞请求被取消。

5 · 错误语义与环检测

结点抛出的真实错误(非 CancelledError)向 get() 的调用方传播,并把结点留在 dirty:错误不被记忆,下一次读从头重试一遍(lab/facts.test.ts §5)。这套语义对付的是瞬时失败;持续失败的结点每次读都会重跑,按依赖变化缓存错误是这一层当前明确不做的事。

判据是 instanceof CancelledError,而这条判据有一个尖角。结点内部若直接 await sleep(ms, ctx.signal),或把 signal 交给 fetch,被取消时抛出的是平台约定的 AbortError(一个 DOMException),图会把它当成结点真的出错。实测同一段脚本的两种写法(lab/facts.test.ts §3):用 sleep 时,被取代的那次 get()AbortError 拒绝、结点停在 dirty;把 abort 翻译成 CancelledError 之后,get() 透明重读并交出新一代的值、结点回到 clean。图的保证是「被取代的那次重算抛 CancelledError、什么都不提交」,而把 ctx.signal 透传给 fetchsleep 抛出来的是 AbortError——两者之间缺的就是这一层翻译,得由结点自己补:catch 到 signal.aborted 时改抛 CancelledError。本页三个 lab 共用的 lab/fake-api.ts 做的就是这件事。

环检测走 pull 链的 ancestry:ctx.get 把当前正在 pull 的 key 链一路带下去,结点读到自己的祖先即抛错,消息里带出整条链,例如 reactive: dependency cycle detected: a → b → alab/facts.test.ts §5)。与 after 依赖图在 run() 时的静态校验不同(见依赖与编排),动态图上的环只在那条分支真的被执行时才暴露,条件依赖让「有没有环」成了运行期才知道的事。

其余的限制一并列出:错误不记忆;watcher 通道只投递成功值,变化之后出错要 refresh() 才重新浮出;协调只作用在 get 的重读路径上;依赖一律动态,NodeCtx 上没有静态声明依赖的口子。

这一层到此为止,它只用 run()createScope 两样公共原语,内核一行没改。把它接进组件、让结点的值随组件生命期起落,是 Vue 集成 的事。与浏览器里那套同步 signal 内核的逐层对照,见 Signals 响应式内核