响应式内核怎么追依赖
Vue 的 ref、Preact 的 signal、Solid 的 createSignal——这些「改一个值,用到它的地方自动更新」的能力,底层都是同一套 push-pull 依赖追踪:谁读了谁(依赖图)用一张双向链表记下来,改值时先低成本地推一遍标记
(push),真要用到时再沿版本号回头核对是否变脏 (pull)。本系列顺着 Preact「Signal Boosting」→ Vue 3.4 重写 → alien-signals 这条演进线,把这套内核逐步拆开:为什么不用 Set、版本号如何省掉无谓重算、push 和 pull 如何分工、Link
节点如何零分配复用。每一节都能单步观察依赖图里 Link 建立、version 号自增、dirty / pending 标记的置位与清除。
阅读本系列假定你用过任一响应式框架 (Vue / Preact / Solid),了解 signal / computed / effect 的基本含义。
本页脉络:为什么需要一套内核 → 依赖图 + Link 双向链表 → 版本号 + lazy pull → propagate 推送 + 批处理 → 零分配 + Link 复用。
1 · 为什么需要一套内核:朴素响应式会重复计算
响应式要解决的事很朴素:派生值要在它依赖的源变化时自动更新。total = price * qty,改了 price,total
自己跟上。最直接的实现是「谁变了,就把用到它的派生值全部重算一遍」。可行,但一旦依赖图里出现钻石(一个值被两条路径共同依赖),它就会把同一个值算两遍,中间还会出现一个用了一半旧数据的错值 (glitch)。下面这个钻石单步运行即可看到:
钻石依赖:a 是源;b = a+1 和
都依赖 a;d = b+c 同时依赖 b 和 c;effect 打印 d。a 一变,b、c 都需更新,而 d 依赖二者——朴素的「逐路通知、立刻重算」会让 d 被重算两次。
问题其一 重复计算 + glitch:朴素法里 b 一更新就立刻把 d 算了一遍——可此刻 c 仍是旧值,于是 d 先得到一个错值、effect 还把它显示了出来;等 c 也更新、d 再算一遍才正确。push-pull 内核先把 b、c、d 全标记成「可能脏」(push),标记完成后再统一拉取 (pull),d 因此只在 b、c 都就绪后算一次,没有中间错值。这正是 propagate「钻石只标一次」的意义。
1.1 · 另外两个问题
问题其二 每轮都分配临时对象——朴素实现常给每个 effect 存一个 Set<依赖>,每次重跑就 new Set() 重建。一个高频更新的组件,一秒就能分配成千上万个临时 Set,增加 GC 压力——卡顿往往来自这里。→ 解法:用双向链表 Link 原地复用,零分配。
问题其三 读到过期值 (stale)——如果 computed 靠「上次算的结果」缓存,又没有可靠的失效机制,就可能在源已经变化后仍返回旧值;Vue 3.4 之前就有 computed 在组件卸载后 stale 的 bug。→ 解法:用 version 号判断缓存新鲜度,从结构上保证不 stale。
三个问题——重复计算、分配临时对象、读到过期值——都指向同一套解法:一张双向链表依赖图 + 版本号 + push/pull 分工。这正是本系列接下来四节要拆解的内核,从 依赖图 + Link 双向链表 开始。
2 · 依赖图:谁读了谁,用双向链表记下来
响应式的第一步是知道谁依赖谁。没人手写这张图——它是跑出来的:让一个 effect 或 computed 跑一遍,它读到的每个 signal,就在「读者」和「被读者」之间建一条 Link。关键在 Link 不是单向的:它同时挂在两条链上——被读的 signal
的「下游 subscribers 链」、读者自己的「上游 dependencies 链」(Preact 管这叫 "quad-linked")。这样「signal 变了要通知谁」和「这个 computed 依赖了谁」都是 O(1) 顺着链走,不用 Set。
看这张图:方块 = signal(源,可改)、圆角框 = computed(派生,惰性算)、胶囊 = effect(副作用,读到啥就跑啥)。箭头 = 一条 Link,从依赖指向读者。这里 pick = flag ? a : b 是条件依赖:flag 为真只读 a、为假只读
b——所以图里始终只有一条
或
,切换时可以看到旧 Link 被回收、新 Link 建立。
2.1 · 为什么是双向链表,而不是 Set
最直觉的实现是:每个 signal 存一个 Set<subscriber>,每个 effect 存一个 Set<signal>。能用,但有代价:
- 增删要算 hash——Set.add / delete 要算哈希、可能 rehash;双向链表插入/删除一个 Link 节点是纯指针操作,O(1) 且无哈希开销。
- 每轮重跑都分配临时对象——effect 重跑要重建依赖集,朴素做法
new Set()逐个 add,旧 Set 成为垃圾。链表可以原地复用同一批 Link 节点(见 零分配 + Link 复用)。 - 双向才能两头走——signal 变了要顺着「下游链」通知 subscribers (push);computed 要算时要顺着「上游链」核对 dependencies (pull)。两个方向都 O(1) 遍历,一条 Link 同时在两条链上。
建好这张「谁依赖谁」的图后,下一步是:signal 改值时,如何不重算就判断哪些 computed 缓存失效了?答案是给每个值挂一个 version 号,详见 版本号 + lazy pull。
3 · 版本号:先比对版本,一致就跳过重算
computed 是惰性的——不读不算,算完缓存。难点在:源变了之后,怎么不真的重算就知道某个缓存还能不能用?做法是给每个值挂一个单调递增的 version 号:signal 每次改值 version++;每条 Link
记下「上次读到时的版本」。要用某个 computed 时,checkDirty 沿它的依赖逐级比对版本号——全对得上 = 没人变过 = 直接用缓存;只要有一条对不上,才真的重算。版本号把「判断脏没脏」从「重算一遍看看」降成了「比几个整数」。
看图里的数字:每个框右上角的 v3 是该节点自己的 version(值变一次涨一次);比对时边上会冒出 v2 小标 = 那条 Link 记录的版本。例子是个两级链:bucket = floor(price/10)*10 → tax = bucket * 0.1 → effect 打印 tax。关键在于 bucket 只在 price 跨过 10 的整数档时才真正改变——price
从 10 改到 13,bucket 仍是 10,于是 tax 的版本比对一致,整条链短路、均不重算。
3.1 · 多级短路:它一层层省
Preact 的 computed 实际上有四道关卡,从最便宜到最贵,任何一关能否定「脏」就立刻返回缓存:
- 全局 version 关——自上次计算以来,没有任何 signal 改过值(全局 version 没动)→ 整个世界没变,直接用缓存。最便宜。
- 没被通知关——这个 computed 压根没被 propagate 标记过(没人通知它上游变了)→ 缓存有效。
- 依赖版本关——逐个依赖比对版本号(上图演示的就是这关);全对得上 → 上游的值其实没变,不重算。
- 真重算——前三关都没拦住,才真的跑一遍 fn。算完若新值还等于旧值, version 仍不涨 → 下游继续短路。
版本号解决了「读取时如何省」。另一半是:signal 改值的那一刻,内核做了什么?它没有立刻顺着图重算,而是先低成本地推一遍标记,详见 propagate 推送 + 批处理。
4 · propagate:改值先推一遍标记,不立刻算
set 一个 signal 时,内核不顺着图把下游重算一遍——那样钻石依赖会重复算、一批改动会反复触发。它分成两段:push (propagate) 沿「下游链」把所有下游标成 pending(可能脏)、把 effect 入队,只标记、不计算,成本极低;pull (flush) 等这一轮改动都标记完成后,才对排队的 effect 跑 checkDirty 区分真脏假脏、真脏才算。这一步把「谁可能受影响」和「是否需要重算」拆开,换来两个收益:钻石只标一次、一批改动只 flush 一次。
例子是个钻石 + 一个旁路:b=a+1、c=a*2 都依赖 a,sum=b+c+k 同时依赖 b、c 和另一个 signal k,effect 打印 sum。改 a 时 propagate 会从 b、c 两条路都走到 sum——但 sum
第二次被到达时已是 pending,直接跳过,所以 sum 全程只标一次、之后也只算一次(这正是 why「算两遍」问题的解法)。
4.1 · 批处理:一批改动,只跑一次 effect
既然 propagate 只标记、flush 才计算,那么把多次 set 之间的 flush 暂停,就能让一连串改动只在最后统一 flush 一次——这正是 Vue 的 pauseScheduling() / resetScheduling() 和 React 的批量更新所做的事。对比上面的 batch 与
不 batch:同样改 a 和 k,前者 effect 只跑 1 次,后者跑 2 次。
- 钻石去重——propagate 遇到已 pending 的节点直接跳过 → 一个 computed 一轮最多被标记一次、之后 checkDirty 也只算一次。从根上避免「算两遍」的 glitch。
- 值没变就不往下传 (Vue #5912)——flush 时若某 computed 重算后值没变(version 不涨),它的下游 checkDirty 一比版本就对上 → 停在这里,不再往下传播。version 演示过这个短路。
- effect 入队去重——同一个 effect 被多条路径标记,也只入队一次;flush 时一个 effect 只跑一遍。
push 标记、pull 计算、还能批处理——这套结构很简洁,但每次 effect 重跑都要重建依赖。如果每轮都 new Set(),GC 压力会很大。零分配 + Link 复用 一节展示现代内核如何把 Link 零分配地复用。
5 · 零分配:Link 不重建,回收复用
每次 effect / computed 重跑,都要重新确定「这一轮读了谁」。朴素做法是每轮 new Set(),逐个 add——跑一万次就分配一万个临时 Set 交给 GC。现代内核采用另一种思路:用 startTracking / endTracking 把一次运行包裹起来。运行前,把这个节点现有的 Link
全标「这轮尚未读到」(used=false);运行过程中读到谁,就把对应 Link 标 used=true——同一个 Link 对象原地复用,不分配;收尾时,凡是仍为 used=false 的 Link 就回收。依赖未变的轮次,新分配 = 0。
看右下角两个计数器:朴素 (new Set) 每读一个依赖就 +1(每轮都在分配);复用 (Link) 只在依赖真正改变、需要新建 Link 时才 +1。依赖稳定时,反复重跑,复用计数保持不变。例子:pick = flag ? a : b → twice = pick*2 →
effect。
5.1 · 进一步:alien-signals 的约束
把「不分配」这条路走到底,就是 alien-signals 给自己定的硬性约束:
- 核心算法不用 Array / Set / Map——依赖关系全靠 Link 节点上的指针串成链表,不借助任何会动态扩容、产生垃圾的集合类型。
- 不用函数递归——propagate / checkDirty 改成迭代 + 显式栈(链表本身就能当栈用),避免深图爆栈、也省掉调用帧开销。
- 状态压入位标志 (flags)——dirty / pending / 正在运行 / 是否被订阅… 全部压进一个整数的不同 bit,判断和切换都是位运算。
- 结论——作者发现:把算法本身做简单,比堆叠复杂的调度策略更能提速。这套内核现已反过来被 Vue 借鉴。
五节串完了:why 摆清朴素响应式的三个问题 → graph 用双向链表记下「谁依赖谁」→ version 用版本号判断缓存能不能用 → propagate 把改值拆成 push 标记 / pull 计算、还能批处理 → recycle 让这套结构零分配地跑。这就是 Preact / Vue 3.4+ / Solid / alien-signals 共享的响应式内核。
6 · 它真实跑在哪里
Preact Signals:博客「Signal Boosting」讲的就是这套双向链表 Node + 全局 version + lazy 求值的来历。
Vue 3.4+:PR #5912 先把 computed 做成「值没变就不传播」+ 批处理调度;PR #10397 再把整套响应式从 Set 换成 Preact 式 Link 双向链表 + version,内存直降
56%、computed 不再 stale。
alien-signals:把上面所有思想提炼成一个最小内核——核心算法不用 Array/Set/Map、不用函数递归,以 flag 位运算表达状态;现已反过来被 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 尚无单一官方规范, 这是目前最接近的标准化方向。