上一篇讲了 Vue2/Vue3 响应式的宏观差异,这篇深挖 Vue3 的两个底层细节:深层嵌套的懒代理实现 和 依赖存储为什么必须用 WeakMap。这两个问题在面试中经常以"追问"的形式出现,能答清楚就是加分项。
第二轮提问(深挖追问):
问题一: 在 Vue3 中,有一个极其深层的嵌套对象:
const state = reactive({ a: { b: { c: 1 } } })
当我在模板里访问了 state.a.b.c 时,Proxy 的 get 拦截器内部到底经历了什么样的过程,才让深层的 b 和 c 也变成响应式对象的?它是如何实现"懒代理"的?
问题二: 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
}
})
}
整个过程是这样的:
-
reactive({ a: { b: { c: 1 } } })执行后,只有最外层对象被 Proxy 代理,内部的a、b、c此时还是普通值,不会产生任何代理开销。 -
当模板访问
state.a时,触发外层 Proxy 的get拦截器。拦截器拿到a对应的值{ b: { c: 1 } },发现它是一个对象,于是在这一刻对它调用reactive(),返回一个新的代理对象。 -
接着访问
.b,触发上一步刚创建的 Proxy 的get,同样的逻辑,{ c: 1 }被代理。 -
最后访问
.c,c的值是数字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。
复盘:三个你应该记住的细节
-
懒代理 = get 拦截器里做
isObject判断 + 按需递归reactive,而不是初始化时遍历 -
WeakMap 防内存泄漏:用对象作 key 时,弱引用让 GC 能正常工作;改 Map 就是给长期运行的 SPA 埋一颗定时炸弹
-
Proxy 缓存机制:同一个 target 多次调用
reactive,返回的是同一个代理对象(内部有 proxyMap 做缓存)
下一篇:[[响应式原理03]:computed 的缓存机制底层是怎么实现的?
dirty flag和scheduler是什么关系?]