Web 平台 API / 响应式内核怎么追依赖 待审核
signals · push-pull 依赖追踪内核

响应式内核怎么追依赖

Vue 的 ref、Preact 的 signal、Solid 的 createSignal——这些「改一个值,用到它的地方自动更新」的能力,底层都是同一套 push-pull 依赖追踪:谁读了谁(依赖图)用一张双向链表记下来,改值时先低成本地推一遍标记 (push),真要用到时再沿版本号回头核对是否变脏 (pull)。

本页顺着 Preact 的「Signal Boosting」、Vue 的两次重写、alien-signals 这条演进线,把这套内核逐层拆开:为什么不用 Set、版本号如何省掉无谓重算、push 和 pull 如何分工、Link 节点如何零分配复用。阅读本页假定读者用过任一响应式框架(Vue、Preact、Solid),了解 signal、computed、effect 的基本含义。

五节依次是朴素响应式的三个问题依赖图与 Link 双向链表版本号与 lazy pullpropagate 的两段零分配与 Link 复用

1 · 朴素响应式的三个问题

响应式要解决的事很朴素:派生值要在它依赖的源变化时自动更新。total = price * qty,改了 price,total 自己跟上。最直接的实现是「谁变了,就把用到它的派生值全部重算一遍」。可行,但一旦依赖图里出现钻石(一个值被两条路径共同依赖),它就会把同一个值算两遍,中间还会出现一个用了一半旧数据的错值 (glitch)。

图 1-1 · 一个钻石依赖:a 是源,b = a+1 与 c = a×2 都依赖 a,d = b+c 同时依赖 b 和 c,effect 打印 d。可单步运行,看 d 如何被重算两次、中间还闪过一个错值。

警示 · 问题其一是重复计算与 glitch。朴素法里 b 一更新就立刻把 d 算了一遍,可此刻 c 仍是旧值,于是 d 先得到一个错值、effect 还把它显示了出来;等 c 也更新、d 再算一遍才正确。push-pull 内核先把 b、c、d 全标记成「可能脏」(push),标记完成后再统一拉取 (pull),d 因此只在 b、c 都就绪后算一次,没有中间错值。这正是 propagate 那节「钻石只标一次」的意义。

问题其二是每轮都分配临时对象。朴素实现常给每个 effect 存一个 Set<依赖>,每次重跑就 new Set() 重建。一个高频更新的组件,一秒就能分配成千上万个临时 Set,增加 GC 压力,卡顿往往由此而来。解法是用双向链表 Link 原地复用

问题其三是读到过期值 (stale)。如果 computed 靠「上次算的结果」缓存,又没有可靠的失效机制,就可能在源已经变化后仍返回旧值;Vue 3.4 之前就有 computed 在组件卸载后 stale 的 bug。解法是用 version 号判断缓存新鲜度,从结构上保证不 stale。

三个问题——重复计算、分配临时对象、读到过期值——都指向同一套解法:一张双向链表依赖图,加版本号,加 push / pull 分工。

响应式的第一步是知道谁依赖谁。没人手写这张图,它是跑出来的:让一个 effectcomputed 跑一遍,它读到的每个 signal,就在读者和被读者之间建一条 Link。关键在 Link 不是单向的,它同时挂在两条链上——被读 signal 的下游 subscribers 链,以及读者自己的上游 dependencies 链,Preact 管这叫 quad-linked。这样「signal 变了要通知谁」和「这个 computed 依赖了谁」都是 O(1)O(1) 顺着链走,不用 Set

图 2-1 · 方块是 signal(源,可改)、圆角框是 computed(派生,惰性算)、胶囊是 effect;箭头是一条 Link,从依赖指向读者。图中 pick = flag ? a : b 是条件依赖,所以始终只有一条 a→pick 或 b→pick。可切换 flag,看旧 Link 被回收、新 Link 建立。

2.1 · 双向链表与 Set 的取舍

最直觉的实现是:每个 signal 存一个 Set<subscriber>,每个 effect 存一个 Set<signal>。能用,但有三处代价。

  • 增删要算 hash。Set.adddelete 要算哈希、可能 rehash;双向链表插入或删除一个 Link 节点是纯指针操作,O(1)O(1) 且无哈希开销。
  • 每轮重跑都分配临时对象。effect 重跑要重建依赖集,朴素做法 new Set() 逐个 add,旧 Set 成为垃圾。链表可以原地复用同一批 Link 节点,见零分配与 Link 复用
  • 双向才能两头走。signal 变了要顺着下游链通知 subscribers (push);computed 要算时要顺着上游链核对 dependencies (pull)。两个方向都 O(1)O(1) 遍历,一条 Link 同时在两条链上。

3 · 版本号与 lazy pull

computed 是惰性的:不读不算,算完缓存。难点在源变了之后,如何不真的重算就判定某个缓存还能不能用。做法是给每个值挂一个单调递增的 version 号——signal 每次改值 version++,每条 Link 记下上次读到时的版本。要用某个 computed 时,checkDirty 沿它的依赖逐级比对版本号:全对得上就说明没人变过,直接用缓存;只要有一条对不上,才真的重算。逐级比对的代价只是几次整数比较。版本号把「判断脏没脏」从「重算一遍再对比」降成了「比几个整数」。

图 3-1 · 每个框右上角的 v3 是该节点自己的 version,比对时边上会冒出 v2 小标,即那条 Link 记录的版本。例子是两级链 bucket = floor(price/10)*10 → tax = bucket * 0.1 → effect。price 从 10 改到 13 时 bucket 仍是 10,版本比对一致,整条链短路、均不重算。

3.1 · 四道短路关卡

Preact 的 computed 实际上有四道关卡,从最便宜到最贵,任何一关能否定「脏」就立刻返回缓存。

  • 全局 version 关:自上次计算以来没有任何 signal 改过值(全局 version 没动),整个世界没变,直接用缓存。最便宜。
  • 没被通知关:这个 computed 压根没被 propagate 标记过,缓存有效。
  • 依赖版本关:逐个依赖比对版本号,也就是图 3-1 演示的那一关;全对得上说明上游的值并未改变,不重算。
  • 真重算:前三关都没拦住,才真的跑一遍 fn。算完若新值还等于旧值,version 仍不涨,下游继续短路。

4 · propagate 的标记与计算两段

set 一个 signal 时,内核不顺着图把下游重算一遍——那样钻石依赖会重复算、一批改动会反复触发。它分成两段:push (propagate) 沿下游链把所有下游标成 pending(可能脏)、把 effect 入队,只标记、不计算,成本极低;pull (flush) 等这一轮改动都标记完成后,才对排队的 effect 跑 checkDirty 区分真脏假脏、真脏才算。这一步把「谁可能受影响」和「是否需要重算」拆开,换来两个收益:钻石只标一次,一批改动只 flush 一次。

图 4-1 · 一个钻石加一个旁路:b=a+1、c=a*2 都依赖 a,sum=b+c+k 同时依赖 b、c 和另一个 signal k,effect 打印 sum。改 a 时 propagate 会从 b、c 两条路都走到 sum,但 sum 第二次被到达时已是 pending、直接跳过,全程只标一次、之后也只算一次。

4.1 · 批处理

既然 propagate 只标记、flush 才计算,那么把多次 set 之间的 flush 暂停,就能让一连串改动只在最后统一 flush 一次——这正是 Vue 的 pauseScheduling()resetScheduling()、以及 React 批量更新所做的事。图 4-1 里对比 batch 与不 batch:同样改 a 和 k,前者 effect 只跑 1 次,后者跑 2 次。

三种去重叠在一起才有这个效果。其一是钻石去重,propagate 遇到已 pending 的节点直接跳过,一个 computed 一轮最多被标记一次。其二是值没变就不往下传(Vue PR #5912),flush 时若某 computed 重算后值没变、version 不涨,它的下游 checkDirty 一比版本就对上,传播就此停住。其三是 effect 入队去重,同一个 effect 被多条路径标记也只入队一次。

每次 effect 或 computed 重跑,都要重新确定「这一轮读了谁」。朴素做法是每轮 new Set() 逐个 add,跑一万次就分配一万个临时 Set 交给 GC。现代内核采用另一种思路:用 startTrackingendTracking 把一次运行包裹起来。运行前,把这个节点现有的 Link 全标「这轮尚未读到」(used = false);运行过程中读到谁,就把对应 Link 标 used = true,同一个 Link 对象原地复用、不分配;收尾时,凡是仍为 used = false 的 Link 就回收。依赖未变的轮次,新分配为 0。

图 5-1 · 右下角两个计数器:朴素 (new Set) 每读一个依赖就加一,每轮都在分配;复用 (Link) 只在依赖真正改变、需要新建 Link 时才加一。依赖稳定时反复重跑,复用计数保持不变。例子是 pick = flag ? a : b → twice = pick*2 → effect。

5.1 · alien-signals 的三条约束

把不分配这条路走到底,就是 alien-signals 给自己定的硬性约束。

  • 核心算法不用 Array、Set、Map。依赖关系全靠 Link 节点上的指针串成链表,不借助任何会动态扩容、产生垃圾的集合类型。
  • 不用函数递归。propagate 与 checkDirty 改成迭代加显式栈(链表本身就能当栈用),避免深图爆栈、也省掉调用帧开销。
  • 状态压进位标志。dirty、pending、正在运行、是否被订阅等等全部压进一个整数的不同 bit,判断和切换都是位运算。

作者由此得出的结论为:把算法本身做简单,比堆叠复杂的调度策略更能提速。

建议 · 五节可以串成一句话回顾:第 1 节摆清朴素响应式的三个问题,第 2 节用双向链表记下谁依赖谁,第 3 节用版本号判断缓存能不能用,第 4 节把改值拆成 push 标记与 pull 计算、还能批处理,第 5 节让这套结构零分配地跑。这就是 Preact、Vue、Solid、alien-signals 共享的响应式内核。

6 · 它真实跑在哪里

Preact Signals 的博客《Signal Boosting》讲的就是这套双向链表 Node 加全局 version 加惰性求值的来历。

Vue 分两步走到今天这套实现,版本号值得分清(核对于 2026-08)。PR #5912 随 Vue 3.4 发布,先把 computed 做成「值没变就不传播」并配上批处理调度;PR #10397 于 2024 年 2 月合入 minor 分支、随 Vue 3.5 发布,才把整套响应式从 Set 换成 Preact 式的 Link 双向链表加 version。PR 里给出的实测是 1000 个 ref、2000 个 computed、1000 个 effect 的场景下内存从 1426k 降到 631k,即 56%,computed 也不再 stale。

alien-signals 把上面所有思想提炼成一个最小内核,而它反过来又被 Vue 借鉴——这条线是双向的:Vue 3.5 的重写取自 Preact 的思路,此后 alien-signals 的成果再回流到 Vue。

Solid、Reactively、Angular signals 同样是 push-pull 的细粒度依赖追踪,细节各有取舍。

相关链接