Vue3 数据响应式引擎:effect / track / trigger 与 Proxy + Reflect
Vue3 数据响应式引擎
核心结论
响应式 = 存一张依赖表(谁依赖哪个对象的哪个属性)+ 在读写时自动维护这张表。
Vue3 用
Proxy + Reflect做"读写拦截",用Set → Map → WeakMap三级结构做依赖表,用activeEffect记录"当前正在跑哪个 effect"。ref()是给基本类型用的替身(靠对象访问器把值包成.value),computed()则是ref() + effect()的封装。
一、三板斧:effect / track / trigger
let activeEffect = null // 当前正在执行的更新函数
function effect(fn) {
activeEffect = fn
fn() // 立即跑一次,顺便登记依赖
activeEffect = null
}
// 依赖表:WeakMap<target, Map<key, Set<effect>>>
const targetMap = new WeakMap()
function track(target, key) {
if (!activeEffect) return // 不在 effect 里读的,不是依赖
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)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) [...dep].forEach(fn => fn())
}三级结构的选择是刻意的:
| 层 | 结构 | 作用 |
|---|---|---|
| 第一级 | WeakMap<对象, Map> | 对象被回收时依赖表自动释放,不阻止 GC |
| 第二级 | Map<属性名, Set> | 一个对象的不同属性各自一组依赖 |
| 第三级 | Set<effect> | 去重:同一个 effect 多次读同一属性只登记一次 |
二、让拦截自动发生:Proxy + Reflect
手写 track/trigger 只能靠人在读写处插桩。Proxy 让它自动:
function reactive(target) {
return new Proxy(target, {
get(t, key) {
track(t, key)
return Reflect.get(t, key)
},
set(t, key, value) {
const ok = Reflect.set(t, key, value)
trigger(t, key)
return ok
},
deleteProperty(t, key) {
const ok = Reflect.deleteProperty(t, key)
trigger(t, key)
return ok
}
})
}为什么要 Reflect 而不是直接 t[key]:
- 它是 Proxy handler 的默认行为,语义一致(返回布尔值、对
getter/setter正确转发 receiver); - 不写死具体操作,后续加
has/ownKeys等 trap 时可以平行扩展; - 直接
t[key]在Proxy里覆盖不完整时容易触发递归或丢失原型链语义。
关键区别在于拦截粒度:defineProperty 是在属性上装钩子,Proxy 是在对象外包一层。所以后加的属性、delete、in 判断、Object.keys 都会经过 handler —— Vue2 那三个限制在这里一次性消失(详见 Object.defineProperty)。
三、两个真实 bug 与 activeEffect
第一版代码有两个问题,都值得记住,因为它们是"看起来能跑"的典型:
- 不只读也重跑:早期把"更新函数"直接绑在
track(target, key)的调用上,结果只要函数在 effect 里被调用(哪怕根本没读这个属性)就被登记。修复:引入activeEffect,track只登记"当前正在执行的 effect"。 - effect 串味:同一段代码里两个 effect 各自写同一个变量时,后登记的会盖掉前一个,导致依赖丢失。修复:
effect()外层保存/恢复activeEffect,并在执行结束后置空。
这两处合起来就是"依赖收集"的全部难点:必须知道当前是谁在读,且读的必须真的是数据。
四、ref():给基本类型一个可观测的盒子
数字、字符串没有属性可代理。ref 的做法是造一个只带 value 访问器的对象:
function ref(raw) {
const r = {
get value() {
track(r, 'value')
return raw
},
set value(newVal) {
raw = newVal
trigger(r, 'value')
}
}
return r
}这也是为什么模板里 ref 要写 count.value,而模板/setup 之外会自动解包。对象也可以传进 ref,Vue 内部会转调 reactive(可用但不推荐,多一层 .value)。
五、computed():ref + effect 的封装
function computed(getter) {
const result = ref()
effect(() => { result.value = getter() })
return result
}计算属性不是新机制,而是既有机制的组合:getter 在 effect 里跑 → 自动收集它依赖的响应式数据 → 依赖变更时 effect 重跑 → result.value 被重新赋值并触发下游。理解了 ref 和 effect,computed 就没有黑箱。
六、Vue2 / Vue3 对比
| 维度 | Vue2 | Vue3 |
|---|---|---|
| 拦截手段 | Object.defineProperty 逐属性 | Proxy 代理整个对象 + Reflect 转发 |
| 拦截范围 | 已定义的属性 | 读写 / 新增 / 删除 / 枚举 |
| 对象嵌套属性 | 不响应 | 响应(get 时按需递归代理) |
| 新增属性 | 需 Vue.set | 直接赋值即响应 |
| 删除属性 | 需 Vue.delete | 直接 delete 即响应 |
| 数组索引赋值 | 不触发(靠重写 7 个方法补) | 触发 |
| 依赖表 | Dep / Watcher 对象 | WeakMap → Map → Set |
