深入解析 Vue 依赖收集原理
原文以观察者模式为入口,分析 Vue 2 源码中的
Dep、Watcher、Observer、数组观测和defineReactive。本文尽量保留这条源码学习路线,清理抓取造成的代码拼接,并补充 Vue 3.5 的ReactiveEffect、Proxy、集合类型和调度器。示例代码是教学版,不是完整框架源码。
一、先谈观察者模式
观察者模式用于建立“一对多”的关系:一个观察目标发生变化时,通知多个观察者。
class Subject {
constructor() {
this.observers = new Set()
}
addObserver(observer) {
this.observers.add(observer)
return () => this.observers.delete(observer)
}
notify(value) {
for (const observer of [...this.observers]) {
observer.update(value)
}
}
}
class Observer {
constructor(update) {
this.update = update
}
}
const subject = new Subject()
subject.addObserver(new Observer(value => {
console.log(`A 的最新值是:${value}`)
}))
subject.notify('Hello, world')
观察者通常知道自己订阅的是哪个 Subject;发布/订阅模式则经常通过事件总线或主题名解耦发布者和订阅者。两者在工程实践中会有重叠,不能只凭“有通知”就绝对区分。
Vue 的依赖收集和观察者模式有相似之处:一个响应式属性可能被渲染 effect、计算属性、侦听器等多个消费者使用;属性变化后,需要通知这些消费者。但 Vue 还要自动发现依赖、清理过期依赖、去重并通过 scheduler 批量更新,因此不只是简单的 observers.forEach()。
二、依赖收集到底要解决什么问题
下面这个模板使用了 A、B、C:
<template>
<p>{{ A }}</p>
<p>{{ B + C }}</p>
</template>
框架需要知道:
- 这次渲染实际读取了哪些响应式属性?
A改变时,应该通知哪些渲染或计算任务?- 下一次条件分支改变后,如何移除已经不再使用的依赖?
- 同一个属性被读取多次时,如何避免重复订阅?
- 多个属性在同一轮事件中改变时,如何只调度一次组件更新?
最自然的时机是:
- getter/Proxy get:读取数据时收集当前正在运行的依赖;
- setter/Proxy set:数据写入时通知已经收集的依赖;
- effect 执行前后:设置当前依赖、完成清理并恢复上层依赖上下文。

三、Vue 2 的三个核心角色
本文原始源码主要是 Vue 2 早期实现;Vue 2.7 的源码仍然保留这套核心概念。
1. Observer
Observer 负责把普通对象/数组转换为可观测数据:
- 对对象的已有属性调用
defineReactive; - 给被观测对象附加
__ob__,保存 observer 和对象/数组级别的dep; - 递归观测嵌套对象和数组元素;
- 为数组替换七个变异方法。
2. Dep
Dep 是一个依赖容器,每个响应式属性通常有一个闭包中的 Dep,对象和数组 observer 还拥有自己的 dep。它负责:
depend():让当前 watcher 订阅自己;addSub()/removeSub():管理订阅者;notify():通知订阅的 watcher 调度更新。
3. Watcher
Watcher 包装一个 getter 或观察函数:
- 渲染 watcher 执行 render 并更新组件;
- 计算属性 watcher 通常是 lazy 的;
- 用户 watcher 执行
watch回调; - watcher 重新执行 getter 时会重新收集依赖,并清理过期依赖。
四、Vue 2 的 Dep 与当前目标
Vue 2.7 源码中的简化版 Dep 如下:
let uid = 0
class Dep {
static target = null
constructor() {
this.id = uid++
this.subs = []
}
addSub(watcher) {
this.subs.push(watcher)
}
removeSub(watcher) {
const index = this.subs.indexOf(watcher)
if (index > -1) this.subs.splice(index, 1)
}
depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify() {
const subs = this.subs.slice()
for (const watcher of subs) {
watcher.update()
}
}
}
原文中的 Dep.target.addDep(this) 很关键:dep 不直接无条件把 watcher 塞进数组,而是交给 watcher 通过 id 去重。真实 Vue 2 源码还会使用 target stack:
const targetStack = []
function pushTarget(target) {
targetStack.push(Dep.target)
Dep.target = target
}
function popTarget() {
Dep.target = targetStack.pop() || null
}
只用一个简单的全局变量,在计算属性嵌套渲染或 watcher getter 嵌套时容易丢失外层依赖;源码的栈结构就是为恢复上下文服务的。
五、Watcher 如何收集和清理依赖
class Watcher {
constructor(getter, callback, { lazy = false } = {}) {
this.getter = getter
this.callback = callback
this.lazy = lazy
this.dirty = lazy
this.value = lazy ? undefined : this.get()
this.deps = []
this.depIds = new Set()
this.newDeps = []
this.newDepIds = new Set()
}
get() {
pushTarget(this)
let value
try {
value = this.getter()
} finally {
popTarget()
this.cleanupDeps()
}
return value
}
addDep(dep) {
if (this.newDepIds.has(dep.id)) return
this.newDepIds.add(dep.id)
this.newDeps.push(dep)
if (!this.depIds.has(dep.id)) {
dep.addSub(this)
}
}
cleanupDeps() {
for (const dep of this.deps) {
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
this.deps = this.newDeps
this.depIds = this.newDepIds
this.newDeps = []
this.newDepIds = new Set()
}
update() {
if (this.lazy) {
this.dirty = true
return
}
queueWatcher(this)
}
run() {
const oldValue = this.value
const newValue = this.get()
this.value = newValue
this.callback?.(newValue, oldValue)
}
}
真实实现还会支持 deep、sync、用户回调错误处理、effect scope、watcher teardown 和 scheduler 去重。上面最值得记住的是两组依赖集合:
deps/depIds:上一轮 getter 使用过的依赖;newDeps/newDepIds:本轮 getter 使用到的依赖。
当条件分支变化时,只有使用两组集合比较,才能把不再读取的依赖从 dep.subs 移除。
六、观测对象和数组
1. defineReactive
function defineReactive(object, key, initialValue) {
const dep = new Dep()
let value = initialValue
let childOb = observe(value)
Object.defineProperty(object, key, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) {
dep.depend()
// 让访问对象/数组本身的 watcher 也能收到内部变化通知。
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
set(nextValue) {
if (Object.is(nextValue, value)) return
value = nextValue
childOb = observe(nextValue)
dep.notify()
},
})
}
这里有两个容易混淆的 Dep:
- 属性闭包中的
dep:处理object.key = value; - 对象/数组
__ob__.dep:处理对象整体、数组变异方法、Vue 2$set/删除等场景。
dependArray 会递归访问数组元素上的 __ob__.dep,因为 Vue 2 无法为每个数组下标安装普通属性 getter:
function dependArray(array) {
for (const item of array) {
if (item?.__ob__) item.__ob__.dep.depend()
if (Array.isArray(item)) dependArray(item)
}
}
2. Vue 2 数组的七个变异方法
const arrayMethods = [
'push', 'pop', 'shift', 'unshift',
'splice', 'sort', 'reverse',
]
for (const method of arrayMethods) {
const original = Array.prototype[method]
Object.defineProperty(observedArray, method, {
value(...args) {
const result = original.apply(this, args)
if (method === 'push' || method === 'unshift') {
this.__ob__.observeArray(args)
} else if (method === 'splice') {
this.__ob__.observeArray(args.slice(2))
}
this.__ob__.dep.notify()
return result
},
})
}
这段代码只是示意。Vue 2 真实实现会把方法挂在增强原型或数组实例上,并兼容不支持 __proto__ 的环境。
下面这些限制是 Vue 2 的经典检测缺陷:
// Vue 2 中不能直接可靠触发更新
vm.items[1] = 'x'
vm.items.length = 1
vm.user.newProperty = 1
// Vue 2 的替代方式
vm.$set(vm.items, 1, 'x')
vm.items.splice(1, 1, 'x')
vm.items.splice(1)
vm.$set(vm.user, 'newProperty', 1)

七、从数据读取到视图更新的完整链路
以模板中的 {{ message }} 为例:
初次渲染
- Vue 创建组件渲染 watcher;
- watcher 执行 render,并设置
Dep.target; - render 读取
message,触发message的 getter; - getter 调用
dep.depend(); - watcher 把 dep 放进自己的依赖集合,dep 把 watcher 放进订阅列表;
- render 返回 vnode,Vue 完成首次 patch;
- watcher 清理本轮与上一轮依赖的差异。
数据变化
message setter
↓
dep.notify()
↓
render watcher.update()
↓
queueWatcher 去重
↓
异步 flush
↓
重新执行 render
↓
patch old vnode/new vnode
因此“setter 调用了 notify”并不代表 render() 在 setter 内立即同步执行。Vue 2 的默认 scheduler 会批处理 watcher;读取更新后的 DOM 应等待 $nextTick。
八、计算属性为什么也能参与依赖收集
计算属性通常由一个 lazy watcher 表示:
const fullName = computed(() => {
return `${firstName} ${lastName}`
})
在 Vue 2 的概念模型中:
- 计算属性第一次访问时执行 getter,收集
firstName/lastName; - 依赖变化时,计算 watcher 被标记为 dirty,而不是立刻无条件计算;
- 模板再次访问计算属性时,watcher 重新求值;
- 如果渲染 watcher 依赖了这个计算属性,计算 watcher 还需要把自己的依赖传递给外层 watcher(
depend())。
这也是为什么“某个 computed 用到了 a”不代表 a 永远被所有组件依赖:只有计算属性被实际执行并被外层使用时,依赖链才会建立。
九、Vue 3 的依赖收集:Proxy 与 ReactiveEffect
Vue 3 的官方概念模型通常表示为:
WeakMap<target, Map<key, Dep>>
let activeEffect
const targetMap = new WeakMap()
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
dep.add(activeEffect)
}
function trigger(target, key) {
targetMap.get(target)?.get(key)?.forEach(effect => {
if (effect.scheduler) effect.scheduler()
else effect.run()
})
}
function reactive(target) {
return new Proxy(target, {
get(object, key, receiver) {
track(object, key)
return Reflect.get(object, key, receiver)
},
set(object, key, value, receiver) {
const oldValue = object[key]
const result = Reflect.set(object, key, value, receiver)
if (!Object.is(oldValue, value)) trigger(object, key)
return result
},
deleteProperty(object, key) {
const hadKey = Object.prototype.hasOwnProperty.call(object, key)
const result = Reflect.deleteProperty(object, key)
if (hadKey && result) trigger(object, key)
return result
},
})
}
上述是语义模型,不是 Vue 3.5 的完整内部结构。当前 Vue 3 的 effect/dep 实现已经使用版本计数和双向链接等优化,但依赖收集的外部行为仍然可以用“读取 track、写入 trigger、effect 订阅 dep”理解。
1. Proxy 能处理更多操作
Vue 3 的基础 handler 可以追踪:
- 对象属性读取、设置、删除;
- 数组下标和
length相关变化; in和 key 迭代;- 新增属性和删除属性。
Map、Set、WeakMap、WeakSet 使用专门的 collection handlers,不能把它们简单等同于普通对象属性:
const cache = reactive(new Map())
cache.set('user', { id: 1 })
cache.delete('user')
cache.clear()
这些操作会按照集合的 key、size 和迭代依赖触发不同的 effect。
2. ref 依赖的是 .value
const count = ref(0)
watchEffect(() => {
console.log(count.value)
})
count.value++
ref 本身可以看作一个带 .value getter/setter 的对象。Vue 3 不是“完全不使用 getter/setter”,更准确地说是:reactive 对象使用 Proxy,ref 使用访问器属性。
十、观察者模式与 Vue 3 effect 的关系
Vue 2 文章常用 Dep.target 和 Watcher 解释依赖收集;Vue 3 使用 ReactiveEffect、scheduler 和 effect scope。两者的语义对应关系可以这样理解:
| 概念 | Vue 2 | Vue 3 |
|---|---|---|
| 当前执行任务 | Dep.target | active effect |
| 依赖容器 | Dep | Dep/依赖链接 |
| 任务 | Watcher | ReactiveEffect |
| 读取时收集 | getter → depend | Proxy/ref → track |
| 写入时通知 | setter/array → notify | Proxy/ref → trigger |
| 调度 | watcher queue | scheduler/job queue |
| 作用域清理 | teardown | effect scope/unmount cleanup |
这个表是概念映射,不表示 Vue 3 内部类名与 Vue 2 完全一一对应。
十一、依赖清理与条件分支
依赖收集不是只增加不删除。假设:
const state = reactive({ enabled: true, a: 1, b: 2 })
watchEffect(() => {
console.log(state.enabled ? state.a : state.b)
})
第一次运行订阅 enabled 和 a;当 enabled 变成 false 并重新运行后,effect 应该取消对 a 的订阅,改为订阅 b。否则以后修改 a 也会无意义地触发这个 effect,甚至导致逻辑错误。
Vue 2 的 newDeps/depIds,以及 Vue 3 effect 的依赖链接和清理机制,都是为此服务的。
十二、调试依赖收集
Vue 3 开发环境提供渲染调试钩子:
<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'
onRenderTracked(event => {
debugger
console.log('本次渲染追踪:', event)
})
onRenderTriggered(event => {
debugger
console.log('本次渲染触发:', event)
})
</script>
watch、watchEffect 和 computed 也支持 onTrack/onTrigger 调试回调。它们只用于开发调试,不应当依赖调试事件实现业务逻辑。



总结
- 依赖收集的核心是:effect 执行时读取数据,读取操作记录当前 effect;
- 数据变化后,依赖容器通知相关 effect,但实际视图更新通常还要经过 scheduler;
- Vue 2 使用 getter/setter、
Dep、Watcher、Observer和数组七个变异方法; - Vue 2 的对象新增属性、数组下标和 length 有检测限制,需要
$set、splice等方式; - Vue 3 的
reactive使用 Proxy,ref使用.valuegetter/setter,集合类型有专门 handler; - 依赖需要去重,也需要在条件分支变化后清理,否则会产生过度更新;
- 官方文档中的 Set/WeakMap 图是理解模型,Vue 3.5 的底层实现已经进一步优化。
参考资料
原文作者:一只小考拉。原文关于观察者模式、Dep、Watcher、数组观测、defineReactive 和依赖链路的内容予以保留;错误的 Markdown 代码围栏、拼接成一行的源码、Dep.target 无嵌套栈、Vue 3 仍等同于 Vue 2 以及抓取推广噪声已整理或修正。