许多前端开发者在初次接触 Vue 响应式原理时,常会被 Observer、Dep、Watcher、defineReactive 这几个概念绕晕。
归根结底,它们都指向同一个核心问题:当数据发生变化时,Vue 如何精确地知道哪些部分依赖了该数据,并及时通知它们完成更新。这正是 Vue 2 中 Object 变化侦测的关键所在。

当一个对象属性发生变化时,Vue 是如何识别出谁在使用它,并精准触发相应更新的?
1. 什么是变化侦测?
Vue 的渲染机制是声明式的。模板中写一个 h1 标签并利用 {{ name }} 进行动态插值,Vue 便会自动根据 name 的值生成对应的 DOM。然而,当 name 改变后,Vue 凭什么判断页面需要重新渲染?这正是变化侦测所要解答的问题。
变化侦测大体可分为两种思路。一种是“拉”模式:状态变化后,框架只知道“可能有内容变了”,于是重新执行计算并通过对比来找出实际需要更新的部分,React 的虚拟 DOM diff 就属于这一类。另一种是“推”模式:状态变更的瞬间,主动通知所有依赖该状态的地方。Vue 2 的响应式系统采用的就是后者。
Vue 的优势在于:它并不等到最后才去猜测哪个地方需要更新,而是在数据被读取时就记录下“谁使用了我”,待数据被修改时直接通知这些使用者。一句话概括:
数据读取时收集依赖,数据修改时触发依赖。
2. Vue 2 如何追踪 Object 的变化?
在 Vue 2 中,对象属性的变化主要依赖 Object.defineProperty 实现。它可以将一个普通属性转换为带有 getter 和 setter 的属性:
function defineReactive(data, key, val) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
}
})
}
这样一来,读取 data[key] 时会触发 get 方法,修改时则触发 set 方法。但这还不够——仅仅知道属性被读或被改,仍无法完成真正的响应式。真正的难点在于:读取时需要明确“谁”在读,修改时需要通知这些读取者执行更新。这就引出了 Vue 响应式系统中的第一个核心角色:Dep。
3. Dep:每个属性专属的“订阅管理器”
你可以把 Dep 理解为一个订阅中心。每一个响应式属性都拥有一个属于自己的 Dep 实例,当属性被读取时就将读取者收集进来,当属性变化时再通知所有订阅者。简化版的代码如下:
class Dep {
constructor() {
this.subs = []
}
depend() {
if (Dep.target) {
this.subs.push(Dep.target)
}
}
notify() {
this.subs.forEach(sub => sub.update())
}
}
接着,我们把它放回 defineReactive 中:
function defineReactive(data, key, val) {
const dep = new Dep()
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
dep.depend()
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
dep.notify()
}
})
}
逻辑瞬间变得清晰:
get -> dep.depend() -> 收集依赖
set -> dep.notify() -> 通知依赖
但下一个问题随之而来:Dep.target 到底是什么?答案就是:Watcher。
4. Watcher:真正需要被通知的执行者
Vue 不会直接将 DOM、模板或用户回调本身塞进 Dep,而是统一抽象为一个对象:Watcher。你可以把 Watcher 理解为“响应式系统中的订阅者”。例如:
vm.$watch('user.name', function(newVal, oldVal) {
console.log(newVal, oldVal)
})
底层会创建一个 Watcher 实例。它的职责非常明确:读取 user.name → 触发对应的 getter → 在 getter 中把自己收集到 Dep 中 → 当数据发生变更时执行自身的 update 方法 → 最终调用用户注册的回调函数。
简化后的 Watcher 实现可以这样理解:
class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
this.getter = parsePath(expOrFn)
this.cb = cb
this.value = this.get()
}
get() {
Dep.target = this
const value = this.getter(this.vm)
Dep.target = null
return value
}
update() {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
这里最核心的技巧在于 get() 方法。它先将当前 Watcher 设置为全局目标:Dep.target = this,然后再执行数据的读取。读取数据时会触发 getter,而 getter 里会调用 dep.depend(),于是这个 Watcher 就被成功纳入对应属性的 Dep 中。
这就是 Vue 2 依赖收集的精髓:Watcher 先把自己挂载到全局目标上,再主动读取数据。数据的 getter 被触发后,就能反向将这个 Watcher 收集起来。
5. parsePath:将字符串路径转换为读取函数
当我们编写 vm.$watch('a.b.c', callback) 时,Vue 需要根据字符串 'a.b.c' 获取到实际的值。简化实现如下:
function parsePath(path) {
const segments = path.split('.')
return function(obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
实现逻辑非常简单:将 'a.b.c' 按点号分割成 ['a','b','c'],然后逐层读取属性。需要注意的是,这个读取过程会依次触发每一层属性的 getter,因此 Watcher 不仅订阅了最终的 c 属性,它在读取链路中经过的每一层属性都有可能参与依赖收集。
6. Observer:将整个对象转换为响应式对象
前面介绍的 defineReactive 只能处理单个属性,但 Vue 的 data 通常是一个完整的嵌套对象:
data() {
return {
user: {
name: 'Tom',
age: 18
}
}
}
Vue 需要递归地将每个属性都转化为响应式属性,这正是 Observer 的职责所在:
class Observer {
constructor(value) {
this.value = value
if (!Array.isArray(value)) {
this.walk(value)
}
}
walk(obj) {
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
}
与此同时,defineReactive 内部也需要递归处理子对象:
function defineReactive(data, key, val) {
if (typeof val === 'object' && val !== null) {
new Observer(val)
}
const dep = new Dep()
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
dep.depend()
return val
},
set(newVal) {
if (newVal === val) return
if (typeof newVal === 'object' && newVal !== null) {
new Observer(newVal)
}
val = newVal
dep.notify()
}
})
}
一个普通对象经过 Observer 处理后,内部的每一个属性都会被转换为 getter/setter。可以这样理解它们各自的分工:
Observer:负责将对象加工成响应式对象;defineReactive:负责将单个属性转换为 getter/setter;Dep:负责保存该属性的依赖列表;Watcher:负责在数据变更后执行相应的更新逻辑。
7. 整体流程串接
假设模板中使用了 ,Vue 会为该组件创建一个渲染 {{ user.name }}
Watcher。
首次渲染时,流程如下:
Watcher 开始渲染
↓
读取 user.name
↓
触发 user 和 name 的 getter
↓
getter 调用 dep.depend()
↓
当前 Watcher 被收集进 Dep
之后当我们执行 this.user.name = 'Jerry' 时,流程变为:
触发 name 的 setter
↓
setter 调用 dep.notify()
↓
Dep 通知所有 Watcher
↓
Watcher.update()
↓
组件重新渲染
↓
生成新的虚拟 DOM
↓
diff 后更新真实 DOM
这就是 Vue 2 对象响应式的主链路。
8. 为什么 Vue 2 检测不到新增属性和删除属性?
Object.defineProperty 有一个天然限制:它只能拦截已经存在的属性。比如初始化时 data 返回 { user: {} },之后执行 this.user.name = 'Tom',这个 name 是后来才添加的,初始化阶段 Vue 并没有见过它,因此也就没有机会为其设置 getter/setter。所以 Vue 2 无法自动侦测新增属性。
删除属性也面临同样的问题:delete this.user.name 不会触发任何已定义的 setter,因此 Vue 2 同样无法直接感知。这就是为什么 Vue 2 需要提供 Vue.set()、Vue.delete() 或实例方法 this.$set()、this.$delete()。它们的本质就是绕过 Object.defineProperty 的限制,手动补充响应式处理与依赖通知。
9. Vue 2 为什么还要引入虚拟 DOM?
既然 Vue 已经能够精确知道哪个数据发生了变化,那为什么 Vue 2 仍然要使用虚拟 DOM?原因在于,粒度太细也会带来成本。如果每个数据都直接绑定到具体的 DOM 节点,依赖关系会变得非常庞大,内存占用与维护开销都会显著上升。所以 Vue 2 选择了一个折中方案:数据变化时通知组件,组件内部再通过虚拟 DOM diff 找出真正需要更新的 DOM。
也就是说,Vue 2 并没有完全放弃“推”的模式,而是将更新粒度从“具体 DOM 节点”提升到了“组件级别”。这样一来,既保留了响应式依赖追踪的优势,又避免了过细粒度带来的巨大依赖开销。
10. 总结
Vue 2 中 Object 的变化侦测,可以浓缩为一句话:利用 Object.defineProperty 拦截属性的读写操作;在读取时收集 Watcher,在修改时通知 Watcher。
几个核心角色的职责分别是:
Observer
负责遍历对象,将属性转换为 getter/setter。
defineReactive
负责处理单个属性,使其具备响应式能力。
Dep
负责保存依赖,相当于每个属性自己的订阅列表。
Watcher
是真正的订阅者。它读取数据时被收集,数据变化时被通知。
完整流程为:
初始化 data
↓
Observer 遍历对象
↓
defineReactive 转换属性
↓
Watcher 读取数据
↓
getter 收集依赖
↓
数据被修改
↓
setter 触发通知
↓
Dep 通知 Watcher
↓
Watcher 执行更新
这就是 Vue 2 响应式系统的基本骨架。理解这条链路后,再去查阅 Vue 源码中的 Observer、Dep、Watcher,就不会觉得它们是几个孤立的类,而是一套完整的订阅发布系统:数据负责暴露变化,Dep 负责管理依赖,Watcher 负责响应变化,视图更新只是 Watcher 被触发后的最终结果。
