Vue3响应式原理实战:从Proxy到ref源码级解析

开场:我被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重新执行前清理上一次的副作用(比如取消请求)。有一次线上项

    滚动至顶部