接手过一个老项目:Vue 2 写的,往某个对象上加了几个新字段,页面死活不更新。老同事看一眼就说「用
this.$set()啊」,加了就好。当时只觉得是个 API 记不牢,后来才明白——那是Object.defineProperty的天花板,也是 Vue 3 改用Proxy的根本原因。
这篇把「数据变了视图怎么知道的」讲透:从 Vue 2 的 defineProperty 讲到 Vue 3 的 Proxy,再拆开依赖收集与派发更新两个核心动作。文中所有行为结论都用 @vue/reactivity 真实源码实跑验证过(附验证清单)。
一、解决什么问题:数据变了,视图怎么知道?
Vue 的核心理念是「数据驱动视图」,但数据只是内存里的一个对象,它不会主动喊"我变了"。要让视图跟着变,必须有这么一条链路:
- 收集:谁(哪个渲染函数/计算属性)读过这个数据,先记下来;
- 通知:数据被修改时,把记下来的"读者"挨个叫醒,让它们重跑。
这就是经典的发布订阅模式:数据是发布者,读它的人是订阅者。Vue 要做的,就是拦截数据的读写——读的时候收集订阅者,写的时候通知订阅者。拦截手段,就是两代实现的分水岭。
二、Vue 2 的做法:Object.defineProperty
Vue 2 在初始化时遍历对象的所有属性,用 Object.defineProperty 把每个属性替换成带 get/set 的存取器:
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
// 读取时:收集依赖(谁在读我)
track(obj, key)
return val
},
set(newVal) {
if (newVal !== val) {
val = newVal
// 修改时:通知依赖(我变了,重跑)
trigger(obj, key)
}
},
})
}思路是对的,但只能在初始化时就存在的属性上做文章,于是有了三个著名坑:
| 场景 | Vue 2 表现 | 补救 |
|---|---|---|
| 给对象新增属性 | setter 不存在,不触发更新 | 必须 this.$set(obj, key, val) |
| 删除属性 | 同样无感知 | 无 API,只能整个对象替换 |
| 按索引改数组元素 | arr[0] = x 不触发 | 必须 this.$set 或 splice |
这几个坑,本质都是「defineProperty 是静态的:属性存在时才有劫持,后来加进来的它管不着」。Vue 2 只能靠 $set、重写数组方法(push/splice 等)打补丁,补丁还补不全——arr[0] = x 就是漏网之鱼。
三、Vue 3 的做法:Proxy
Vue 3 换了个根本思路:不再逐个劫持属性,而是劫持整个对象。new Proxy(target, handler) 返回一个代理对象,对原对象的所有操作(读、写、新增、删除、遍历……)都先经过 handler 里的拦截函数:
const state = new Proxy(
{ name: 'BinMaker', age: 30 },
{
get(target, key) {
track(target, key) // 读任何属性都能拦截到
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key) // 写任何属性(包括新增)都能拦截到
return true
},
deleteProperty(target, key) {
delete target[key]
trigger(target, key) // 删除也能拦截到
return true
},
}
)
state.age = 31 // 命中 set → 触发更新
state.extra = '新' // 新增属性也命中 set → 触发更新(Vue 2 的 $set 痛点没了)
delete state.age // 命中 deleteProperty → 触发更新Proxy 支持 13 种拦截操作,其中对响应式最关键的三个:
| 拦截操作 | 触发时机 | 对应能力 |
|---|---|---|
get | 读取任意属性 | 收集依赖,且新增属性也能收集 |
set | 修改或新增属性 | 触发更新,$set 成为历史 |
deleteProperty | 删除属性 | 删除也能触发更新 |
两个细节值得注意:
- 惰性深层:
reactive({ user: { name: 'x' } })里user并不会在初始化时深度递归代理,而是第一次读到state.user时才把它包成reactive——对象再大也只代理用到的部分。 - 数组天然支持:
push、按索引赋值、length变化都走set拦截,Vue 2 那套数组方法重写补丁整个不需要了。
四、核心两动作:依赖收集 track 与派发更新 trigger
拦截只是"能看到读写",真正的机制是这两个函数,一读一写,配合成环:
- track(收集):
get触发。当某个函数(effect,比如组件的渲染函数)执行时读到state.key,就记录「effect 依赖 key」。同一个 effect 重复读同一个 key 只收集一次。 - trigger(派发):
set/deleteProperty触发。把收集到的、依赖这个 key 的所有 effect 挨个重新执行,视图随之更新。
用 @vue/reactivity 的 effect 验证这条链路(已实跑,[PASS]):
import { reactive, effect } from '@vue/reactivity'
const state = reactive({ count: 0 })
let log = null
effect(() => {
// 渲染函数执行:读到 state.count → track 收集「本 effect 依赖 count」
log = `count=${state.count}`
})
state.count = 1 // set → trigger → 重跑上面的 effect
// log === 'count=1' ✅ 首次执行 1 次 + 修改后再执行 1 次五、ref 与 reactive:为什么基本类型要包一层
Proxy 只能代理对象,基本类型(字符串、数字、布尔)没有属性可拦截,Vue 3 就用 ref 包一层对象:
import { ref, reactive, isRef, isReactive } from '@vue/reactivity'
const count = ref(0) // 内部是 { value: 0 },value 由 reactive 处理
count.value++ // 访问/修改必须 .value
const user = reactive({ name: 'BinMaker' })
user.name = 'Alan' // 对象直接点属性
// isRef(count) === true isReactive(user) === true ✅模板里 {{ count }} 不用写 .value,是 Vue 编译模板时自动解包,JavaScript 逻辑里没这个待遇。一句话记法:基本类型用 ref,对象用 reactive;ref 里存对象时会自动转成 reactive 行为。
六、computed:会缓存的 effect
computed 的本质是一个「带缓存的 effect」:只有它依赖的数据变了才重算,否则直接返回上次结果。实跑验证([PASS]):
import { ref, computed } from '@vue/reactivity'
const a = ref(1)
const b = ref(2)
const sum = computed(() => a.value + b.value)
sum.value // 3,首次计算
sum.value // 3,缓存命中,不重算
a.value = 10
sum.value // 12,依赖变了才重算这个「依赖没变就不重算」的行为,是模板里用 computed 而不是直接在模板写表达式做复杂计算的原因——省性能,也更好测。
七、实跑验证清单
本篇所有行为断言都用真实 @vue/reactivity(Vue 3.5.x 源码)在 Node 里实跑,12 项全 PASS:
| 行为 | 结果 |
|---|---|
| effect 随依赖变化自动重跑 | ✅ 改 2 次执行 3 次(首次+2 次修改) |
| 嵌套对象深层属性响应 | ✅ state.user.name 改动生效 |
数组 push / 按索引赋值 | ✅ 都触发 |
| 新增属性直接响应 | ✅ 无需 $set(Vue 2 做不到) |
delete 属性触发更新 | ✅ |
ref 需 .value 访问 | ✅ isRef 判定 |
computed 缓存 | ✅ 依赖不变不重算 |
Map/Set 响应式 | ✅ Vue 3 原生支持 |
| 同一 effect 重复读同一依赖 | ✅ 只收集一次,不重复触发 |
| defineProperty 无法拦截新增属性 | ✅ Vue 2 痛点的根因复现 |
小结
| 维度 | Vue 2 | Vue 3 |
|---|---|---|
| 拦截手段 | Object.defineProperty 逐属性劫持 | Proxy 整体代理(13 种操作) |
| 新增属性 | 不响应,需 $set | 直接响应 |
| 删除属性 | 不响应 | 直接响应 |
| 数组索引赋值 | 不响应,需补丁 | 直接响应 |
| 深层代理 | 初始化时递归 | 惰性,读到才代理 |
| 基本类型 | data 直接挂 | 需 ref 包装 |
想从用法层面先跑一遍响应式,看 Vue 快速入门;computed/watch 在代码里怎么组织,看 Composition API 详解;Vue 2 和 Vue 3 的版本脉络、其他破坏性变更,看 Vue 版本演进:Vue 2 与 Vue 3 全对比。
