开场:我被Vue2的响应式坑惨了
刚开始用Vue3的时候,我真以为响应式就是换个API那么简单。结果第一个项目就踩了三个坑——对象新增属性不更新、数组索引赋值没反应、computed的getter里不小心改了响应式数据导致死循环。我靠,debug了整整一下午才搞明白。
Vue2的Object.defineProperty确实有天花板——你总不能提前把所有属性都劫持了吧?Vue3直接用Proxy,这才叫真正的”全量拦截”。一开始搞错了,后来发现Proxy连数组的push都能拦截,真香。
先看:Proxy到底干了什么
javascript
// 这是Vue3响应式的基石
const target = { name: 'vue3', version: 3 }
const handler = {
get(target, key, receiver) {
console.log(`读取 ${key}`)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
console.log(`设置 ${key} = ${value}`)
return Reflect.set(target, key, value, receiver)
}
}
const proxy = new Proxy(target, handler)
proxy.name // 输出 "读取 name"
proxy.version = 4 // 输出 "设置 version = 4"
为什么要用Reflect?因为Proxy的handler里如果直接操作target,会绕过handler的陷阱,但更坑的是——如果你在handler里又访问代理对象,就会无限递归。Reflect能保证操作符合原始语义,避免这种递归。这个设计真的反人类——我第一次写的时候忘了用Reflect,结果浏览器直接崩了。官方文档这段文档不够清晰,我翻了3遍才看懂。
核心:依赖收集与触发更新的完整链路
Vue3的响应式本质就三件事:
reactive的实现(简化版)
javascript
// 全局的依赖容器
const targetMap = new WeakMap()
let activeEffect = null
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver)
// 收集依赖
track(target, key)
// 如果值是对象,递归代理(注意:实际Vue3有缓存机制避免循环引用)
if (typeof value === 'object' && value !== null) {
return reactive(value)
}
return value
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
// 值变了才触发更新
if (oldValue !== value) {
trigger(target, key)
}
return result
}
})
}
function track(target, key) {
if (!activeEffect) return
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)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
if (deps) {
deps.forEach(effect => effect())
}
}
为什么用WeakMap? 当时我查WeakMap的文档,差点被弱引用绕晕。简单说就是:如果target对象被销毁了,对应的依赖映射也会自动消失,不会占着内存不放。比Map省心多了,真坑——我第一次用了Map,结果页面切了20次,内存从30MB飙到200MB。
ref的原理
ref其实是对reactive的封装。当你写 ref(0) 时,Vue3内部其实创建了一个 { value: 0 } 的对象,然后对这个对象调用reactive。注意,ref也支持对象——如果传入对象,内部会自动调用reactive把它变成响应式。
javascript
function ref(rawValue) {
// 如果rawValue是对象,自动转为reactive
const reactiveValue = typeof rawValue === 'object' && rawValue !== null
? reactive(rawValue)
: rawValue
const r = {
get value() {
track(r, 'value')
return reactiveValue
},
set value(newValue) {
if (reactiveValue !== newValue) {
reactiveValue = newValue
trigger(r, 'value')
}
}
}
return r
}
有个坑:如果你在ref外面直接修改 rawValue,不会触发更新。必须通过 .value 赋值。我第一次写自定义hook的时候忘了这个,debug了整整一下午,最后发现是这么个破事。
另一个坑:computed的惰性求值
computed默认是惰性的——只有被读取时才计算。而且它会缓存结果,只有依赖变了才重新计算。这个设计挺聪明的,但坑也多。
javascript
function computed(getter) {
let value
let dirty = true // 标记是否需要重新计算
const effect = () => {
dirty = true
// 通知依赖computed的地方更新
trigger(obj, 'value')
}
const obj = {
get value() {
if (dirty) {
value = getter()
dirty = false
}
track(obj, 'value') // 让外层effect依赖这个computed
return value
}
}
// 注册effect,当依赖变化时标记dirty
// 注意:这里用独立的effect调度,避免在依赖收集阶段触发更新
effect() // 首次执行,收集依赖
return obj
}
这里有个隐藏的大坑:如果computed的getter里又访问了另一个computed,或者访问了reactive对象但不小心触发了setter,就会死循环。我在一个复杂的表单场景里用过三层computed嵌套,结果页面直接卡死。后来改成watch+普通变量才解决,搞了半天。
还有个技巧:effect的清理机制
清理机制这玩意儿,我一开始完全没当回事。组件卸载时,响应式依赖不会自动清理。如果你在组件里创建了effect,组件销毁后effect还在,就会导致内存泄漏。
Vue3的解决方案是让effect返回一个清理函数。另外,Vue3还提供了onCleanup回调,用于在effect重新执行前清理上一次的副作用(比如取消请求)。有一次线上项