Vue 2 的响应式系统基于 Object.defineProperty 实现,它通过“数据劫持”来拦截对象属性的读取和修改,并在这些操作中完成“依赖收集”和“派发更新”。理解这一流程,是理解 Vue 响应式工作原理的基础。
第一步:数据劫持——让对象变得“可观测”
在 Vue 2 中,当你把一个普通对象传给组件的 data 选项时,Vue 会遍历这个对象的所有属性,使用 Object.defineProperty 为每个属性定义 getter 和 setter。这个过程被称为“数据劫持”。
function defineReactive(obj, key, val) {
// 递归处理嵌套对象
observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 依赖收集:如果有人正在读取这个属性,记录下来
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
// 如果新值是对象,也要递归劫持
observe(newVal);
// 派发更新:通知所有依赖这个属性的订阅者
}
});
}
这一步骤让普通的 JavaScript 对象变成了“可观测”的响应式对象。一旦有代码读取或修改这些属性,Vue 就能介入并完成后续的关键动作。
两个需要知道的限制:
- 无法检测属性的新增和删除
因为 defineProperty 只能劫持对象已有的属性。如果你动态给对象添加一个新属性(如 vm.newProp = 'value'),这个属性不会被自动转换成响应式。这也是为什么 Vue 2 需要提供 Vue.set / this.$set 和 Vue.delete / this.$delete 的原因。
- 数组的特殊处理
Object.defineProperty 原本可以监听数组索引的变化,但直接监听数组的每个索引在高频操作下性能很差,而且无法感知数组长度的变化。所以 Vue 2 选择重写了数组的 7 个变异方法(push、pop、shift、unshift、splice、sort、reverse),在这些方法被调用时手动触发更新。同时,通过索引直接修改数组元素(arr[0] = newVal)和修改长度(arr.length = 0)依然是不响应的。
第二步:依赖收集——建立“谁用了谁”的关系网
当组件渲染或计算属性求值时,会读取响应式对象的属性,从而触发该属性的 getter。Vue 在 getter 中完成“依赖收集”。
具体过程:
- 每个组件实例都有一个对应的
Watcher(渲染 Watcher),它负责在组件数据变化时重新渲染视图。 - 读取属性时,Vue 会把当前正在执行的
Watcher压入一个全局的“目标栈”Dep.target。 - 属性对应的 getter 会创建一个
Dep(依赖管理器)对象,并将当前的Watcher添加到Dep的订阅者列表中。 - 于是,
Dep与Watcher之间形成了一种多对多的关系:一个属性可能被多个组件依赖(多个 Watcher 订阅同一个 Dep),一个组件也可能依赖多个属性(一个 Watcher 订阅多个 Dep)。
// 简化的依赖收集过程
class Dep {
constructor() {
this.subs = []; // 存放 Watcher
}
addSub(sub) {
this.subs.push(sub);
}
depend() {
if (Dep.target) {
Dep.target.addDep(this); // 把当前 Watcher 添加到 Dep
}
}
}
// 在 getter 中
get() {
const dep = new Dep();
if (Dep.target) {
dep.depend(); // 收集依赖
}
return val;
}
正是这层依赖关系,让 Vue 能够精确地知道“哪些组件依赖了哪些数据”。当数据变化时,不需要全局扫描,只需通知对应 Dep 里的所有 Watcher 即可。
第三步:派发更新——精确地通知变化
当修改响应式属性时,会触发该属性的 setter。setter 内部会:
- 更新属性值(如果新值是对象则递归劫持)。
- 调用该属性对应的
Dep的notify()方法。 notify()方法会遍历subs列表中的每一个Watcher,调用它们的update()方法。Watcher的update()并不会立刻执行渲染,而是将自身推入一个异步更新队列。Vue 在同一个事件循环中收集到的所有Watcher更新,会被合并并批量执行,从而避免不必要的重复计算和 DOM 操作。
// 简化的派发更新过程
class Dep {
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
// 在 setter 中
set(newVal) {
val = newVal;
dep.notify(); // 通知所有订阅者
}
整个流程的直观理解
可以想象一个微信聊天群的比喻:
- 响应式对象:聊天群
- Dep:群成员列表
- Watcher:群里的某个成员(组件)
- getter:成员说“我对某个话题感兴趣”(读属性),话题就把他拉到一个专门的讨论组(依赖收集)
- setter:有人对话题发言(改属性),消息自动推送给该讨论组内所有感兴趣的人(派发更新)
整个过程不需要任何人挨个通知,数据变化会自动、精确地流向需要更新视图的组件。
Vue 3 的改进
Vue 2 这种基于 Object.defineProperty 的实现虽然能工作,但存在无法劫持新增/删除属性、数组响应式需要特殊处理等原生缺陷。Vue 3 使用 Proxy 取代了 Object.defineProperty,可以直接代理整个对象,监听属性添加、删除、数组索引修改和 length 变化,无需任何额外 API。同时,依赖收集和派发更新的核心流程没有根本性改变,依然是 getter 时 track,setter 时 trigger,只是实现更加一致和高效。
这一整套“数据劫持 → 依赖收集 → 派发更新”的机制,是 Vue 响应式系统的灵魂,也是“数据驱动视图”得以运转的底层支柱。