响应式原理
vue官方对于响应式原理的介绍:https://cn.vuejs.org/guide/extras/reactivity-in-depth
在 Vue3 中,响应式系统迎来了彻底的重构,核心改动主要有以下几点:
- 底层 API 替换:实现响应式的核心 API 由
Object.defineProperty改为原生 ES6 的Proxy。 - 依赖收集模型:引入了基于副作用函数
effect的模型(取代了 Vue2 笨重的 Observer、Watcher 实例)。 - 细粒度更新与数据结构:依赖地图使用了
WeakMap -> Map -> Set的弱引用数据结构,极大优化了内存管理。
一、 Vue3 响应式的基石:Proxy 与 Reflect
面试必考:相比 Vue2 的
Object.defineProperty,Vue3 为什么一定要换成Proxy?
- 拦截维度的降维打击:
defineProperty只能劫持对象的属性,因此 Vue2 必须在初始化时递归遍历对象的所有层级来绑定 getter/setter。而Proxy劫持的是整个对象,只需在访问到深层对象时才进行“懒代理”,极大提升了初始化性能。 - 支持更多操作的拦截:
defineProperty无法监听到对象属性的新增与删除(这就是为什么 Vue2 需要$set和$delete),也无法完美监听数组索引的赋值和 length 的改变。Proxy支持 13 种拦截操作(包括has拦截in操作符,deleteProperty拦截delete操作符),完美解决了这些痛点。
Proxy 与 Reflect 的黄金搭档
Vue3 的响应式不仅用了 Proxy,还深度绑定了 Reflect。
const proxy = new Proxy(target, {
get(target, key, receiver) {
// 依赖收集
track(target, key);
// 为什么不用 return target[key]?而要用 Reflect.get?
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
// 派发更新
trigger(target, key);
return result;
},
});
高级追问:为什么在
Proxy的get/set里一定要使用Reflect.get/set并且传入receiver?如果原对象中存在 getter 属性(如
get fullName() { return this.firstName + this.lastName }),且内部依赖了this。如果不传入receiver(指向 Proxy 实例本身),this会错误地指向原对象,导致firstName和lastName的读取绕过了 Proxy 的get拦截,依赖收集就会失败。传入receiver可以确保this正确绑定为代理对象。
二、 依赖收集与派发更新的核心链路
响应式更新主要分为两个阶段:track(依赖收集)和 trigger(派发更新)。
1. 核心数据结构 (WeakMap -> Map -> Set)
面试必考:Vue3 是如何存储响应式依赖的?为什么最外层要用
WeakMap?
Vue3 内部维护了一个全局的 targetMap,它的数据结构嵌套非常巧妙:
// targetMap (WeakMap)
{
[target1 对象引用]: Map { // depsMap
'name': Set [ effect1, effect2 ], // deps
'age': Set [ effect1, effect3 ]
},
[target2 对象引用]: Map { ... }
}
WeakMap(target -> depsMap):最外层的 key 必须是代理的原对象。使用WeakMap的原因是它是弱引用。当你的业务代码里销毁了target对象时,由于WeakMap不会阻碍垃圾回收,这个对象对应的所有响应式依赖都会被 V8 引擎自动回收,完美避免了内存泄漏。Map(key -> deps):中间层的 key 是对象的属性名(如name,age),value 是一个Set。Set(deps):最内层存放的是一个个副作用函数(effect)。使用Set是为了自动去重,保证同一个组件多次访问同一个属性时,只收集一次渲染effect。
2. 依赖收集 (track)
当你在模板中读取(get)某个响应式属性时,会触发 track 函数。
它的大致逻辑是:
- 取出当前正在执行的副作用函数(保存在全局的
activeEffect中)。 - 从
targetMap中找到当前对象对应的depsMap,再找到当前属性对应的deps(Set)。 - 把
activeEffect塞进这个Set里。
3. 派发通知 (trigger)
当你修改(set)某个响应式属性时,会触发 trigger 函数。
它的大致逻辑是:
- 从
targetMap中找到当前对象对应的depsMap,再找到当前属性对应的deps。 - 遍历执行
deps里的所有effect函数,触发视图重新渲染或执行 watch 回调。
三、 响应式 API 对比与边界情况
ref 与 reactive 的底层区别
面试经常问:为什么基础数据类型必须用 ref,而对象用 reactive?
reactive:底层通过Proxy代理对象。Proxy 只能代理对象,无法代理基础数据类型(如 string, number, boolean)。ref:底层是一个RefImpl类的实例。它通过对实例的.value属性设置getter/setter(类似于defineProperty的思想)来实现拦截。- 注意:如果传给
ref的是一个对象(如ref({ a: 1 })),ref底层依然会去调用reactive将其转换为 Proxy 对象,然后挂载到.value上。
- 注意:如果传给
面试陷阱:Proxy 的重复代理与解构失效
1. 重复代理问题
如果你把一个已经是 Proxy 的对象再次传给 reactive,会发生什么?
- Vue3 内部利用了刚才提到的
WeakMap作为缓存(Hash 表)。 - 在创建代理前,会先查一下这个对象是不是已经被代理过了,如果是,直接返回之前缓存的 Proxy 实例,避免了无意义的性能消耗。
2. 解构为什么会失去响应式?
const state = reactive({ count: 0 });
// 这里的 count 只是一个普通数字 0,完全脱离了 Proxy 的管控
let { count } = state;
- 原因:解构赋值相当于
let count = state.count。对于基础类型,赋值传递的是值拷贝。后续修改本地变量count,根本不会触发原state对象的 Proxyset拦截器。 - 解法:使用
toRefs(state)。它会把state里的每一个属性都包进一个ref实例中,解构出来的是ref对象引用,依然保留响应式。