响应式内核怎么追依赖
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 pull、propagate 的两段、零分配与 Link 复用。
1 · 朴素响应式的三个问题
响应式要解决的事很朴素:派生值要在它依赖的源变化时自动更新。total = price * qty,改了 price,total
自己跟上。最直接的实现是「谁变了,就把用到它的派生值全部重算一遍」。可行,但一旦依赖图里出现钻石(一个值被两条路径共同依赖),它就会把同一个值算两遍,中间还会出现一个用了一半旧数据的错值 (glitch)。
警示 · 问题其一是重复计算与 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 分工。
2 · 依赖图与 Link 双向链表
响应式的第一步是知道谁依赖谁。没人手写这张图,它是跑出来的:让一个 effect 或 computed 跑一遍,它读到的每个 signal,就在读者和被读者之间建一条 Link。关键在 Link 不是单向的,它同时挂在两条链上——被读 signal 的下游 subscribers 链,以及读者自己的上游 dependencies
链,Preact 管这叫 quad-linked。这样「signal 变了要通知谁」和「这个 computed 依赖了谁」都是
顺着链走,不用 Set。
2.1 · 双向链表与 Set 的取舍
最直觉的实现是:每个 signal 存一个 Set<subscriber>,每个 effect 存一个 Set<signal>。能用,但有三处代价。
-
增删要算 hash。
Set.add与delete要算哈希、可能 rehash;双向链表插入或删除一个 Link 节点是纯指针操作, 且无哈希开销。 - 每轮重跑都分配临时对象。effect 重跑要重建依赖集,朴素做法
new Set()逐个 add,旧 Set 成为垃圾。链表可以原地复用同一批 Link 节点,见零分配与 Link 复用。 - 双向才能两头走。signal 变了要顺着下游链通知 subscribers (push);computed 要算时要顺着上游链核对 dependencies (pull)。两个方向都 遍历,一条 Link 同时在两条链上。
3 · 版本号与 lazy pull
computed 是惰性的:不读不算,算完缓存。难点在源变了之后,如何不真的重算就判定某个缓存还能不能用。做法是给每个值挂一个单调递增的 version 号——signal 每次改值 version++,每条 Link 记下上次读到时的版本。要用某个 computed 时,checkDirty
沿它的依赖逐级比对版本号:全对得上就说明没人变过,直接用缓存;只要有一条对不上,才真的重算。逐级比对的代价只是几次整数比较。版本号把「判断脏没脏」从「重算一遍再对比」降成了「比几个整数」。
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 · 批处理
既然 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 被多条路径标记也只入队一次。
5 · 零分配与 Link 复用
每次 effect 或 computed 重跑,都要重新确定「这一轮读了谁」。朴素做法是每轮 new Set() 逐个 add,跑一万次就分配一万个临时 Set 交给 GC。现代内核采用另一种思路:用 startTracking 与 endTracking 把一次运行包裹起来。运行前,把这个节点现有的 Link 全标「这轮尚未读到」(used = false);运行过程中读到谁,就把对应 Link 标 used = true,同一个 Link 对象原地复用、不分配;收尾时,凡是仍为 used = false 的 Link 就回收。依赖未变的轮次,新分配为 0。
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 的细粒度依赖追踪,细节各有取舍。
相关链接
- Signal Boosting — Preact 博客 preactjs.com 双向链表 Node、全局 version、computed 的多级短路 lazy 求值、Node 复用避免 GC 的来龙去脉。
- vuejs/core#10397 — reactivity 换成 Link 双向链表 github.com Vue 3.4 把响应式从 Set 重写成 version-counting 的双向链表, 内存 -56%, computed 不再 stale。
- vuejs/core#5912 — 更高效的 computed 调度 github.com computed「值没变就不触发下游」+ pauseScheduling/resetScheduling 批处理, 基准快 2.5~4.5x。
- stackblitz/alien-signals github.com 无 Array/Set/Map、无递归的最小 push-pull 内核: propagate / checkDirty / startTracking / endTracking。其核心算法已被 Vue 3.6 反向采用。
- tc39/proposal-signals — Signals 提案 github.com TC39 把这套 push-pull 依赖追踪标准化的早期尝试 (Stage 1): 只定义 signal 图的底层语义供各框架共建, 不直接给业务 API。signals 尚无单一官方规范, 这是目前最接近的标准化方向。