Appearance
第 3 章 响应式系统设计哲学
本章要点
- 响应式编程的本质:从命令式手动同步到声明式自动传播
- Vue 响应式的三代实现:defineProperty → Proxy → Alien Signals
- 细粒度 vs 粗粒度响应式:Vue 与 React 的根本分歧
- 与 MobX、Solid Signals、Svelte Runes 的横向对比
- "精确传播变化"——响应式系统的终极追求
假设你在管理一家咖啡店的库存。每天早上,你需要做三件事:
- 检查咖啡豆存量
- 根据存量计算今天能做多少杯咖啡
- 如果不够,给供应商打电话补货
用命令式编程的方式,代码大概是这样的:
typescript
let beans = 500 // 克
let cupsAvailable = Math.floor(beans / 15)
let needRestock = cupsAvailable < 20
// 第二天早上,有人用掉了一些豆子
beans = 200
// 糟了!cupsAvailable 和 needRestock 没有自动更新
console.log(cupsAvailable) // 仍然是 33,实际应该是 13
console.log(needRestock) // 仍然是 false,实际应该是 true
// 你必须手动重新计算
cupsAvailable = Math.floor(beans / 15)
needRestock = cupsAvailable < 20看到问题了吗?当 beans 变化时,cupsAvailable 和 needRestock 不会自动更新。你必须手动重新计算。在这个简单例子中,手动同步还能应付。但在一个有数百个相互依赖的状态的前端应用中,手动同步就是噩梦的起点。
这就是响应式系统要解决的核心问题:让数据之间的依赖关系自动维护。
typescript
import { ref, computed } from 'vue'
const beans = ref(500)
const cupsAvailable = computed(() => Math.floor(beans.value / 15))
const needRestock = computed(() => cupsAvailable.value < 20)
console.log(cupsAvailable.value) // 33
console.log(needRestock.value) // false
beans.value = 200 // 修改源数据
console.log(cupsAvailable.value) // 13 — 自动更新了!
console.log(needRestock.value) // true — 自动更新了!没有手动重新计算,没有 setState,没有 dispatch。数据变了,所有依赖它的计算自动保持一致。
这不是魔法。这是一套精心设计的因果传播系统。
3.1 响应式编程的本质:数据驱动的依赖图
什么是依赖图
响应式系统的核心数据结构是一个有向无环图(DAG, Directed Acyclic Graph)。图中的节点分为三种:
- 信号(Signal):源数据,如
ref(0),是依赖图的叶节点 - 计算(Computed):从信号或其他计算派生的数据,如
computed(() => count.value * 2) - 副作用(Effect):当依赖变化时需要执行的操作,如 DOM 更新、日志打印
当 price 变化时,系统需要:
- 重算
total(因为它依赖price) - 重算
tax(因为它依赖total) - 重新执行两个 effect(DOM 更新)
关键约束是:不能多做(重算不需要重算的),也不能少做(遗漏需要重算的)。 这就是"精确传播"的含义。
推模型 vs 拉模型
依赖图中变化的传播方式有两种基本策略:
推模型(Push):当信号变化时,立即沿依赖图向下推送通知。
price 变化 → 推送给 total → total 重算 → 推送给 tax → tax 重算 → 推送给 effects拉模型(Pull):当信号变化时,只标记为"脏"。下游节点在被读取时才检查上游是否脏,按需重算。
price 变化 → 标记 price 脏
...(什么都不发生,直到有人读取 total 或 tax)
读取 tax → 检查 total 是否脏 → 检查 price 是否脏 → 是 → 重算 total → 重算 tax → 返回新值| 维度 | 推模型 | 拉模型 |
|---|---|---|
| 触发时机 | 数据变化时 | 数据被读取时 |
| 无用计算 | 可能(推送给无人读取的节点) | 无(只计算被读取的节点) |
| 延迟 | 低(立即推送) | 可能更高(读取时才计算) |
| 适用场景 | 实时性要求高 | 计算密集但读取稀少 |
| 典型实现 | Vue 3.0–3.4、RxJS | Alien Signals、Solid.js |
🔥 深度洞察
Vue 3.6 的 Alien Signals 并非纯粹的拉模型——它是混合模型。信号变化时,版本号递增(这是推的动作,但开销极低——只是一个整数加一)。下游节点在被读取时,通过版本号比较判断是否需要重算(这是拉的动作)。但对于 effect(副作用),系统仍然会主动调度它们的重新执行(因为没人会"读取"一个副作用)。这种混合策略取了两家之长:对 computed 用拉模型(避免无用计算),对 effect 用推模型(确保副作用及时执行)。
3.2 Vue 响应式的三代实现
第一代:Object.defineProperty(Vue 2)
Vue 2 使用 Object.defineProperty 拦截对象属性的 getter 和 setter:
typescript
// Vue 2 响应式核心(简化)
class Dep {
private subs: Watcher[] = []
depend() {
if (Dep.target) {
this.subs.push(Dep.target)
}
}
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
function defineReactive(obj: any, key: string, val: any) {
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
dep.depend() // 当前 Watcher 订阅这个属性
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
dep.notify() // 通知所有 Watcher
}
})
}这套方案有三个根本性局限:
无法检测属性的添加和删除:
Object.defineProperty只能拦截已存在的属性。vm.newProp = 'hello'不会触发更新,必须使用Vue.set()。无法拦截数组索引赋值:
arr[0] = 'new'不会触发更新。Vue 2 通过重写数组的 7 个变异方法(push、pop、splice等)来部分解决,但这是一个补丁,不是一个解决方案。初始化成本高:
defineReactive必须在创建对象时递归遍历所有属性,一次性设置所有 getter/setter。对于大型对象,这个初始化开销不可忽视。
typescript
// Vue 2 的痛点演示
const vm = new Vue({
data: {
user: { name: 'Alice' }
}
})
// ❌ 不触发更新 — defineProperty 无法拦截新属性
vm.user.age = 25
// ✅ 必须使用 Vue.set
Vue.set(vm.user, 'age', 25)
// ❌ 不触发更新 — defineProperty 无法拦截数组索引
vm.items[0] = 'new item'
// ✅ 必须使用 splice
vm.items.splice(0, 1, 'new item')第二代:Proxy + Set-based tracking(Vue 3.0–3.4)
Vue 3 使用 ES6 Proxy 替代 Object.defineProperty,一举解决了前一代的所有局限:
typescript
// Vue 3.0 响应式核心(简化)
const targetMap = new WeakMap<object, Map<string | symbol, Set<ReactiveEffect>>>()
function reactive<T extends object>(target: T): T {
return new Proxy(target, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key) // 依赖收集
return result
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
trigger(target, key) // 触发更新
return result
},
deleteProperty(target, key) {
const result = Reflect.deleteProperty(target, key)
trigger(target, key) // 删除也能触发更新!
return result
},
has(target, key) {
track(target, key) // in 操作符也能追踪!
return Reflect.has(target, key)
}
})
}Proxy 的优势是全面拦截——不仅 get/set,还有 delete、has、ownKeys 等操作,且不需要事先知道对象有哪些属性。
但 Vue 3.0 的依赖追踪仍然基于 Set:
typescript
// Vue 3.0 依赖追踪的数据结构
//
// WeakMap<target, Map<key, Set<ReactiveEffect>>>
//
// 例如:
// targetMap = {
// { name: 'Vue' } => {
// 'name' => Set { effect1, effect2 }
// }
// }
function track(target: object, key: string | symbol) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, (dep = new Set()))
dep.add(activeEffect) // ← Set.add()
activeEffect.deps.push(dep) // ← 反向引用,用于 cleanup
}
function trigger(target: object, key: string | symbol) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) {
const effects = new Set(dep) // ← 创建副本以避免无限循环
effects.forEach(effect => {
if (effect !== activeEffect) {
effect.scheduler ? effect.scheduler() : effect.run()
}
})
}
}