[响应式原理02]:Proxy 懒代理如何实现深层嵌套响应式?WeakMap 又在其中扮演了什么角色?

5 阅读5分钟

上一篇讲了 Vue2/Vue3 响应式的宏观差异,这篇深挖 Vue3 的两个底层细节:深层嵌套的懒代理实现依赖存储为什么必须用 WeakMap。这两个问题在面试中经常以"追问"的形式出现,能答清楚就是加分项。

第二轮提问(深挖追问):

问题一: 在 Vue3 中,有一个极其深层的嵌套对象:

const state = reactive({ a: { b: { c: 1 } } })

当我在模板里访问了 state.a.b.c 时,Proxy 的 get 拦截器内部到底经历了什么样的过程,才让深层的 bc 也变成响应式对象的?它是如何实现"懒代理"的?

问题二: Vue3 的全局依赖追踪系统使用了 WeakMap -> Map -> Set 这个三层数据结构。请从内存管理的角度解释:最外层为什么一定要用 WeakMap?如果图省事直接用普通的 Map,在一个复杂的长期运行的 SPA 中会导致什么问题?

参考答案

一、深层嵌套的懒代理:get 拦截器里的"按需递归"

先看一段简化的 Vue3 reactive 源码逻辑(伪代码):

    function reactive(target) {
      return new Proxy(target, {
        get(target, key, receiver) {
          const res = Reflect.get(target, key, receiver)
    
          // 依赖收集(track)
          track(target, key)
    
          // 懒代理的核心:拿到值之后,判断它是不是对象
          // 如果是对象,就在这一刻递归 reactive 包裹它
          if (isObject(res)) {
            return reactive(res)
          }
    
          return res
        },
        set(target, key, value, receiver) {
          const res = Reflect.set(target, key, value, receiver)
          // 触发更新(trigger)
          trigger(target, key)
          return res
        }
      })
    }

整个过程是这样的:

  1. reactive({ a: { b: { c: 1 } } }) 执行后,只有最外层对象被 Proxy 代理,内部的 abc 此时还是普通值,不会产生任何代理开销。

  2. 当模板访问 state.a 时,触发外层 Proxy 的 get 拦截器。拦截器拿到 a 对应的值 { b: { c: 1 } },发现它是一个对象,于是在这一刻对它调用 reactive(),返回一个新的代理对象。

  3. 接着访问 .b,触发上一步刚创建的 Proxy 的 get,同样的逻辑,{ c: 1 } 被代理。

  4. 最后访问 .cc 的值是数字 1,不是对象,直接返回。

懒代理的本质:不是在初始化时递归,而是把"需不需要代理"这个判断推迟到get真正被触发的那一刻。用到哪一层,代理才到哪一层。


二、WeakMap vs Map:一个被忽视的内存陷阱

Vue3 的依赖存储结构长这样:

    WeakMap<target, Map<key, Set<effect>>>
  • 最外层 WeakMap:key 是被代理的原始对象(target),value 是它的依赖 Map
  • 中间层 Map:key 是属性名(key),value 是依赖这个属性的副作用集合
  • 最内层 Set:存放所有相关的 effect,用 Set 天然去重

问题的关键:最外层为什么是 WeakMap 而不是 Map

答案在于两者持有引用的方式不同:

  • Map 的 key 是强引用:只要 Map 存在,其中所有的 key 对象都不会被 GC(垃圾回收器)回收,即使你已经在业务代码里删掉了对这个对象的引用。
  • WeakMap 的 key 是弱引用:如果一个对象只被 WeakMap 作为 key 引用,GC 可以随时回收它,WeakMap 里对应的条目也会自动消失。

如果用 Map 会怎样?

想象一个复杂 SPA:用户在不同路由之间跳转,每个页面都声明了大量的 reactive 响应式数据。用户离开这个页面后,Vue 销毁了组件,业务代码里也不再持有这些数据对象的引用了——但如果依赖存储用的是 Map,这些对象的引用被 Map 的 key 牢牢抓住,GC 无法回收它们

随着用户在应用里持续使用,越来越多的"已死对象"堆积在内存里,无法释放。这就是经典的内存泄漏,在长期运行的 SPA 中会导致应用越来越卡,最终崩溃。

WeakMap 用弱引用持有 target,当组件销毁、外部引用断开后,GC 自然回收这些对象,WeakMap 里的条目也随之自动清除。


三、经典坑:拿到的不是同一个 Proxy?

在理解了懒代理的机制之后,有一个随之而来的问题很多同学会卡住:

const state = reactive({ a: { b: 1 } })
const a1 = state.a  // 第一次访问,创建了一个代理
const a2 = state.a  // 第二次访问,又创建了一个代理?
console.log(a1 === a2)  // 输出什么?

如果每次 get 都直接 new Proxy(res),那 a1 === a2 就是 false,这会带来无数奇怪的 bug。

Vue3 的处理方式是加一层缓存:在 reactive 函数里维护一个 proxyMap(实际上也是 WeakMap),每次创建代理之前,先检查这个 target 是否已经有对应的 Proxy 了,有则直接返回缓存,没有才创建新的。

所以实际上,a1 === a2 的结果是 true


复盘:三个你应该记住的细节

  1. 懒代理 = get 拦截器里做 isObject 判断 + 按需递归 reactive,而不是初始化时遍历

  2. WeakMap 防内存泄漏:用对象作 key 时,弱引用让 GC 能正常工作;改 Map 就是给长期运行的 SPA 埋一颗定时炸弹

  3. Proxy 缓存机制:同一个 target 多次调用 reactive,返回的是同一个代理对象(内部有 proxyMap 做缓存)


下一篇:[[响应式原理03]:computed 的缓存机制底层是怎么实现的?dirty flagscheduler 是什么关系?]