很多人第一次看 Vue 响应式原理时,会被几个名字绕晕:Observer、Dep、Watcher、defineReactive。
其实它们解决的是同一个问题:
当一个对象属性发生变化时,Vue 怎么知道谁用过它,并通知这些地方更新?
这就是 Vue 2 中 Object 变化侦测的核心。
1. 什么是变化侦测?
Vue 的渲染是声明式的。我们在模板里写:
css
<h1>{{ name }}</h1>
Vue 会根据 name 的值生成 DOM。问题是:当 name 改变时,Vue 怎么知道页面应该更新?
这件事就叫变化侦测。
变化侦测大体可以分成两类:
一种是"拉":
状态变了以后,框架只知道"可能有东西变了",于是重新执行一轮计算,再通过比较找出真正需要更新的地方。React 的虚拟 DOM diff 就属于这个思路。
另一种是"推":
状态在变化的那一刻,就主动通知依赖它的地方。Vue 2 的响应式系统就是这种思路。
Vue 的优势在于:它不是等到最后再猜哪里变了,而是在数据被读取的时候就记录"谁用过我",在数据被修改的时候再通知这些使用者。
一句话概括:
读取时收集依赖,修改时触发依赖。
2. Vue 2 如何追踪 Object 的变化?
在 Vue 2 中,对象属性的变化主要依靠 Object.defineProperty 实现。
它可以把一个普通属性改造成带有 getter 和 setter 的属性:
kotlin
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;当我们修改 data[key] 时,会触发 set。
但这还不够。
只知道属性被读取、被修改,并不能完成响应式。真正关键的是:
-
读取时,要知道是谁在读取;
-
修改时,要通知这些读取者更新。
这就引出了 Vue 响应式系统里的第一个核心角色:Dep。
3. Dep:每个属性自己的"订阅列表"
可以把 Dep 理解成一个订阅中心。
每个响应式属性都有自己的 Dep。
谁读取了这个属性,就把谁收集进来;这个属性变化时,再通知所有订阅者。
简化版代码如下:
javascript
class Dep {
constructor() {
this.subs = []
}
depend() {
if (Dep.target) {
this.subs.push(Dep.target)
}
}
notify() {
this.subs.forEach(sub => sub.update())
}
}
再把它放回 defineReactive:
kotlin
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()
}
})
}
这时逻辑就清楚了:
rust
get -> dep.depend() -> 收集依赖
set -> dep.notify() -> 通知依赖
但问题又来了:
Dep.target 是谁?
答案是:Watcher。
4. Watcher:真正需要被通知的人
Vue 不会直接把 DOM、模板或者用户回调塞进 Dep。
它会统一抽象成一个对象:Watcher。
你可以把 Watcher 理解成"响应式系统里的订阅者"。
比如:
javascript
vm.$watch('user.name', function(newVal, oldVal) {
console.log(newVal, oldVal)
})
这背后会创建一个 Watcher。这个 Watcher 的任务是:
-
读取
user.name; -
触发
user.name的 getter; -
在 getter 中把自己收集进
Dep; -
当
user.name变化时,执行自己的update; -
最后调用用户传入的回调函数。
简化后的 Watcher 可以这样理解:
kotlin
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 放到全局位置:
ini
Dep.target = this
然后再读取数据:
kotlin
this.getter(this.vm)
读取数据时会触发 getter,而 getter 里会执行:
scss
dep.depend()
于是当前这个 Watcher 就被收集到了对应属性的 Dep 里。
这就是 Vue 2 依赖收集的核心技巧:
Watcher 先把自己挂到全局目标上,再主动读取数据。数据的 getter 被触发后,就能反过来把这个 Watcher 收集起来。
5. parsePath:把字符串路径变成读取函数
当我们写:
bash
vm.$watch('a.b.c', callback)
Vue 需要根据字符串 'a.b.c' 读取到真正的值。
简化实现如下:
javascript
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
}
}
它做的事很简单:
css
'a.b.c' -> ['a', 'b', 'c']
然后一层一层读取:
ini
obj = obj['a']
obj = obj['b']
obj = obj['c']
注意,这个读取过程会连续触发每一层属性的 getter。
因此,Watcher 不只是订阅了最后的 c,它在读取链路上经过的属性也可能参与依赖收集。
6. Observer:把整个对象变成响应式对象
前面的 defineReactive 只能处理单个属性。
但 Vue 的 data 往往是一个完整对象:
javascript
data() {
return {
user: {
name: 'Tom',
age: 18
}
}
}
Vue 需要递归地把每个属性都变成响应式属性。
这就是 Observer 的作用。
javascript
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 里面还要递归处理子对象:
kotlin
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. 整个流程串起来
假设模板里用了:
css
<h1>{{ user.name }}</h1>
Vue 会为这个组件创建一个渲染 Watcher。
第一次渲染时,流程是:
objectivec
Watcher 开始渲染
↓
读取 user.name
↓
触发 user 和 name 的 getter
↓
getter 调用 dep.depend()
↓
当前 Watcher 被收集进 Dep
之后当我们执行:
ini
this.user.name = 'Jerry'
流程变成:
objectivec
触发 name 的 setter
↓
setter 调用 dep.notify()
↓
Dep 通知所有 Watcher
↓
Watcher.update()
↓
组件重新渲染
↓
生成新的虚拟 DOM
↓
diff 后更新真实 DOM
这就是 Vue 2 对象响应式的主链路。
8. 为什么 Vue 2 检测不到新增属性和删除属性?
Object.defineProperty 的能力有一个天然限制:
它只能拦截已经存在的属性。
比如初始化时是:
javascript
data() {
return {
user: {}
}
}
后面再写:
ini
this.user.name = 'Tom'
这个 name 是后来新增的。
初始化阶段 Vue 没有见过它,也就没有机会给它设置 getter/setter。
所以 Vue 2 无法自动侦测这种新增属性。
删除属性也一样:
arduino
delete this.user.name
delete 不会触发某个已经定义好的 setter,因此 Vue 2 也无法直接感知。
这就是为什么 Vue 2 需要提供:
scss
Vue.set()
Vue.delete()
或者实例方法:
kotlin
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。
几个核心角色分别是:
objectivec
Observer
负责遍历对象,把属性转换成 getter/setter。
defineReactive
负责处理单个属性,让它具备响应式能力。
Dep
负责保存依赖,相当于每个属性自己的订阅列表。
Watcher
是真正的订阅者。它读取数据时被收集,数据变化时被通知。
完整流程是:
objectivec
初始化 data
↓
Observer 遍历对象
↓
defineReactive 转换属性
↓
Watcher 读取数据
↓
getter 收集依赖
↓
数据被修改
↓
setter 触发通知
↓
Dep 通知 Watcher
↓
Watcher 执行更新
这就是 Vue 2 响应式系统的基本骨架。
理解了这条链路,再去看 Vue 源码里的 Observer、Dep、Watcher,就不会觉得它们是几个孤立的类,而是一套完整的订阅发布系统:
数据负责暴露变化,Dep 负责管理依赖,Watcher 负责响应变化,视图更新只是 Watcher 被触发后的结果。