Skip to main content

响应式原理

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

  1. 拦截维度的降维打击defineProperty 只能劫持对象的属性,因此 Vue2 必须在初始化时递归遍历对象的所有层级来绑定 getter/setter。而 Proxy 劫持的是整个对象,只需在访问到深层对象时才进行“懒代理”,极大提升了初始化性能。
  2. 支持更多操作的拦截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;
},
});

高级追问:为什么在 Proxyget/set 里一定要使用 Reflect.get/set 并且传入 receiver

如果原对象中存在 getter 属性(如 get fullName() { return this.firstName + this.lastName }),且内部依赖了 this。如果不传入 receiver(指向 Proxy 实例本身),this 会错误地指向原对象,导致 firstNamelastName 的读取绕过了 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 函数。

它的大致逻辑是:

  1. 取出当前正在执行的副作用函数(保存在全局的 activeEffect 中)。
  2. targetMap 中找到当前对象对应的 depsMap,再找到当前属性对应的 deps (Set)。
  3. activeEffect 塞进这个 Set 里。

3. 派发通知 (trigger)

当你修改(set)某个响应式属性时,会触发 trigger 函数。

它的大致逻辑是:

  1. targetMap 中找到当前对象对应的 depsMap,再找到当前属性对应的 deps
  2. 遍历执行 deps 里的所有 effect 函数,触发视图重新渲染或执行 watch 回调。

三、 响应式 API 对比与边界情况

refreactive 的底层区别

面试经常问:为什么基础数据类型必须用 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 对象的 Proxy set 拦截器。
  • 解法:使用 toRefs(state)。它会把 state 里的每一个属性都包进一个 ref 实例中,解构出来的是 ref 对象引用,依然保留响应式。