Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity
一个让人头秃的 bug
上周组里一个同事来问我:"为什么我给 reactive 对象加了个新属性,页面不更新?"
我看了一眼代码------Vue2 的写法,用的 Vue3 的 API。
ts
const state = reactive({ name: '张三' })
// 他的操作:
state.age = 25 // 页面更新了 ✅(Vue3 没问题)
// 但他之前在 Vue2 项目里被坑过,条件反射写了:
Vue.set(state, 'age', 25) // Vue3 里根本没有 Vue.set 了
这件事让我意识到:很多人用了两三年 Vue3,但对响应式到底怎么工作的,还是停留在"Proxy 比 defineProperty 好"这句话上。
好在哪?为什么好?依赖怎么收集的?什么时候触发更新?
今天咱们把这事彻底说清楚,顺便手写一个能跑的迷你 reactivity。
Vue2 的 defineProperty 到底差在哪
先别急着夸 Proxy,得知道 Vue2 为啥被淘汰。
js
// Vue2 的响应式核心:逐个属性拦截
Object.defineProperty(obj, 'name', {
get() {
// 收集依赖
return value
},
set(newVal) {
value = newVal
// 通知更新
}
})
// ❌ 问题1:新增属性拦截不到,必须用 Vue.set
obj.age = 25 // set 根本不会触发,页面不更新
// ❌ 问题2:数组下标修改拦截不到
arr[0] = 'new' // 没反应
// ❌ 问题3:初始化时要递归遍历整个对象,性能差
// 1000 个属性 → 1000 次 defineProperty
本质问题就一句话:defineProperty 是"属性级别"的拦截,你得提前知道有哪些属性。
这就像安检------defineProperty 是给每个人单独装一个安检门,来一个新人得现装;Proxy 是在大楼入口装一个,谁进来都得过。
Proxy:对象级别的拦截
js
const raw = { name: '张三', age: 25 }
const proxy = new Proxy(raw, {
get(target, key) {
console.log(`读取了 ${key}`) // 任何属性的读取都能拦截
return target[key]
},
set(target, key, value) {
console.log(`设置了 ${key} = ${value}`)
target[key] = value
return true // set 必须返回 true,不然严格模式报错
}
})
proxy.name // → "读取了 name"
proxy.hobby = '摸鱼' // → "设置了 hobby = 摸鱼" ✅ 新增属性也能拦截!
delete proxy.age // 配合 deleteProperty trap,删除也能拦截
不用提前遍历,不用 Vue.set,天然支持新增/删除属性。这不是"好一点",这是降维打击。
光有 Proxy 还不够
拦截到读写只是第一步。关键问题是:谁在读?读了之后要通知谁?
这就是依赖收集。
想象一个场景:你在公司群里发了条消息"今晚团建",但不是所有人都需要知道------只有你组里的人需要收到通知。响应式系统干的就是这事:精准投递,别群发。
核心流程就三步:
- 读取(get) → 谁在读我?记住他(track)
- 修改(set) → 值变了,通知所有记住的人(trigger)
- effect → "那个在读的人"到底是谁?就是当前正在执行的副作用函数
手撸一个迷你 reactivity
别怕,核心代码不到 80 行。
第一步:全局变量------当前正在执行的 effect
ts
// 全局指针:当前谁在执行?
// 这是整个系统的"指挥棒"
let activeEffect: Function | null = null
function effect(fn: Function) {
activeEffect = fn // 先把"当前执行者"挂上
fn() // 执行函数 → 函数内部会读取响应式数据 → 触发 get
activeEffect = null // 执行完了,摘掉
}
这里有个精妙的设计:执行 fn() 的时候,fn 内部读取了什么属性,Proxy 的 get 就知道"当前是谁在读"。
时序上是这样的:
javascript
effect(() => console.log(state.name))
│
├─ activeEffect = fn ← 挂上
├─ fn() ← 开始执行
│ └─ 读取 state.name ← 触发 Proxy get
│ └─ get 里发现 activeEffect 不为 null
│ └─ 记住:name 这个属性被 fn 依赖了!(track)
└─ activeEffect = null ← 摘掉
第二步:依赖存储结构
ts
// 依赖关系的存储:target → key → Set<effect>
// 用 WeakMap 是为了不阻止对象被垃圾回收
const targetMap = new WeakMap<object, Map<string | symbol, Set<Function>>>()
function track(target: object, key: string | symbol) {
if (!activeEffect) return // 没人在执行 effect,不用收集
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let deps = depsMap.get(key)
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
deps.add(activeEffect) // 把当前 effect 加进去
}
数据结构长这样:
javascript
targetMap (WeakMap)
└─ { name: '张三', age: 25 } (Map)
├─ 'name' → Set [ effect1, effect2 ]
└─ 'age' → Set [ effect3 ]
为什么用三层结构? 因为一个应用里有多个响应式对象(target),每个对象有多个属性(key),每个属性可能被多个 effect 依赖。三层刚好,多了浪费,少了不够。
第三步:触发更新
ts
function trigger(target: object, key: string | symbol) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
if (!deps) return
// 遍历所有依赖了这个 key 的 effect,逐个执行
deps.forEach(effect => effect())
}
简单粗暴:找到谁依赖了这个 key,挨个重新执行。
第四步:组装 reactive
ts
function reactive<T extends object>(raw: T): T {
return new Proxy(raw, {
get(target, key, receiver) {
track(target, key) // 读取时收集依赖
const result = Reflect.get(target, key, receiver)
// 如果值是对象,递归代理(懒代理,用到才包)
if (typeof result === 'object' && result !== null) {
return reactive(result)
}
return result
},
set(target, key, value, receiver) {
const oldValue = target[key as keyof T]
const result = Reflect.set(target, key, value, receiver)
if (oldValue !== value) {
trigger(target, key) // 值变了才触发,没变不浪费
}
return result
}
})
}
注意两个细节:
- 懒代理:Vue3 不会在初始化时递归代理整个对象,只有 get 到嵌套对象时才代理。对比 Vue2 初始化就递归遍历,这就是性能差距。
- Reflect.get/set :不直接用
target[key],因为 Reflect 能正确处理this指向和继承问题。你可能觉得"直接读不也行吗"------行,但在有 getter/继承的场景会出 bug。
跑一下看看
ts
const state = reactive({ count: 0, msg: 'hello' })
// effect 1:只依赖 count
effect(() => {
console.log('count changed:', state.count)
})
// 立即输出:count changed: 0
// effect 2:只依赖 msg
effect(() => {
console.log('msg changed:', state.msg)
})
// 立即输出:msg changed: hello
state.count++
// → "count changed: 1" ✅ effect1 触发
// → (effect2 没触发) ✅ 精准更新,不是无脑全刷
state.msg = 'world'
// → "msg changed: world" ✅ effect2 触发
70 多行代码,一个能跑的响应式系统就出来了。
设计权衡:Vue3 做了哪些取舍
为什么用 WeakMap 而不是 Map?
ts
// WeakMap 的 key 是弱引用,对象没有其他引用时会被 GC 回收
// 如果用 Map → 响应式对象永远被 targetMap 引用 → 内存泄漏
const targetMap = new WeakMap() // ✅
const targetMap = new Map() // ❌ 内存泄漏风险
为什么 effect 要立即执行一次?
因为不执行就收集不到依赖。依赖收集发生在 get 里,不读一遍属性,系统不知道你依赖了谁。
这也是 watchEffect 和 watch 的核心区别:
ts
// watchEffect → 立即执行,自动收集依赖
watchEffect(() => {
console.log(state.count) // 读了 count → 自动依赖 count
})
// watch → 你手动告诉它监听谁
watch(() => state.count, (newVal) => {
console.log(newVal)
})
懒代理 vs 初始化全量代理
| 策略 | 初始化耗时 | 运行时耗时 | 适合场景 |
|---|---|---|---|
| Vue2 全量递归 | 高(大对象很慢) | 低 | 小型对象 |
| Vue3 懒代理 | 几乎为零 | 首次访问有微小开销 | 大型 / 深层嵌套对象 |
Vue3 选了懒代理,因为实际项目中大部分属性不会在首帧全部读取------你一个 1000 行的 config 对象,首屏可能只用了 5 个字段,全量代理纯属浪费。
我们的迷你版漏了什么
写到这里你可能觉得"就这?挺简单啊"。别急,真实的 Vue3 reactivity 还处理了一堆你想不到的边界情况:
1. effect 嵌套
ts
effect(() => {
console.log('outer', state.a)
effect(() => {
console.log('inner', state.b) // 内层 effect 执行完,activeEffect 被置为 null
})
console.log(state.c) // ❌ 这里 activeEffect 已经是 null 了,c 的依赖收集不到!
})
Vue3 用 effectStack(栈结构)解决这个问题------进入 effect 时 push,退出时 pop,恢复上一层的 activeEffect。
2. 无限循环
ts
effect(() => {
state.count = state.count + 1 // 读 count → 触发 get → 收集依赖
// 写 count → 触发 set → 执行 effect
// effect 又读 count → 又触发 set → 💥 死循环
})
Vue3 的解法:如果当前正在执行的 effect 和要触发的 effect 是同一个,跳过。
3. ref 的存在意义
ts
// reactive 只能代理对象
const count = reactive(0) // ❌ Proxy 不能代理基本类型
// ref 用对象包一层,把基本类型变成对象
const count = ref(0) // 内部:{ value: 0 } → 再用 reactive 代理
count.value++ // 通过 .value 触发 get/set
所以 ref 的 .value 不是脱裤子放屁------是基本类型没法直接 Proxy 的无奈之举。
4. 集合类型的处理
ts
const map = reactive(new Map())
map.set('key', 'value') // set 方法不是赋值操作,Proxy 的 set trap 拦不到
// Vue3 对 Map/Set/WeakMap/WeakSet 做了专门的 handler
// 拦截的是 get → 拿到 set 方法 → 返回一个包装后的 set 方法
这部分代码在 Vue3 源码里占了不少篇幅,也是最容易被忽略的。
可扩展性:这套模型能做什么
这套 track → trigger → effect 模型不只是给 Vue 用的,它本质上是一个自动依赖追踪的发布-订阅系统。
你完全可以用它来做:
- 状态管理:Pinia 的底层就是 reactive + 一些封装
- 计算缓存:computed 就是一个带 dirty 标记的 effect
- 跨组件通信:provide/inject + reactive = 自动响应式的上下文注入
- 表单引擎:字段之间的联动关系,天然适合响应式依赖图
如果你的项目需要一套"数据变了自动通知"的机制,不一定要上 Vue,把这 70 行代码抄走改改就能用。
总结:一个通用模型
Vue3 响应式的本质,是解决一个古老的编程问题:状态同步。
A 变了,B 要跟着变。手动同步容易漏、容易错、容易忘。
Vue3 的解法是:
- Proxy 拦截读写------知道谁被读了、谁被改了
- effect + activeEffect------知道"谁在关心这个数据"
- track / trigger------自动建立和触发依赖关系
- WeakMap 三层结构------高效存储依赖映射
以后遇到类似的问题,不管是前端状态管理、后端事件驱动、还是 Excel 的单元格公式联动,底层模型都是一样的:观察者模式 + 自动依赖追踪。
区别只在于:谁来当观察者,怎么收集依赖,粒度做到多细。
想明白这一层,你看任何响应式框架(Solid、Svelte 5 runes、Preact signals)都会觉得------嗯,换了个壳,内核没跑出这个圈。