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。

三种工程解法:

  1. 拓扑排序(MobX 的做法):传播前对下游做一遍拓扑排序,保证每个节点在其所有上游都已更新后再求值。正确,但需要维护排序,且每次结构变化都要重排。
  2. Push-Pull 双阶段(Solid、Vue 3.5 alien-signals、Preact Signals):push 阶段只打脏标记、不计算,沿图向下传播 dirty;pull 阶段从 sink 反向按需求值,遇到标记为 dirty 的上游就重新计算,遇到 clean 就直接返回缓存值。中间态根本不会被计算出来,因为没人去 pull 它。
  3. 深度优先标记 + 版本号(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);   // 静态确定的单一依赖

编译器可以做三件运行时做不到的事:

  1. 依赖内联:已知 b 只依赖 a 时,不需要维护动态链表,直接生成 b = f(a) 的显式调用,省掉集合操作与去重判断。
  2. 静态提升:不依赖任何响应式状态的表达式被提到组件外,只求值一次。
  3. 粒度裁剪:{{ a }} {{ b }} 两个插值生成两个独立的 effect,各自绑定一个文本节点,互不干扰。

但编译期追踪有个硬边界:动态依赖。如果依赖出现在条件分支或循环里:

const total = $derived(flag ? expensive(x) : cheap(y));

编译器无法静态判定依赖集,只能保守地退化为完整运行时追踪,或者在 flag 翻转时重建订阅。这就是为什么真实框架都是混合方案:编译期处理 80% 的静态情形,运行时机制兜住剩下的 20%。一个实用的判断标准是——你的依赖集是否随控制流变化。如果是,响应式带来的收益会明显缩水。

六、生产环境的六个陷阱

  1. Effect 泄漏:响应式不会自动回收 effect。组件卸载时没有 dispose,effect 及其整条依赖链全部常驻内存。这是 Signals 时代最常见的内存泄漏,务必把 effect 生命周期绑定到组件作用域(Solid 的 onCleanup、Vue 的 effectScope)。
  2. 响应式循环:在 effect 里写 signal,而该 signal 又是这个 effect 的依赖。push-pull 架构下通常表现为无限 flush。防御手段是设置单次 flush 的迭代上限并抛出可诊断错误。
  3. 异步竞态:async 派生值天然存在"旧响应覆盖新值"问题。必须用请求序号或 AbortController 做失效判断,不要依赖求值顺序。
  4. SSR 的跨请求污染:如果 signal 定义在模块作用域(而非请求作用域),服务端并发渲染时不同用户会互相看到对方的数据。这是把状态从组件提升到模块时的经典事故。
  5. 大列表的 keyed 更新:细粒度响应式不解决列表重排。10000 项的 keyed diff 依然昂贵,正确做法是让每一行自己订阅自己的数据项(For / mapArray),把 O(n) 的 diff 变成 O(变更项数)。
  6. 调试可观测性下降:调用栈里不再有"哪个组件重渲染了",只有"哪条依赖边被触发了"。需要专门的工具(依赖图可视化、signal 写入栈追踪)才能定位性能问题。这是采用响应式前必须准备的工程配套。

七、结论

细粒度响应式的本质,是把"何时重算"这个问题的答案,从运行时的大规模推断(diff)换成编译期与订阅关系共同确定的精确传播。它换来的是:更新复杂度从 O(子树) 降到 O(依赖边),以及开发者心智负担的显著下降(不再手写 memo)。

代价是:你必须理解一张看不见的图。glitch、泄漏、循环、异步竞态,全都是"图管理"问题的不同侧面。

判断要不要采用的三个问题:

  1. 你的更新是否频繁且局部(实时仪表盘、编辑器、协同应用)?是 → 收益巨大。
  2. 你的依赖集是否静态可分析?是 → 编译期优化能吃满红利。
  3. 你的团队是否有能力观测和调试依赖图?否 → 先把工具链建好,再迁移。

三个都是"是",响应式会给你数量级的提升;否则,一个调好的 VDOM 依然是很体面的答案。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部