Vue3 响应式原理
【面试速答版】
Q1: “Vue3 的响应式是如何实现的?”
基于 Proxy 代理整个对象,而非 Vue2 的 Object.defineProperty 劫持单个属性。当访问对象的属性时触发 Proxy 的 get,通过 track 函数收集依赖(存入 WeakMap <target, Map<key, Setset,通过 trigger 通知所有依赖更新。Vue3 的响应式分为两层:reactive 对对象做深层 Proxy 代理,ref 把基础类型包装成 { value } 对象(内部本质还是 reactive)。与 Vue2 相比,Proxy 可监听数组下标修改、新增/删除属性、Map/Set 等内置类型,且初始化时不需要递归遍历(惰性代理——访问到深层时才代理)。
Q2: “ref 和 reactive 的区别是什么?为什么需要 ref?”
reactive 接收对象返回 Proxy,直接操作属性。ref 接收任意值(基础类型或对象),返回 { value } 结构,通过 .value 读写。为什么需要 ref? 因为 Proxy 只能代理对象,不能代理基础类型(number/string/boolean 等)。如果 Vue3 只有 reactive,那 let count = 0; count++ 就无法变成响应式——必须包在一个对象里。ref 就是那个”壳”。另外 ref 在赋值时可以直接替换整个值(count.value = 100),而 reactive 做不到(state = newObj 会断开引用)。
Q3: “Vue3 如何处理数组和新增属性的响应式?”
直接处理——因为 Proxy 代理的是整个对象。数组 arr.push(4) 会触发 Proxy 的 set,arr[4] = 5 也会触发。给对象新增属性 obj.newProp = 'hello' 同样会触发。Vue2 为什么不行?因为 Object.defineProperty 在初始化时遍历已有属性逐一劫持,之后新增的属性自然不会触发 setter。Vue2 用 Vue.set 和 Vue.delete 来弥补,Vue3 不再需要它们。
【深入理解版】
1. 这个知识点要解决什么问题?
响应式是 Vue 最核心的能力——数据变化自动驱动视图更新。当你在模板中使用 {{ count }},修改 count 时页面自动更新,不需要手动操作 DOM。
Vue2 实现这个能力的技术选型是 Object.defineProperty——在组件初始化时遍历 data 对象的所有属性,逐一用 getter/setter 替换。这个方案的先天限制:① 只能劫持已有属性——新增属性或删除属性不会被劫持,需要 Vue.set/Vue.delete 补充;② 数组的下标赋值(arr[0] = x)不会被劫持,Vue 只能 hack 了 push/pop/splice 等 7 个数组方法;③ 初始化时递归遍历整个 data 树,如果 data 很大,一次性开销可观。
Vue3 的 Proxy 方案从根本解决了这些问题——Proxy 代理的是”对象”本身,而不是”对象的属性”。不管新增、删除、数组操作都在 Proxy 的拦截范围内。
2. 核心原理/执行过程
2.1 Proxy 工作流程
1 | import { reactive } from 'vue' |
当访问 state.count 时:
1 | state.count |
当修改 state.count = 1 时:
1 | state.count = 1 |
依赖关系图的数据结构:
1 | // WeakMap 的键是原始对象(优势:对象被回收时自动清除对应的依赖) |
为什么用 WeakMap? WeakMap 的键是”弱引用”——当原始对象 target1 不再被任何变量引用时,它可以被垃圾回收,同时 WeakMap 中对应的条目也会自动删除。如果换成普通 Map,即使 target1 被回收了,Map 中仍保留着它的条目,造成内存泄漏。
2.2 惰性递归(Lazy Proxy)—— 为什么比 Vue2 快
Vue2 初始化时递归遍历 data 的所有属性,不管用不用都变成 getter/setter:
1 | // Vue2 — 初始化时全部递归 |
Vue3 只在实际访问到深层对象时才创建 Proxy:
1 | const state = reactive({ user: { profile: { age: 25 } } }) |
这就是惰性递归,也叫”懒代理”。不访问就不代理,省掉了 Vue2 那种递归整个 data 树的开销。
2.3 ref 的内部实现
1 | function ref(value) { |
ref(0) 创建了一个 { value: 0 } 结构,它的 .value 的 getter/setter 实现了 track 和 trigger。所以 ref 的 .value 本质上就是一个属性访问,与 reactive 的 state.count 没有区别。
3. 实际应用场景
场景1:对比 Vue2 和 Vue3 处理数组
1 | // Vue2 — 只能检测 7 个方法 |
场景2:shallowRef 优化大数据集合
1 | import { shallowRef, triggerRef } from 'vue' |
如果用 ref(深层代理)包裹 10 万条数据,初始化时会递归代理每条数据的每个属性,造成明显的卡顿。shallowRef 只代理 .value 一层,内部数据不代理,省掉大量初始化时间。
场景3:readonly 保护跨组件数据
1 | // 在 provider 中暴露只读数据 |
4. 常见误区 & 实际项目中的坑
误区1:ref 在模板中不用 .value,认为在所有地方都不用
1 | const count = ref(0) |
误区2:reactive 可以代理基础类型
1 | const a = reactive(1) // ❌ 无效 |
reactive 只接收对象,传入基础类型会原样返回。基础类型必须用 ref。
坑:解构 reactive 对象时丢失响应式
1 | const state = reactive({ a: 1, b: 2 }) |
解法:用 toRefs(state) 把每个属性转为 ref,然后再解构。
5. 与相关知识的关联 & 对比
| 对比维度 | Vue2 (Object.defineProperty) | Vue3 (Proxy) |
|---|---|---|
| 监听方式 | 劫持单个属性 | 代理整个对象 |
| 数组操作 | 需 hack 7 个方法 | 原生支持 |
| 新增属性 | Vue.set() 或 this.$set() | 直接赋值即可 |
| 删除属性 | Vue.delete() | delete obj.key |
| 深层代理 | 初始化时递归遍历(一次性开销) | 惰性代理(访问时才代理) |
| Map/Set | 不支持 | 支持 |
| 兼容性 | IE9+ | 不支持 IE |
| API | 作用 | 适用场景 |
|---|---|---|
| reactive() | 深层 Proxy 代理 | 对象/数组/Map/Set |
| ref() | 包装为 { value } | 基础类型、需要整体赋值的变量 |
| shallowRef() | 仅代理 .value 层 | 大数据结构 |
| shallowReactive() | 仅代理第一层属性 | 已知结构不变的对象 |
| readonly() | 只读代理 | 状态保护 |
| toRaw() | 获取原始对象 | 需要绕过响应式系统时 |
| triggerRef() | 手动触发 shallowRef 更新 | 配合 shallowRef |
6. 现代最佳实践(2024-2025)
- 优先用 ref 而不是 reactive——ref 在赋值时可以直接替换(
count.value = 100),reactive 不行(state = newObj会断开引用)。ref 的 .value 写法虽然麻烦一点,但胜在一致性和可预测性。 - 大数据集合用 shallowRef——超过 1 万条数据的列表,用 shallowRef 包裹,更新时整个替换或手动 triggerRef。
- 跨组件传递状态用 readonly 包装——避免子组件意外修改父组件的状态。
- 第三方实例(地图、图表、SDK)用 markRaw——告诉 Vue”不要代理这个对象”,避免不必要的性能损耗。
- TypeScript 中善用泛型:
ref<number>(0)、reactive<{ x: number }>({ x: 0 })。
7. 常见疑问解答
Q:Proxy 的 handler.set 中为什么要用 Reflect.set?直接 target[key] = value 不行吗?
A:直接 target[key] = value 也可以修改值,但 Reflect.set 有一些额外的行为——它会触发对象原型上的 setter、正确处理继承属性、返回 boolean 表示是否设置成功。Vue3 的 trigger 依赖这个 boolean 来判断是否需要通知更新。更关键的是,Reflect.set 的 receiver 参数可以指定 this 指向——当你通过代理访问一个继承属性时,Reflect.set 能确保 this 指向代理对象而不是原始对象,保证依赖收集的正确性。
Q:reactive 怎么判断一个对象已经被代理过了?
A:Vue3 在代理时给原始对象添加了一个隐藏的不可枚举属性 __v_raw 和 __v_isReactive(通过 toRaw 和 isReactive 检查)。已经代理过的对象,再次调用 reactive(obj) 会直接返回已有的 Proxy,不会重复代理。同时 Proxy 代理后的对象再次被 reactive() 也不是问题——它检查到已经是 Proxy 就跳过。
Q:为什么 reactive([ref(1)]) 数组中的 ref 不会自动解包?
A:自动解包规则是设计上的权衡。Vue 团队认为在 reactive 数组中出现 ref 的场景比较少,而且如果自动解包会导致一些歧义——比如 arr[0] = 2 到底是要替换 ref 的值还是替换整个 ref?为了保持一致性,数组中的 ref 不解包,需要显式使用 .value。
关联知识点索引
Vue3 核心变化与 Composition API.md— ref/reactive 在组件中的使用diff 算法与虚拟DOM.md— 响应式变化如何驱动 VDOM 更新