Signal 响应式内核深度工程:从依赖图、Glitch-Free 传播到编译期依赖追踪
执行摘要:前端框架的渲染范式正在经历一次"从 O(组件树) 到 O(依赖边)"的迁移。Virtual DOM 的代价不是 diff 本身,而是重渲染粒度过粗——一次状态变更要重跑整棵子树。细粒度响应式(fine-grained reactivity)把状态与副作用建模成一张有向依赖图,变更只沿真实依赖边传播。这篇文章从 Signals 的抽象模型讲起,拆解 glitch(瞬时不一致)的成因与三种解法,给出一个可运行的最小响应式内核(含 epoch 版本号、diamond 修复、O(1) 依赖清理),再讨论编译期依赖追踪(Svelte 5 runes / Vue Vapor)如何把运行时开销进一步压到零,最后列出生产环境的六个陷阱。
一、先算笔账:为什么要摆脱 Virtual DOM
React 的经典模型是"状态变了 → 重跑组件函数 → 生成新 VDOM → diff → patch"。它的复杂度与受影响的组件子树规模成正比,而不是与真正变化的状态量成正比。
一个 500 行的表格,只改了其中一个单元格:
- VDOM 模型:重跑表格组件(可能还有它的全部子组件),生成 500 个 vnode,diff 500 次,最后 patch 1 个 DOM 文本节点。
- 细粒度模型:那条文本节点订阅了那个 signal,signal 被写入 → 直接
textContent = newValue。一次函数调用,零 diff。
这正是 Solid、Svelte 5、Vue Vapor Mode、Angular Signals、Preact Signals 集体转向的原因。注意这不是"更快"这么简单——响应式让开发者可以不再思考 memoization。React 里 useMemo / useCallback / memo 本质上是在手工维护一张依赖图,而框架本可以自动维护它。
但响应式不是银弹。它把复杂度从"渲染时刻"搬到了"依赖图维护时刻",而后者有一整套自己的坑,其中最著名的就是 glitch。
二、模型:Signal 是一张带版本的双向图
无论 API 长什么样(Solid 的 createSignal、Vue 的 ref、Angular 的 signal、Svelte 5 的 $state、TC39 提案的 Signal.State),底层都是同一套抽象:
| 概念 | 角色 | 关键状态 |
|---|---|---|
| Signal(源) | 图中的叶子生产者 | value、version、订阅者集合 |
| Computed(派生) | 既是消费者又是生产者 | value、version、dirty 标记、上游集合、下游集合 |
| Effect(汇) | 只消费不产出 | 回调、上游集合、调度标记 |
关键点在于:依赖是运行时自动收集的,不是声明的。靠的是一个全局的"当前正在求值的消费者"指针:
// 全局唯一的"当前订阅者"指针
let activeSub: Subscriber | undefined = undefined;
function track(signal: Signal) {
if (activeSub === undefined) return; // 不在求值上下文中 → 不建立依赖
if (activeSub.deps.has(signal)) return; // 本轮已订阅过,去重
activeSub.deps.add(signal);
signal.subs.add(activeSub); // 双向边,用于 O(1) 反向清理
}
读一个 signal 时 track,写一个 signal 时 trigger。计算属性在求值前把 activeSub 设为自己,求值完后恢复——这就是"自动依赖追踪"的全部魔法,代价是每个 getter 多一次分支判断。
三、Glitch:钻石依赖下的瞬时不一致
考虑这张经典的菱形(diamond)依赖图:
a
/ \
b c
\ /
d d = b + c, b = a + 1, c = a * 2
a 从 1 变成 2。如果传播是朴素的深度优先 push(a → b → d → c → d),那么 d 会被计算两次:第一次时 c 还是旧值 2,于是 d 得到一个中间态 (a+1) + 旧 c = 3 + 2 = 5——这个 5 既不是旧值(2+2=4)也不是新值(3+4=7)。如果这个中间态被渲染到 DOM 或被某个 effect 观察到,就是 glitch。
三种工程解法:
- 拓扑排序(MobX 的做法):传播前对下游做一遍拓扑排序,保证每个节点在其所有上游都已更新后再求值。正确,但需要维护排序,且每次结构变化都要重排。
- Push-Pull 双阶段(Solid、Vue 3.5 alien-signals、Preact Signals):push 阶段只打脏标记、不计算,沿图向下传播
dirty;pull 阶段从 sink 反向按需求值,遇到标记为 dirty 的上游就重新计算,遇到 clean 就直接返回缓存值。中间态根本不会被计算出来,因为没人去 pull 它。 - 深度优先标记 + 版本号(Svelte 5):每个节点带 epoch 版本号,重算前递归确认上游版本是否匹配,不匹配就先更新上游。
方案 2 是当下的主流,因为它同时拿到两个好处:正确性(无 glitch)+ 惰性(没人用的 computed 不算)。它的核心是两句话:
- push 只是
markDirty,复杂度 O(下游边数),极廉价; - pull 时才真正计算,且自底向上按需求值。
四、可运行的最小内核
下面是一个真正跑得起来的实现骨架,包含版本戳、diamond 修复、O(1) 依赖清理、批量刷新:
type Link = { dep: Dep; sub: Sub; prev?: Link; next?: Link };
interface Dep { // 被依赖方:signal / computed
subs?: Link; // 订阅者链表头
version: number; // 版本号,用于快速判断是否需要重算
}
interface Sub { // 依赖方:computed / effect
deps?: Link; // 依赖链表头
version: number;
dirty: boolean;
}
let batchDepth = 0;
const dirtyQueue = new Set<Sub>();
function linkToHead(dep: Dep, sub: Sub): void {
const l: Link = { dep, sub, next: dep.subs };
if (dep.subs) dep.subs.prev = l;
dep.subs = l;
}
function startTrack(sub: Sub): void {
// 清理上一轮的依赖:O(依赖数) 的纯指针操作,无 GC 压力
let cur = sub.deps;
while (cur) {
const { prev, next, dep } = cur;
if (prev) prev.next = next; else dep.subs = next;
if (next) next.prev = prev;
cur = next;
}
sub.deps = undefined;
activeSub = sub;
}
computed 的求值(pull 阶段的核心):
function computedGet<T>(c: Computed<T>): T {
if (c.dirty || upstreamChanged(c)) {
const prev = activeSub;
startTrack(c); // 重新收集依赖
const v = c.fn(); // 求值,内部读 signal 自动 track
activeSub = prev; // 恢复上下文
c.value = v;
c.version++; // 版本推进,下游据此判断
c.dirty = false;
}
track(c); // 被外层 effect 订阅
return c.value;
}
function upstreamChanged(c: Computed): boolean {
for (let l = c.deps; l; l = l.next) {
// 对 computed 上游递归 pull;对 signal 上游比对版本
if (isComputed(l.dep)) computedGet(l.dep as Computed);
if (l.dep.version !== l.seenVersion) return true;
}
return false;
}
注意 upstreamChanged 里对 computed 上游递归调用 computedGet——这一步就是"自底向上按需求值",它保证回到 sink 时所有上游都已最新。glitch 在结构上被消除了,而不是靠排序规避。
写入与批量刷新:
function signalSet<T>(s: Signal<T>, v: T): void {
if (Object.is(s.value, v)) return; // 相等即短路,避免无效传播
s.value = v;
s.version++;
markDirty(s);
}
function markDirty(dep: Dep): void {
for (let l = dep.subs; l; l = l.next) {
const sub = l.sub;
if (sub.dirty) continue; // 已标记,剪枝
sub.dirty = true;
if (isEffect(sub)) dirtyQueue.add(sub);
else markDirty(sub as Dep); // computed 继续向下传播脏标记
}
if (batchDepth === 0) flush();
}
export function batch<T>(fn: () => T): T {
batchDepth++;
try { return fn(); }
finally { if (--batchDepth === 0) flush(); }
}
function flush(): void {
// 同一 tick 内合并执行,避免中间态被渲染
const queue = [...dirtyQueue];
dirtyQueue.clear();
for (const e of queue) {
startTrack(e); e.fn(); activeSub = undefined; e.dirty = false;
}
}
五、编译期依赖追踪:把运行时开销再压掉一层
运行时的 activeSub + 双向链表已经很轻,但仍有成本:每次读取都要 track,每次求值都要重建依赖链表。Svelte 5、Vue Vapor、Solid 的编译产物走得更远——编译器在编译期就知道依赖关系。
// 源码(Svelte 5 runes)
let a = $state(1);
let b = $derived(a + 1);
// 编译产物(简化)
let a = $.state(1);
let b = $.derived(() => $.get(a) + 1); // 静态确定的单一依赖
编译器可以做三件运行时做不到的事:
- 依赖内联:已知
b只依赖a时,不需要维护动态链表,直接生成b = f(a)的显式调用,省掉集合操作与去重判断。 - 静态提升:不依赖任何响应式状态的表达式被提到组件外,只求值一次。
- 粒度裁剪:
{{ a }} {{ b }}两个插值生成两个独立的 effect,各自绑定一个文本节点,互不干扰。
但编译期追踪有个硬边界:动态依赖。如果依赖出现在条件分支或循环里:
const total = $derived(flag ? expensive(x) : cheap(y));
编译器无法静态判定依赖集,只能保守地退化为完整运行时追踪,或者在 flag 翻转时重建订阅。这就是为什么真实框架都是混合方案:编译期处理 80% 的静态情形,运行时机制兜住剩下的 20%。一个实用的判断标准是——你的依赖集是否随控制流变化。如果是,响应式带来的收益会明显缩水。
六、生产环境的六个陷阱
- Effect 泄漏:响应式不会自动回收 effect。组件卸载时没有
dispose,effect 及其整条依赖链全部常驻内存。这是 Signals 时代最常见的内存泄漏,务必把 effect 生命周期绑定到组件作用域(Solid 的onCleanup、Vue 的effectScope)。 - 响应式循环:在 effect 里写 signal,而该 signal 又是这个 effect 的依赖。push-pull 架构下通常表现为无限 flush。防御手段是设置单次 flush 的迭代上限并抛出可诊断错误。
- 异步竞态:
async派生值天然存在"旧响应覆盖新值"问题。必须用请求序号或AbortController做失效判断,不要依赖求值顺序。 - SSR 的跨请求污染:如果 signal 定义在模块作用域(而非请求作用域),服务端并发渲染时不同用户会互相看到对方的数据。这是把状态从组件提升到模块时的经典事故。
- 大列表的 keyed 更新:细粒度响应式不解决列表重排。10000 项的 keyed diff 依然昂贵,正确做法是让每一行自己订阅自己的数据项(
For/mapArray),把 O(n) 的 diff 变成 O(变更项数)。 - 调试可观测性下降:调用栈里不再有"哪个组件重渲染了",只有"哪条依赖边被触发了"。需要专门的工具(依赖图可视化、signal 写入栈追踪)才能定位性能问题。这是采用响应式前必须准备的工程配套。
七、结论
细粒度响应式的本质,是把"何时重算"这个问题的答案,从运行时的大规模推断(diff)换成编译期与订阅关系共同确定的精确传播。它换来的是:更新复杂度从 O(子树) 降到 O(依赖边),以及开发者心智负担的显著下降(不再手写 memo)。
代价是:你必须理解一张看不见的图。glitch、泄漏、循环、异步竞态,全都是"图管理"问题的不同侧面。
判断要不要采用的三个问题:
- 你的更新是否频繁且局部(实时仪表盘、编辑器、协同应用)?是 → 收益巨大。
- 你的依赖集是否静态可分析?是 → 编译期优化能吃满红利。
- 你的团队是否有能力观测和调试依赖图?否 → 先把工具链建好,再迁移。
三个都是"是",响应式会给你数量级的提升;否则,一个调好的 VDOM 依然是很体面的答案。

发表评论 取消回复