Fine-grained Reactivity(细粒度响应式)

追踪粒度到单个值或表达式的响应式系统,状态变更直接映射到具体的 DOM 更新,无需 Virtual DOM diff。Solid 和 Svelte 5 是典型代表,但两者的依赖图构建时机不同——前者运行时动态收集,后者编译期静态绑定。

核心概念

Fine-grained Reactivity(细粒度响应式)指响应式系统的追踪粒度达到单个值或表达式级别——当某个状态变更时,系统精确知道哪个具体的 DOM 节点需要更新,直接执行目标更新,跳过传统的 Virtual DOM diff 阶段。

与之对比的是:

  • 无自动追踪(React)setState 只知道"某个组件的状态脏了",必须重新执行整个组件函数 + VDOM diff 来找出变化
  • 属性级追踪(Vue 3):Proxy 精确记录了"哪个组件依赖了哪个属性",但更新单位仍是组件——重新执行渲染函数、生成 VNode、走 diff

两种实现路径

路径 A:运行时 Signal 订阅(Solid)

依赖关系在 createEffect 执行时动态构建。effect 函数体内的 Signal getter(如 count())调用触发依赖收集,Signal 的订阅者列表记住了这个 effect。

js
// Solid 编译产物核心逻辑
const [count, setCount] = createSignal(0);
// insert 内部建立 signal → DOM 节点的直接订阅
insert(_el$, count);

关键特征:支持条件依赖——if (show()) { name() } 中,show 为 false 时 name 不被收集。依赖集合随每次 effect 重执行动态变化。灵活但有运行时依赖收集的开销。

依赖清理机制:每个 computation 维护 sources 数组和 sourceSlots 索引数组。重执行前,cleanNode 通过 sourceSlots 记录的位置索引精确地从每个 Signal 的 observer 列表中移除自己——使用经典的 swap-and-pop 技巧(用最后一个元素覆盖被删除位置),实现 O(1) 的单依赖移除。

路径 B:编译期静态绑定 + 运行时兜底(Svelte 5)

编译器在编译阶段分析 $state 与模板绑定的静态关系,直接生成 signal → DOM 更新的绑定代码,无需运行时依赖收集:

js
// Svelte 5 编译产物
$.template_effect(() => set_text(text, $.get(count)));

简单绑定(如 {count})在编译期就确定了完整的更新路径,响应式开销比运行时方案更低。但遇到动态依赖(如 {show ? name : 'default'}),$derived 仍需 fallback 到运行时追踪,此时行为与 Solid 趋同。编译期静态分析的能力边界在"条件依赖"这道线上。

对比参照:Vue 3.5 的依赖清理

Vue 3.5 使用双向链表(Link 节点)管理依赖关系。effect 执行时,已存在的依赖 Link 被移到链表尾部(O(1) 指针操作),执行结束后截断未移动的节点(过期依赖)。对依赖稳定的 computed,所有 Link 都被移到尾部,清理操作为零。与 Solid 的 swap-and-pop 在复杂度上同级,两者已趋于收敛。

响应式原语的组合性

细粒度响应式框架的原语组合规则直接决定了开发者封装复用逻辑的自由度:

  • Solid:Signal 统一为 getter/setter 函数对,createMemo 返回的也是 getter——调用方不需要区分原始 Signal 还是派生值。组合就是函数组合,规则极其一致。但解构 props 会丢失响应性(const { name } = props 只取一次值)
  • Svelte 5:Runes($state / $derived)在语法上消除了调用 getter 的仪式感——直接用变量名,编译器自动插入 $.get()。但 .svelte 文件外需使用 .svelte.ts 扩展名才能使用 Runes,跨文件组合有文件类型约束

与其他响应式粒度的因果关系

响应式粒度的选择是框架的"基因"决策,决定了整条技术栈的特征:

text
追踪粒度
├─ 无追踪(React)→ 需要 VDOM diff 兜底 → 需要复杂调度器(Fiber/Lanes)→ 需要手动优化 API(useMemo 等)
├─ 属性级(Vue 3)→ 精确触发组件,但仍走 diff → 中等调度(微任务队列)→ 少量优化 API
└─ 表达式级(Solid/Svelte 5)→ 无 diff,直接 DOM 操作 → 最简调度(同步 batch)→ 几乎无需手动优化

隐性成本

细粒度 DOM 操作可能将成本从框架侧转嫁到浏览器布局引擎——多个 Signal 同时变化时,如果触发交替的 DOM 读写,可能导致 layout thrashing。因此 Solid 仍需 batch() 来合并原子更新,本质上是为浏览器的布局引擎减压。

引用本术语的文章