Vue3 响应式原理

【面试速答版】

Q1: “Vue3 的响应式是如何实现的?”

基于 Proxy 代理整个对象,而非 Vue2 的 Object.defineProperty 劫持单个属性。当访问对象的属性时触发 Proxy 的 get,通过 track 函数收集依赖(存入 WeakMap <target, Map<key, Set>>);修改属性时触发 set,通过 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.setVue.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
2
3
4
import { reactive } from 'vue'

const state = reactive({ count: 0 })
// reactive 内部:new Proxy(原始对象, handler)

当访问 state.count 时:

1
2
3
4
5
6
7
8
9
10
state.count

Proxy 的 get 拦截被触发

track(target, 'count') — 收集"谁依赖了这个属性"

存入依赖关系图:
WeakMap { target → Map { 'count' → Set [ effect1, effect2 ] } }

返回 Reflect.get(target, 'count')

当修改 state.count = 1 时:

1
2
3
4
5
6
7
8
9
10
11
state.count = 1

Proxy 的 set 拦截被触发

判断值是否真的变了(新旧值不同才继续)

trigger(target, 'count') — 通知所有依赖

从依赖关系图找到 Set [ effect1, effect2 ]

逐一执行 effect(如重新渲染组件、执行 watch 回调)

依赖关系图的数据结构

1
2
3
4
5
6
7
8
9
10
11
12
// WeakMap 的键是原始对象(优势:对象被回收时自动清除对应的依赖)
// Map 的键是属性名
// Set 存储所有依赖这个属性的 effect
WeakMap {
target1: Map {
'count': Set [ renderEffect, watchEffect ],
'name': Set [ renderEffect ]
},
target2: Map {
'items': Set [ renderEffect ]
}
}

为什么用 WeakMap? WeakMap 的键是”弱引用”——当原始对象 target1 不再被任何变量引用时,它可以被垃圾回收,同时 WeakMap 中对应的条目也会自动删除。如果换成普通 Map,即使 target1 被回收了,Map 中仍保留着它的条目,造成内存泄漏。

2.2 惰性递归(Lazy Proxy)—— 为什么比 Vue2 快

Vue2 初始化时递归遍历 data 的所有属性,不管用不用都变成 getter/setter:

1
2
3
4
5
6
// Vue2 — 初始化时全部递归
data() {
return { user: { profile: { age: 25 } } }
}
// 初始化时:遍历 user → user.profile → user.profile.age
// 都给加上 getter/setter,即使页面只用了 user.name

Vue3 只在实际访问到深层对象时才创建 Proxy:

1
2
3
4
5
6
7
const state = reactive({ user: { profile: { age: 25 } } })
// 初始化只代理了 state 和最外层的 user
// state.user.profile 还没被代理!

// 只有当模板或代码中访问了 state.user.profile 时:
const p = state.user.profile
// 这时才创建 profile 的 Proxy

这就是惰性递归,也叫”懒代理”。不访问就不代理,省掉了 Vue2 那种递归整个 data 树的开销。

2.3 ref 的内部实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function ref(value) {
// 如果传入的是对象,直接交给 reactive
if (isObject(value)) {
return reactive({ value }) // 包装一层 { value }
}
// 基础类型 → RefImpl 包装
return new RefImpl(value)
}

class RefImpl {
constructor(value) {
this._value = toReactive(value) // 深层响应式化
// getter/setter 访问器
}

get value() {
track(this, 'value') // 收集依赖
return this._value
}

set value(newVal) {
if (newVal !== this._value) {
this._value = toReactive(newVal)
trigger(this, 'value') // 通知依赖
}
}
}

ref(0) 创建了一个 { value: 0 } 结构,它的 .value 的 getter/setter 实现了 tracktrigger。所以 ref 的 .value 本质上就是一个属性访问,与 reactive 的 state.count 没有区别。

3. 实际应用场景

场景1:对比 Vue2 和 Vue3 处理数组

1
2
3
4
5
6
7
8
9
10
11
// Vue2 — 只能检测 7 个方法
const list = [1, 2, 3]
list.push(4) // ✅ 可检测(hack 的)
list[4] = 5 // ❌ 无法检测
list.length-- // ❌ 无法检测

// Vue3 — 全部可检测
const list = reactive([1, 2, 3])
list.push(4) // ✅
list[4] = 5 // ✅
delete list[0] // ✅

场景2:shallowRef 优化大数据集合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { shallowRef, triggerRef } from 'vue'

// shallowRef:只代理 .value 这一层,不深入代理内部
// 适合存储"整个替换"的大数据
const bigList = shallowRef([])

// 从 API 获取 10 万条数据
async function loadData() {
const data = await fetch('/api/big-list').then(r => r.json())
bigList.value = data // 整个替换,shallowRef 够用
}

// 如果只修改某一条(shallowRef 不会自动追踪内部变化)
function updateItem(index, newItem) {
bigList.value[index] = newItem
triggerRef(bigList) // 手动触发更新
}

如果用 ref(深层代理)包裹 10 万条数据,初始化时会递归代理每条数据的每个属性,造成明显的卡顿。shallowRef 只代理 .value 一层,内部数据不代理,省掉大量初始化时间。

场景3:readonly 保护跨组件数据

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在 provider 中暴露只读数据
import { readonly } from 'vue'

const state = reactive({ user: null, token: '' })

// 给子组件的数据是 readonly 的——子组件不能意外修改
provide('auth', readonly(state))

// 修改只能通过 provider 暴露的方法
provide('login', (credentials) => {
state.user = credentials.user
state.token = credentials.token
})

4. 常见误区 & 实际项目中的坑

误区1:ref 在模板中不用 .value,认为在所有地方都不用

1
2
3
4
5
6
7
const count = ref(0)
console.log(count) // RefImpl 对象,不是 0!
console.log(count.value) // 0

// reactive 数组中的 ref 不会自动解包
const arr = reactive([ref(1)])
console.log(arr[0].value) // 需要 .value

误区2:reactive 可以代理基础类型

1
2
const a = reactive(1)  // ❌ 无效
console.log(a) // 原样返回 1,没有任何响应式能力

reactive 只接收对象,传入基础类型会原样返回。基础类型必须用 ref

坑:解构 reactive 对象时丢失响应式

1
2
const state = reactive({ a: 1, b: 2 })
const { a, b } = state // ❌ a 和 b 是普通值

解法:用 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)

  1. 优先用 ref 而不是 reactive——ref 在赋值时可以直接替换(count.value = 100),reactive 不行(state = newObj 会断开引用)。ref 的 .value 写法虽然麻烦一点,但胜在一致性和可预测性。
  2. 大数据集合用 shallowRef——超过 1 万条数据的列表,用 shallowRef 包裹,更新时整个替换或手动 triggerRef。
  3. 跨组件传递状态用 readonly 包装——避免子组件意外修改父组件的状态。
  4. 第三方实例(地图、图表、SDK)用 markRaw——告诉 Vue”不要代理这个对象”,避免不必要的性能损耗。
  5. 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.setreceiver 参数可以指定 this 指向——当你通过代理访问一个继承属性时,Reflect.set 能确保 this 指向代理对象而不是原始对象,保证依赖收集的正确性。

Q:reactive 怎么判断一个对象已经被代理过了?

A:Vue3 在代理时给原始对象添加了一个隐藏的不可枚举属性 __v_raw__v_isReactive(通过 toRawisReactive 检查)。已经代理过的对象,再次调用 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 更新