响应式系统的四条路:从 Proxy 到 Signal 的追踪粒度全景
总览篇用一张四分法表格定位了各框架的响应式粒度,本篇拆开每种追踪策略的内部机制——Vue 的 Proxy + Link 链表、Solid 的 Signal + swap-and-pop、Svelte 5 的编译期静态绑定、React 的无追踪模型,以及它们各自的依赖清理策略和原语组合规则。
为什么响应式粒度是一切的起点
总览篇用一个核心论点串起了五大框架的全部技术差异:"谁来承担精确定位更新的责任"这个根源选择,决定了后续一切。 响应式系统是这条因果链的第一环——追踪粒度决定了编译器的优化空间、渲染管线的长度、调度器的复杂度、SSR 水合的成本。
总览篇用约 2200 字给出了四分法的全景定位。本篇拆开每种追踪策略的内部机制——不再讲"做了什么",而是讲"怎么做的":依赖收集的数据结构、通知传播的算法路径、依赖清理的具体实现、响应式原语的组合规则。
四分法定位
| 路线 | 代表框架 | 追踪粒度 | 依赖图构建时机 | 核心数据结构 |
|---|---|---|---|---|
| 无自动追踪 | React | 组件级 | 无(靠 VDOM diff 兜底) | Fiber 节点的 hooks 链表 |
| 运行时属性级追踪 | Vue 3 | 属性级 | 运行时(Proxy get/set) | Dep → Link → Sub 双向链表 |
| 运行时表达式级追踪 | Solid | 表达式级 | 运行时(signal getter) | Signal.observers + Computation.sources |
| 编译期静态绑定 + 运行时兜底 | Svelte 5 | 表达式级 | 简单绑定编译期 / 动态依赖运行时 | 编译生成的 template_effect |
注意这是四分法而非三分法——Solid 和 Svelte 5 虽然都达到了表达式级追踪精度,但依赖图的构建时机不同:Solid 完全在运行时动态收集(支持条件依赖),Svelte 5 对静态可分析的简单绑定在编译期就确定了依赖关系(零追踪开销),但遇到动态依赖(如条件分支里的响应式值)仍需 fallback 到运行时。
React:没有响应式系统的"响应式框架"
React 是五大框架里唯一一个没有自动依赖追踪机制的。useState 返回一个值和一个 setter,调用 setter 时 React 只知道"这个组件的状态脏了"——不知道组件内部哪个值变了,不知道这个值影响了 JSX 的哪个部分。
组件重执行:React 的"响应式"
React 的应对策略是整个组件函数重新执行。每次 setState 后,组件函数从头到尾重跑一遍,生成新的虚拟 DOM 树,和上一次做 diff,找出差异后批量更新真实 DOM。diff 算法在做的事情本质上就是"在运行时重新发现依赖关系"——因为编译期和声明期都没有收集过依赖信息。
手动优化 API:开发者补信息
因为框架不知道"什么变了影响了什么",开发者必须自己告诉框架"什么不用重算":React.memo、useMemo、useCallback。这些 API 的存在本身就是 React 没有自动追踪的证据。 React Compiler 正在尝试自动化这些手动声明——但仍然没有改变"组件重执行 + VDOM diff"的基本模型。
闭包陷阱:重执行模型的必然后果
组件函数每次重执行都生成新的闭包。事件处理函数捕获的是当次 render 的 state 快照,不是最新值。
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
console.log(count); // 点击时的值,不是 3 秒后的最新值
}, 3000);
};
return <button onClick={handleClick}>{count}</button>;
}
useRef 是这个问题的逃生通道。Vue 和 Solid 不存在闭包陷阱——Vue 的 ref 是对象引用(.value 总是最新),Solid 的 signal getter 每次调用返回最新值。
Vue 3:Proxy 属性级追踪的完整链路
Vue 3 的响应式系统是五大框架里最"经典"的自动依赖追踪实现——基于 ES6 Proxy 做属性级的 get/set 拦截,在运行时自动建立"谁依赖了谁"的关系图。
核心数据结构:Dep、Link、Sub
Vue 3.5 的响应式系统围绕三个核心概念构建:
- Dep(依赖源):每个响应式属性对应一个 Dep 实例,维护一条双向链表记录所有订阅者
- Sub(订阅者):每个
ReactiveEffect是一个 Sub,维护一条双向链表记录所有依赖 - Link(链接节点):连接一个 Dep 和一个 Sub 的双向链表节点,同时存在于两条链表中
这种双向链表结构让正向通知和反向清理都是 O(1) 的指针操作。
Vue 3.4 → 3.5 的演进:3.4 用 _trackId 整数版本号做依赖清理——effect 每次执行递增版本号,执行后遍历依赖列表,版本号不匹配的 dep 即过期依赖。3.5 用 Link 双向链表替代了数组 + Set 结构,清理机制从"版本号比较"升级为"链表位置隐式标记"(见下文依赖清理节),内存更紧凑且链表遍历对 CPU 缓存更友好。编译产物不受影响——createElementVNode 的 Patch Flags 参数在 3.4 和 3.5 之间完全相同,Link 链表重构是纯运行时内部优化。
依赖收集:Proxy get 触发 track
当渲染函数访问 state.name 时,Proxy 的 get handler 触发 track(target, 'name'):找到或创建对应的 Dep,检查当前 effect 是否已在订阅者链表里——不在就创建 Link 插入,已在就移动到链表尾部(标记存活)。
属性级追踪的精度就在这里建立: state.name 和 state.age 对应不同的 Dep,订阅者链表完全独立。
通知传播:Proxy set 触发 trigger
state.name = 'new' 触发 trigger:沿 Dep 的 Link 链表遍历找到所有 Sub,调用其调度回调推入 queueJob 微任务队列,flush 时按组件树深度排序执行。
通知精确到属性级,但更新单位仍然是组件。 被通知的组件完整重执行渲染函数、生成新 VNode、走带 Patch Flags 的快速 diff。
依赖清理:Link 链表的移动与截断
响应式系统最容易出 bug 的环节不是"通知谁",而是"什么时候停止通知"。考虑这段代码:
const show = ref(true);
const name = ref('Alice');
watchEffect(() => {
if (show.value) {
console.log(name.value); // show 为 true 时依赖 name
}
});
show 变成 false 后,name 变化不应该再触发这个 effect。Vue 3.5 的清理机制用链表位置作为隐式的存活标记:
- effect 执行前,记录当前依赖链表的尾指针位置
- 执行过程中,每个被访问的 Dep 的 Link 被移动到链表尾部
- 执行完毕后,尾指针之后的所有 Link 就是"本次没被访问的过期依赖"——截断并回收
依赖稳定时(绝大多数 computed 和渲染函数),所有 Link 都移到尾部,截断操作为空——零清理开销。
computed 的懒求值
computed 是 Vue 响应式系统里最精妙的优化——懒求值(标记 dirty 等被读取时才重算)、缓存(依赖没变直接返回)、传播效率(一个 tick 改依赖 5 次只算一次)。"同步标脏 + 延迟求值"的设计让 computed 在依赖链路上几乎没有不必要的计算。
Solid:运行时 Signal 的表达式级追踪
Solid 的响应式系统把追踪粒度推到了极致——"哪个 DOM 节点的 textContent 依赖了哪个 signal"。中间没有组件重执行,没有虚拟 DOM,没有 diff。
核心数据结构:Signal + Computation + Owner
- Signal:值容器,维护
observers数组(所有订阅了这个 signal 的 computation) - Computation(effect / memo):维护
sources数组(它订阅的所有 signal)和sourceSlots数组(它在每个 signal 的 observers 数组中的索引位置) - Owner 树:computation 之间形成嵌套的 owner 关系——组件销毁时 owner 的所有子 computation 自动 dispose
依赖收集:effect 执行时动态建立
createEffect 执行时设置全局 Listener,回调内的 signal getter 检查 Listener 并互相注册——computation 加入 signal 的 observers,signal 加入 computation 的 sources。依赖关系完全在运行时动态建立, 条件分支里的 signal 只有分支被执行时才收集。
通知传播:signal set 触发同步 flush
遍历 observers,按拓扑排序同步执行每个 computation 的回调。从 signal 变化到 DOM 更新是同步直达的。 batch() 延迟通知但 flush 仍是同步的。
依赖清理:swap-and-pop
Solid 的依赖清理和 Vue 完全不同。computation 重执行前,cleanNode 函数遍历 sources 数组,用 sourceSlots 预存的索引直接定位到 signal 的 observers 数组中自己的位置,把最后一个元素搬到被删除的位置(swap-and-pop),然后 pop() 缩短数组。
swap-and-pop 的核心优势是不需要遍历查找——每次移除 O(1)。 和 Vue 3.5 的 Link 链表移动在复杂度上同级,但清理时序不同:Solid 是"先全量清再重新收集"(两步分离),Vue 3.5 是"边执行边移动最后截断"(清理收集交织)。Vue 3.5 在依赖稳定场景下更优(截断为空零开销),Solid 即使依赖没变也要先全量移除再全量重建。
组件函数只执行一次
Solid 的组件函数是构造函数——执行一次建立所有 signal、effect、DOM 节点的关系后就"消失"了。后续所有更新由 signal 的 effect 订阅驱动,不经过组件函数。不存在闭包陷阱,没有 onUpdate,解构 props 丢失响应性。
Svelte 5:编译期静态绑定 + 运行时兜底
Svelte 5 的 Runes 是四条路线里最"混合"的——对静态可分析的简单绑定在编译期确定依赖(零追踪开销),遇到动态依赖(条件分支里的响应式值)fallback 运行时(行为趋同 Solid)。
简单绑定:编译期静态确定
{count} 在模板里出现,编译器在 Analyze 阶段就能确定"这个绑定只依赖 count 这一个 $state",直接生成 $.template_effect(() => set_text(text, $.get(count)))。没有运行时依赖收集逻辑——编译器已经知道依赖关系,不需要运行时"发现"。
和 Solid 的关键区别:Solid 的 insert(_el$, count) 内部调用 createRenderEffect 做运行时依赖收集——每次 effect 执行都要重新收集依赖。Svelte 5 对简单绑定跳过了这层运行时抽象,追踪开销为零。
动态依赖:fallback 运行时追踪
当绑定表达式包含条件分支时,编译器无法在编译期确定依赖集:
<span>{show ? name : 'default'}</span>
show 为 true 时依赖 show 和 name,为 false 时只依赖 show。编译器不知道运行时 show 的值,所以 $derived 必须走运行时动态追踪——和 Solid 的 createMemo 行为一样。
这就是四分法的核心理由:Svelte 5 不是"纯编译期"框架,它是"编译期为主、运行时兜底"的混合模型。 能力边界在"条件依赖"这道线上——静态可分析的绑定走编译期(零追踪开销),条件依赖走运行时(有追踪开销但保证正确性)。
Runes vs 旧版 $: 语法
$: 的问题是行为取决于位置(顶层 vs {#each} 内 vs {#if} 内)。Runes 是显式函数调用,语义明确不依赖位置。从编译器魔法到显式原语,是 Svelte 承认了纯编译期方案的可维护性天花板。 Runes 在语义上更接近 Solid 的 createSignal——Svelte 正在从"极致编译"向"显式响应式原语 + 编译辅助"靠拢。
依赖清理:响应式系统最容易出 bug 的环节
响应式系统最常出 bug 的地方不是"通知谁",而是"什么时候停止通知"——取消订阅。各框架的清理策略差异巨大。
三种清理策略对比
| 框架 | 清理时机 | 清理算法 | 依赖稳定时的开销 |
|---|---|---|---|
| Vue 3.5 | 执行过程中 + 执行后 | Link 移动 + 截断 | 零(所有 Link 移到尾部,截断为空) |
| Solid | 执行前 | swap-and-pop | 有(先全量移除再全量重建,操作次数 = 依赖数量) |
| React | N/A(无订阅图) | N/A | N/A |
| Svelte 5 | 简单绑定不需要 / $derived 同运行时 | 混合 | 简单绑定零 / 动态依赖同 Solid |
Vue 3.5 vs Solid 的清理时序差异
Vue 3.5 是"边执行边标记,最后截断"——effect 执行过程中,每个被访问的 Dep 的 Link 被移到链表尾部;执行完毕后,尾部之后的 Link 就是过期依赖,截断回收。清理和收集是交织的。
Solid 是"先全量清,再重新收集"——cleanNode 在 effect 重执行前先从所有 signal 的 observers 数组中移除自己(swap-and-pop),然后重新执行 effect 时重新收集依赖。清理和收集是分离的两个步骤。
React 的 useEffect cleanup:不同维度的"清理"
React 没有响应式订阅图,不存在"取消订阅"的问题。但 useEffect 的 cleanup 函数解决副作用清理——取消定时器、关闭 WebSocket、移除事件监听。漏写不会内存泄漏(没有订阅图要泄漏),但会导致逻辑 bug(闭包捕获的旧值在过期回调里继续执行)。React Compiler 能自动推导依赖数组但管不了 cleanup 逻辑。
Solid 的 Owner 树自动清理与内存泄漏风险
组件销毁时 owner 递归清理子 computation 的订阅。但组件外部手动 createEffect(如模块顶层)没有 owner 接管,effect 永远不会被清理——这是 Solid 文档里强调的内存泄漏风险。需要 createRoot 手动管理。
响应式原语的组合性:composables vs hooks vs Runes
响应式原语之间怎么组合,直接决定了开发者封装复用逻辑的自由度。这个维度离开发者日常体验最近。
Vue:最宽松的组合约束
ref嵌套在reactive里自动解包——reactive({ count: ref(0) }).count直接返回数字computed返回的也是ref,可无缝传递给任何接受ref的地方watch/watchEffect可监听任意粒度——一个 ref、一个 reactive 属性、一个 getter 函数
代价: 自动解包规则有边界 case 很难记——reactive 嵌套 ref 的数组不解包,shallowReactive 里的 ref 也不解包。
Solid:最严格但最一致
Signal 是 getter/setter 函数对,createMemo 返回的也是 getter——调用方不需要知道一个值是原始 signal 还是 derived memo。所有响应式值的读取方式统一为"调用函数"。
代价: 解构 props 丢失响应性(const { name } = props 只取一次值),JSX 外使用 signal 值必须在 tracking scope 内。
React Hooks:最受限
- 调用顺序不可变——hooks 不能放在条件分支、循环、嵌套函数里
- 依赖数组手动声明——
useMemo/useCallback/useEffect的依赖数组写错不报错,只是行为不符预期 - 自定义 hooks 无缓存语义——每次重执行都完整调用
React Compiler 缓解依赖数组负担但无法解决调用顺序约束(模型层面的限制)。
Svelte 5 Runes:语法最直觉但跨文件受限
$state/$derived/$effect 在编译器层面做"魔法"——变量名直接用,编译器自动插入 $.get()/$.set()。
代价: Runes 只能在 .svelte 和 .svelte.ts 文件里使用。普通 .ts 文件不支持——限制了跨文件的响应式状态共享。
组合性对比总结
| 框架 | 值的读取方式 | 组合约束 | 核心代价 |
|---|---|---|---|
| Vue | .value / 自动解包 | 最宽松 | 解包规则有边界 case |
| Solid | 调用 getter 函数 | 最严格最一致 | 解构丢失响应性 |
| React | 直接读变量(每次重执行) | hooks 调用顺序 + 手动依赖数组 | 闭包陷阱 + 依赖数组写错 |
| Svelte 5 | 直接用变量名 | 文件类型约束 | 跨文件响应式共享受限 |
响应式粒度如何决定后续一切
因果传导:从追踪粒度到所有后续维度
| 后续维度 | 无追踪(React) | 属性级(Vue) | 表达式级运行时(Solid) | 编译期静态(Svelte 5) |
|---|---|---|---|---|
| 编译器能做什么 | 只能加速 diff | 能标记 diff 跳过范围 | 能消灭 diff | 简单场景消灭 diff + 零追踪开销 |
| 渲染管线长度 | 最长 | 中 | 最短 | 最短 |
| 调度器复杂度 | 最高 | 中 | 最低 | 低 |
| 性能优化 API 数量 | 6+ | 3-4 | 1-2 | 几乎没有 |
| SSR 水合成本 | 最高 | 中高 | 低 | 中低 |
| 闭包陷阱 | 有 | 无 | 无 | 无 |
| 依赖清理策略 | 不需要 | Link 移动+截断(零开销) | swap-and-pop | 混合 |
每一行都是上一行的必然推论。不存在混搭的可能——所有维度被同一个根源选择锁定。
收敛趋势的响应式维度
- React Compiler 推导的是缓存跳过(浅比较),不是依赖追踪——React 的收敛位置是"编译辅助的组件级重执行"
- Angular Signals 从"不追踪"向"细粒度追踪"迁移
- Svelte 5 从编译器魔法转向显式 Runes
响应式粒度的四条路线正在收敛成两条:带自动追踪的(Vue/Solid/Svelte/Angular)和不带的(React + 编译辅助)。 追踪粒度的差异和编译深度的差异仍然是框架间最核心的区分度。