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

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 | function reactive(target) { |
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 | let activeEffect = null // 当前正在执行的副作用 |
WeakMap 用对象作 key 且不影响垃圾回收——当响应式对象不再被引用时,它的依赖表会随之被回收,避免内存泄漏。这是 Vue 3 对比手写实现的一个关键细节。
四、ref vs reactive:到底用哪个
reactive 只能代理对象,基本类型(number/string/boolean)没法直接 Proxy。于是 Vue 3 引入了 ref:用一个带 .value 的对象把基本类型包起来。
1 | class RefImpl { |
| 维度 | 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 | function computed(getter) { |
只有依赖的响应式数据变化、dirty 被 trigger 置回 true 时,下次读取才会重新计算。这避免了模板里重复调用带来的无谓开销。
六、watch 与 watchEffect 的差异
两者都基于 effect,但触发时机和写法不同:
| 对比项 | watch |
watchEffect |
|---|---|---|
| 依赖来源 | 显式指定(第一个参数) | 函数体内用到谁就追踪谁 |
| 触发时机 | 默认 pre(组件更新前),可配 flush |
同上,但首次立即执行 |
| 拿到旧值 | 有 oldVal / newVal |
无旧值 |
| 典型场景 | 监听某个具体状态做异步/副作用 | 依赖收集式地自动追踪 |
watch 的 flush: 'post' 能保证回调在 DOM 更新后执行,适合”数据变了再操作 DOM”;flush: 'sync' 则同步触发,谨慎使用,容易引发性能问题。
七、Composition API 实战
Composition API 把”按逻辑关注点”组织代码变成可能,而不是 Options API 的”按选项类型分散”。
1 | import { ref, onMounted, computed } from 'vue' |
把状态、计算、方法、生命周期收敛进 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 | <!-- v-memo:列表项成本较高时,只有 id/selected 变才更新 --> |
九、生产避坑清单
- 大对象用
shallowRef:深层不常变、只在顶层替换时,避免深层递归代理带来的初始化开销。 - 不要在
setup顶层写同步重逻辑:耗时操作放进onMounted或watch回调。 reactive不要整体重新赋值:需要替换请用ref包对象,或Object.assign(state, newObj)。- 解构必用
toRefs:传给子组件的 props/state 解构后仍是响应式的。 computed里不要有副作用:它可能被多次求值或缓存跳过,副作用应放在watch。watch监听对象用深层{ deep: true },但深层监听成本高,尽量监听具体路径() => state.a.b。v-for务必带稳定key,且避免用index当 key 造成错位复用。
一句话总结:Vue 3 响应式 = Proxy 拦截读写 + track/trigger 三级依赖表;组织逻辑用 Composition API 的组合函数;性能靠编译期 PatchFlag/Block 与运行期 v-memo 双管齐下。理解了这条主线,绝大多数”为什么不更新””为什么这么慢”的问题都能定位到根因。











