Vue 3 响应式原理与 Composition API 实战:Proxy、依赖收集与性能优化

Vue 2 用 Object.defineProperty 做响应式,遇到”新增属性不更新””数组索引改了没反应”只能靠 $set 打补丁。Vue 3 把底层换成了 Proxy,从根上解决了这些坑。本文把响应式的”任督二脉”——依赖收集与派发更新——彻底拆开,再看 Composition API 怎么把逻辑组织得更干净。

一、为什么 Vue 2 的响应式不够用

Vue 2 在初始化时递归遍历 data,用 Object.defineProperty 给每个属性加 getter/setter。这带来三个绕不开的硬伤:

痛点 现象 Vue 2 的妥协方案
新增属性无响应 this.obj.newKey = 1 视图不更新 必须用 this.$set(obj, 'newKey', 1)
删除属性无响应 delete this.obj.key 视图不更新 必须用 this.$delete(obj, 'key')
数组索引/length 监听不到 arr[0] = x / arr.length = 0 不触发 重写数组 7 个方法(push/pop/…)打补丁

Object.defineProperty 只能劫持已经存在的属性,无法感知”属性被新增或删除”,这是语言层面决定的死穴。Vue 3 的 Proxy 能拦截 get/set/deleteProperty/has/ownKeys 等十余种操作,从根本上覆盖这些场景。

二、Vue 3 响应式核心:Proxy + Reflect

reactive() 的本质,是给目标对象包一层 Proxy,在 get 时收集依赖、在 set 时触发更新。

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
28
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
// 依赖收集:谁在读这个属性,就记住谁
track(obj, key)
const result = Reflect.get(obj, key, receiver)
// 嵌套对象懒代理,只有被访问才继续 reactive
return typeof result === 'object' && result !== null
? reactive(result)
: result
},
set(obj, key, value, receiver) {
const oldVal = obj[key]
const result = Reflect.set(obj, key, value, receiver)
if (oldVal !== value) {
// 派发更新:属性变了,通知所有依赖它的副作用重跑
trigger(obj, key)
}
return result
},
deleteProperty(obj, key) {
const had = key in obj
const result = Reflect.deleteProperty(obj, key)
if (had) trigger(obj, key) // 删除属性也能触发更新
return result
}
})
}

Reflect 的作用有两个:一是保证 this 指向正确(配合 receiver 处理继承链上的 getter),二是返回 boolean 表示操作是否成功。

为什么必须配合 receiver?当对象存在继承、且父类 getter 里访问了 this 时,没有 receiver 会让 this 指向原始对象而非 Proxy,导致依赖收集遗漏。这是手写响应式最容易踩的坑。

三、依赖收集:effect / track / trigger

Vue 3 用一个三级数据结构记录”谁依赖了谁”:

结构 类型 作用
targetMap WeakMap key 是响应式对象,value 是它的依赖表
depsMap Map key 是属性名,value 是该属性的依赖集合
dep Set 存放所有依赖该属性的副作用函数(effect)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let activeEffect = null // 当前正在执行的副作用

function effect(fn) {
activeEffect = fn
fn() // 执行时会触发 get → track 收集 activeEffect
activeEffect = null
}

function track(target, key) {
if (!activeEffect) return
const depsMap = targetMap.get(target) ?? targetMap.set(target, new Map()).get(target)
const dep = depsMap.get(key) ?? depsMap.set(key, new Set()).get(key)
dep.add(activeEffect)
}

function trigger(target, key) {
const dep = targetMap.get(target)?.get(key)
dep?.forEach(eff => eff()) // 重新执行所有副作用
}

WeakMap 用对象作 key 且不影响垃圾回收——当响应式对象不再被引用时,它的依赖表会随之被回收,避免内存泄漏。这是 Vue 3 对比手写实现的一个关键细节。

四、ref vs reactive:到底用哪个

reactive 只能代理对象,基本类型(number/string/boolean)没法直接 Proxy。于是 Vue 3 引入了 ref:用一个带 .value 的对象把基本类型包起来。

1
2
3
4
5
6
7
8
class RefImpl {
constructor(value) {
this.__v_isRef = true
this._value = value
}
get value() { track(this, 'value'); return this._value }
set value(newVal) { this._value = newVal; trigger(this, 'value') }
}
维度 ref reactive
适用类型 基本类型 + 对象 仅对象/数组
访问方式 脚本里 .value,模板里自动解包 直接访问
重新赋值 count.value = 2 安全 state = newObj 会丢失响应性
解构 直接解构丢失响应,需 toRefs toRefs(state) 保持

解构会丢失响应性。直接 const { name } = reactiveObj 拿到的只是那一刻的值;要用 const { name } = toRefs(reactiveObj)toRefs 会把每个属性包成 ref,从而继续追踪。

另外还有几个变体:

  • shallowRef / shallowReactive:只做浅层响应式,深层变化不触发,适合大对象性能优化。
  • readonly:深层只读代理,常用于把状态暴露给子组件或外部,防止被意外修改。

五、computed:带缓存的派生状态

computed 不是简单包一层 effect,它的核心是两个标志:lazy(默认不立即执行)dirty(脏检查)

1
2
3
4
5
6
7
8
9
10
11
12
function computed(getter) {
let value, dirty = true
const runner = effect(getter, { lazy: true })
return {
get value() {
if (dirty) { value = runner(); dirty = false } // 依赖没变就返回缓存
track(this, 'value')
return value
},
set value(newVal) { /* 有 setter 时支持写回 */ }
}
}

只有依赖的响应式数据变化、dirtytrigger 置回 true 时,下次读取才会重新计算。这避免了模板里重复调用带来的无谓开销。

六、watch 与 watchEffect 的差异

两者都基于 effect,但触发时机和写法不同:

对比项 watch watchEffect
依赖来源 显式指定(第一个参数) 函数体内用到谁就追踪谁
触发时机 默认 pre(组件更新前),可配 flush 同上,但首次立即执行
拿到旧值 oldVal / newVal 无旧值
典型场景 监听某个具体状态做异步/副作用 依赖收集式地自动追踪

watchflush: 'post' 能保证回调在 DOM 更新后执行,适合”数据变了再操作 DOM”;flush: 'sync' 则同步触发,谨慎使用,容易引发性能问题。

七、Composition API 实战

Composition API 把”按逻辑关注点”组织代码变成可能,而不是 Options API 的”按选项类型分散”。

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

export function useCounter(initial = 0) {
const count = ref(initial)
const double = computed(() => count.value * 2)
const increment = () => count.value++
onMounted(() => console.log('counter mounted:', count.value))
return { count, double, increment }
}

// 组件中使用
export default {
setup() {
const { count, double, increment } = useCounter(10)
return { count, double, increment } // 模板直接可用
}
}

把状态、计算、方法、生命周期收敛进 useCounter 这样的组合函数(composable),多个组件之间可以零成本复用,逻辑内聚、可读性远胜 Options API 的 data/methods/computed/mounted 四分五裂。

八、编译期 + 运行时的双重性能优化

Vue 3 快,不只是因为 Proxy,更因为编译期帮你做了大量优化:

优化手段 说明
静态提升(hoistStatic) 不变的节点/属性提升到 render 外,只创建一次
PatchFlag 给动态节点打标记,diff 时只比对有变化的维度(class/text/props)
Block Tree 以结构稳定的”块”为单位收集动态节点,跳过静态子树
缓存事件处理函数 @click="foo" 编译为缓存引用,避免每次渲染新建函数
v-memo 手动声明依赖数组,数组不变则跳过该子树更新
1
2
3
4
<!-- v-memo:列表项成本较高时,只有 id/selected 变才更新 -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
{{ item.label }}
</div>

九、生产避坑清单

  1. 大对象用 shallowRef:深层不常变、只在顶层替换时,避免深层递归代理带来的初始化开销。
  2. 不要在 setup 顶层写同步重逻辑:耗时操作放进 onMountedwatch 回调。
  3. reactive 不要整体重新赋值:需要替换请用 ref 包对象,或 Object.assign(state, newObj)
  4. 解构必用 toRefs:传给子组件的 props/state 解构后仍是响应式的。
  5. computed 里不要有副作用:它可能被多次求值或缓存跳过,副作用应放在 watch
  6. watch 监听对象用深层 { deep: true },但深层监听成本高,尽量监听具体路径 () => state.a.b
  7. v-for 务必带稳定 key,且避免用 index 当 key 造成错位复用。

一句话总结:Vue 3 响应式 = Proxy 拦截读写 + track/trigger 三级依赖表;组织逻辑用 Composition API 的组合函数;性能靠编译期 PatchFlag/Block 与运行期 v-memo 双管齐下。理解了这条主线,绝大多数”为什么不更新””为什么这么慢”的问题都能定位到根因。