深入解析Vue依赖收集原理
一、先谈观察者模式
观察者模式是一种实现一对多
关系解耦的行为设计模式。它主要涉及两个角色:观察目标、观察者。如图:
它的特点:观察者
要直接订阅观察目标
,观察目标
一做出通知,观察者
就要进行处理(这也是观察者模式
区别于发布/订阅模式
的最大区别)
解释: 有些地方说观察者模式和发布/订阅模式
是一样的,其实是不完全等同的,发布/订阅
模式中,其解耦能力更近一步,发布者
只要做好消息的发布,而不关心消息有没有订阅者
订阅。而观察者模式则要求两端同时存在
观察者模式,实现如下:
// 观察者集合
class ObserverList {
constructor() {
this.list = [];
}
add(obj) {
this.list.push(obj);
}
removeAt(index) {
this.list.splice(index, 1);
}
count() {
return this.list.length;
}
get(index) {
if (index < 0 || index >= this.count()) {
return;
}
return this.list[index];
}
indexOf(obj, start = 0) {
let pos = start;
while (pos < this.count()) {
if (this.list[pos] === obj) {
return pos;
}
pos++;
}
return -1;
}
}
// 观察者类
class Observer {
constructor(fn) {
this.update = fn;
}
}
// 观察目标类
class Subject {
constructor() {
this.observers = new ObserverList();
}
addObserver(observer) {
this.observers.add(observer);
}
removeObserver(observer) {
this.observers.removeAt(
this.observers.indexOf(observer)
);
}
notify(context) {
const count = this.observers.count();
for (let i = 0; i < count; ++i) {
this.observers.get(i).update(context);
}
}
}
现在,假设我们需要在数据A变更时,打印A的最新值,则用上述的代码实现如下:
const observer = new Observer((newval) => {
console.log(`A的最新值是${newval}`);
})
const subject = new Subject();
subject.addObserver(observer);
// 现在,做出A最新值改变的通知
> subject.notify('Hello, world');
// 控制台输出:
< 'Hello, world'
二、Vue与Vue的依赖收集
~~Vue是一个实现数据驱动视图的框架~~(废话,大家都知道,说重点) 我们都知道,Vue能够实现当一个数据变更时,视图就进行刷新,而且用到这个数据的其他地方也会同步变更;而且,这个数据必须是在有被依赖的情况下,视图和其他用到数据的地方才会变更。 所以,Vue要能够知道一个数据是否被使用,实现这种机制的技术叫做依赖收集
根据Vue官方文档的介绍,其原理如下图所示:

- 每个组件实例都有相应的watcher
实例 - 渲染组件的过程,会把属性记录为依赖 - 当我们操纵一个数据时,依赖项的setter
会被调用,从而通知watcher
重新计算,从而致使与之相关联的组件得以更新
那么,现在问题来了:~~挖掘机技术哪家强,……~~ 如果我们现在模板里用到了3个数据A、B、C,那么我们怎么处理A、B、C变更时能刷新视图呢? 这就要先考虑以下两个问题: 1、我们怎么知道模板里用到了哪些数据? 2、数据变更了,我们怎么告诉render()
函数?
那么很自然的,可以联想到有没有时机能够进行这么个处理,即: 1、既然模板渲染需要用到某个数据,那么一定会对这个数据进行访问,所以只要拦截getter,就有时机做出处理 2、在值变更的时候,也有setter可供拦截,那么拦截setter,也就能做出下一步动作。
所以在getter里,我们进行依赖收集(所谓依赖,就是这个组件所需要依赖到的数据),当依赖的数据被设置时,setter能获得这个通知,从而告诉render()
函数进行重新计算。
三、依赖收集与观察者模式
我们会发现,上述vue依赖收集的场景,正是一种一对多
的方式(一个数据变更了,多个用到这个数据的地方要能够做出处理),而且,依赖的数据变更了,就一定要做出处理,所以观察者模式
天然适用于解决依赖收集的问题。 那么,在Vue依赖收集里:谁是观察者?谁是观察目标? 显然: - 依赖的数据是观察目标
- 视图、计算属性、侦听器这些是观察者
和文章开头里观察者模式实现代码相对应的,做出notify
动作可以在setter
里进行,做出addObserver()
动作,则可以在getter
里进行。
四、从源码解析Vue的依赖收集
下面开始我们的源码解析之旅吧。这里主要阅读的是Vue2早期commit的版本,源码比较精简,适合用来掌握精髓。
1、角色
Vue源码中实现依赖收集,实现了三个类: - Dep
:扮演观察目标
的角色,每一个数据都会有Dep
类实例,它内部有个subs队列,subs就是subscribers的意思,保存着依赖本数据的观察者
,当本数据变更时,调用dep.notify()
通知观察者 - Watcher
:扮演观察者
的角色,进行观察者函数
的包装处理。如render()
函数,会被进行包装成一个Watcher
实例 - Observer
:辅助的可观测类
,数组/对象通过它的转化,可成为可观测数据
2、每一个数据都有的Dep
类实例
Dep类
实例依附于每个数据而出来,用来管理依赖数据的Watcher
类实例
let uid = 0;
class Dep {
static target = null; // 巧妙的设计!
constructor() {
this.id = uid++;
this.subs = [];
}
addSub(sub) {
this.subs.push(sub);
}
removeSub(sub) {
this.subs.$remove(sub);
}
depend() {
Dep.target.addDep(this);
}
notify() {
const subs = this.subs.slice();
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
}
}
由于JavaScript是单线程模型,所以虽然有多个观察者函数
,但是一个时刻内,就只会有一个观察者函数
在执行,那么此刻正在执行的那个观察者函数
,所对应的Watcher
实例,便会被赋给Dep.target这一类变量,从而只要访问Dep.target就能知道当前的观察者是谁。 在后续的依赖收集
工作里,getter
里会调用dep.depend()
,而setter
里则会调用dep.notify()
3、配置数据观测
上面我们说每一个数据都会有一个Dep类
的实例,具体是什么意思呢?在讲解数据观测之前,我们先给个具体的例子,表明处理前后的变化,如下所示的对象(即为options.data
):
{
a: 1,
b: [2, 3, 4],
c: {
d: 5
}
}
在配置完数据观测后,会变成这样子:
{
__ob__, // Observer类的实例,里面保存着Dep实例__ob__.dep => dep(uid:0)
a: 1, // 在闭包里存在dep(uid:1)
b: [2, 3, 4], // 在闭包里存在着dep(uid:2),还有b.__ob__.dep => dep(uid:4)
c: {
__ob__, // Observer类的实例,里面保存着Dep实例__ob__.dep => dep(uid:5)
d: 5 // 在闭包里存在着dep(uid:6)
}
}
我们会发现,新角色Observer类
登场啦,要说这个Observer类,那还得从生产每个组件
的Component类
的构造函数说起,在Component类
的构造函数里,会进行一个组件实例化前的一系列动作,其中与依赖收集
相关的源码如下:
this._ob = observe(options.data)
this._watchers = []
this._watcher = new Watcher(this, render, this._update)
this._update(this._watcher.value)
看到没有啊,observe(options.data)
,咦?不对,不是说好的Observer
吗?怎么是小写的observe
?~~怕不是拼夕夕上买的对象?~~ 别急,我们首先来看一下observe
函数里做了什么事情:
function observe (value, vm) {
if (!value || typeof value !== 'object') {
return
}
var ob
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (shouldConvert && (isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue) {
ob = new Observer(value)
}
if (ob && vm) {
ob.addVm(vm)
}
return ob
}
总结来说就是: 只为对象/数组 实例一个Observer
类的实例,而且就只会实例化一次,并且需要数据是可配置的时候才会实例化Observer
类实例。 那么,Observer
类又干嘛了呢?且看以下源码:
class Observer {
constructor(value) {
this.value = value
this.dep = new Dep()
def(value, '__ob__', this)
if (isArray(value)) {
var augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
} else {
this.walk(value)
}
}
walk(obj) {
var keys = Object.keys(obj)
for (var i = 0, l = keys.length; i < l; i++) {
this.convert(keys[i], obj[keys[i]])
}
}
observeArray(items) {
// 对数组每个元素进行处理
// 主要是处理数组元素中还有数组的情况
for (var i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
convert(key, val) {
defineReactive(this.value, key, val)
}
addVm(vm) {
(this.vms || (this.vms = [])).push(vm)
}
removeVm(vm) {
this.vms.$remove(vm)
}
}
总结起来,就是: - 将Observer类
的实例挂载在__ob__
属性上,提供后续观测数据使用,以及避免被重复实例化。然后,实例化Dep
类实例,并且将对象/数组
作为value属性保存下来 - 如果value是个对象,就执行walk()
过程,遍历对象把每一项数据都变为可观测数据(调用defineReactive
方法处理) - 如果value是个数组,就执行observeArray()
过程,递归地对数组元素调用observe()
,以便能够对元素还是数组的情况进行处理
4、如何观测数组?
访问对象属性,其取值与赋值操作,都能被Object.defineProperty()
成功拦截,但是Object.defineProperty()
在处理数组上却存在一些问题,下面我们通过例子来了解一下:
const data = {
arr: [1, 2, 3]
}
function defineReactive(obj, key, val) {
const property = Object.getOwnPropertyDescriptor(obj, key);
if (property && property.configurable === false) {
return;
}
const getter = property && property.get;
const setter = property && property.set;
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
console.log('取值过程被拦截了');
const value = getter ? getter.call(obj) : val;
return value;
},
set(newval) {
console.log(`新的值是${newval}`)
if (setter) {
setter.call(obj, newval);
} else {
val = newval;
}
}
})
}
defineReactive(data, 'arr', data.arr);
然后,我们进行一组测试,其结果如下:
data.arr; // 取值过程被拦截了
data.arr[0] = 1; // 取值过程被拦截了
data.arr.push(4); // 取值过程被拦截了
data.arr.pop(); // 取值过程被拦截了
data.arr.shift(); // 取值过程被拦截了
data.arr.unshift(5); // 取值过程被拦截了
data.arr.splice(0, 1); // 取值过程被拦截了
data.arr.sort((a, b) => a - b); // 取值过程被拦截了
data.arr.reverse(); // 取值过程被拦截了
data.arr = [4, 5, 6] // 新的值是4,5,6
可见,除了对arr重新赋值一个数组外,其他的操作都不会被setter
检测到。所以为了能检测到数组的变更操作,在传入的数据项是一个数组时,Vue会进行以下处理:
var augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
也就是对先对数组进行一个增强操作,这个增强操作呢,实际上是在数组的原型链上定义一系列操作方法,以此实现数组变更的检测,即定义一组原型方法在arr.__proto__
指向的那个原型对象上,如果浏览器不支持__proto__
,那么就直接挂载在数组对象本身上),最后再进行数组项的观测操作。 那么,增强操作又是怎么做到检测数组变更的呢?,那么就需要用到AOP的思想了,即保留原来操作的基础上,植入我们的特定的操作代码。 一个例子如下:
const arrayMethods = Object.create(Array.prototype);
// 形成:arrayMethods.__proto__ -> Array.prototype
const originalPush = arrayMethods.push;
Object.defineProperty(arrayMethods, 'push', {
configurable: true,
enumerable: false,
writable: true,
value(...args) {
const result = originalPush.apply(this, args);
console.log('对数组进行了push操作,加入了值:', args);
return result;
}
})
data.arr.__proto__ = arrayMethods
data.arr.push([5, 6], 7) // 对数组进行了push操作,加入了值:[5, 6], 7
所以,只要对每一个数组操作方法进行这么一个处理,那么我们也就有办法在数组变更时,通知观察者了。Vue具体的实现如下:
;[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.forEach(function (method) {
var original = arrayProto[method]
def(arrayMethods, method, function mutator () {
var i = arguments.length
var args = new Array(i)
while (i--) {
args[i] = arguments[i]
}
var result = original.apply(this, args)
var ob = this.__ob__
var inserted
switch (method) {
case 'push':
inserted = args
break
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
ob.dep.notify()
return result
})
})
思路仍然是一样的: - 保留数组原来的操作 - push
、unshift
、splice
这些方法,会带来新的数据元素,而新带来的数据元素,我们是有办法得知的(即为传入的参数) - 那么新增的元素也是需要被配置为可观测数据的,这样子后续数据的变更才能得以处理。所以要对新增的元素调用observer
实例上的observeArray
方法进行一遍观测处理 - 由于数组变更了,那么就需要通知观察者
,所以通过ob.dep.notify()
对数组的观察者
watchers进行通知
5、Watcher
Watcher
扮演的角色是观察者
,它关心数据,在数据变化后能够获得通知,并作出处理。一个组件里可以有多个Watcher类
实例,Watcher类
包装观察者函数
,而观察者函数
使用数据。 观察者函数
经过Watcher
是这么被包装的: - 模板渲染:this._watcher = new Watcher(this, render, this._update)
- 计算属性:
computed: {
name() {
return `${this.firstName} ${this.lastName}`;
}
}
/*
会形成
new Watcher(this, function name() {
return `${this.firstName} ${this.lastName}`
}, callback);
*/
在Watcher
类里做的事情,概括起来则是: 1、传入组件实例
、观察者函数
、回调函数
、选项
,然后我们先解释清楚4个变量:deps
、depIds
、newDeps
、newDepIds
,它们的作用如下: - deps
:缓存上一轮执行观察者函数
用到的dep实例 - depIds
:Hash表,用于快速查找 - newDeps
:存储本轮执行观察者函数
用到的dep实例 - newDepIds
:Hash表,用于快速查找
2、进行初始求值,初始求值时,会调用watcher.get()
方法 3、watcher.get()
会做以下处理:初始准备工作、调用观察者函数
计算、事后清理工作 4、在初始准备工作里,会将当前Watcher
实例赋给Dep.target
,清空数组newDeps
、newDepIds
5、执行观察者函数
,进行计算。由于数据观测阶段执行了defineReactive()
,所以计算过程用到的数据会得以访问,从而触发数据的getter
,从而执行watcher.addDep()
方法,将特定的数据
记为依赖 6、对每个数据执行watcher.addDep(dep)
后,数据对应的dep
如果在newDeps
里不存在,就会加入到newDeps
里,这是因为一次计算过程数据有可能被多次使用,但是同样的依赖只能收集一次。并且如果在deps
不存在,表示上一轮计算中,当前watcher未依赖过某个数据,那个数据相应的dep.subs
里也不存在当前watcher,所以要将当前watcher加入到数据的dep.subs
里 7、进行事后清理工作,首先释放Dep.target
,然后拿newDeps
和deps
进行对比,接着进行以下的处理: - newDeps
里不存在,deps
里存在的数据,表示是过期的缓存数据。相应的,从数据对应的dep.subs
移除掉当前watcher - 将newDeps
赋给deps
,表示缓存本轮的计算结果,这样子下轮计算如果再依赖同一个数据,就不需要再收集了
8、当某个数据
更新时,由于进行了setter拦截,所以会对该数据的dep.subs
这一观察者队列里的watchers进行通知,从而执行watcher.update()
方法,而update()
方法会重复求值过程(即为步骤3-7),从而使得观察者函数
重新计算,而render()
这种观察者函数重新计算的结果,就使得视图同步了最新的数据
6、defineReative
我们都知道,Vue实现数据劫持使用的是Object.defineProperty()
,而使用Object.defineProperty()
来拦截数据的操作,都封装在了defineReactive
里。接下来,我们来解析下defineReactive()
源码:
function defineReactive (obj, key, val) {
var dep = new Dep()
var property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
var getter = property && property.get
var setter = property && property.set
var childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
var value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
}
if (isArray(value)) {
for (var e, i = 0, l = value.length; i < l; i++) {
e = value[i]
e && e.__ob__ && e.__ob__.dep.depend()
}
}
}
return value
},
set: function reactiveSetter (newVal) {
var value = getter ? getter.call(obj) : val
if (newVal === value) {
return
}
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = observe(newVal)
dep.notify()
}
})
}
1、闭包的妙用:上述代码里Object.defineProperty()
里的get/set
方法相对于var dep = new Dep()
形成了闭包,从而很巧妙地保存了dep
实例 2、getter
里进行的是依赖的收集工作。如果某个观察者函数访问了某个数据,我们就可以把这个观察者函数认为是依赖这个数据的,所以举个具体的例子:data.a
,在以下地方被使用:
<template>
<div>{{a}}</div>
</template>
computed: {
newValue() {
return this.a + 1;
}
}
那么,template被编译后,会形成AST,在执行render()
函数过程中就会触发data.a的getter
,并且这个过程是惰性收集
的(如newValue
虽然用到 了a,但如果它没有被调用执行,就不会触发getter
,也就不会被添加到data.a
的dep.subs
里) 现在,假设template变成了这样子:
<template>
<div>I am {{a}},plus 1 is {{newValue}}</div>
</template>
那么,可以看到就对应了两个观察者函数
:计算属性newValue
和render()
函数,它们会被包装为两个watcher。 在执行render()
函数渲染的过程中,访问了data.a
,从而使得data.a
的dep.subs
里加入了render@watcher
又访问了计算属性newValue,计算属性里访问了data.a
,使得data.a
的dep.subs
里加入了newValue@watcher
。所以data.a
的dep.subs
里就有了[render@watcher, newValue@watcher
] 为什么访问特定数据就使能让数据的deps.subs
里加入了watcher呢? 这是因为,在访问getter
之前,就已经进入了某个watcher的上下文了,所以有一件事情是可以保证的:Watcher类的实例watcher已经准备好了,并且已经调用了watcher.get()
,Dep.target
是有值的 所以,我们看到getter
里进行依赖收集的写法是dep.depend()
,并没有传入什么参数,这是因为,我们只需要把Dep.target
加入当前dep.subs
里就好了。 但是我们又发现,Dep.prototype.depend()
的实现是:
depend() {
Dep.target.addDep(this);
}
为什么depend()
的时候,不直接把Dep.target
加入dep.subs
,而是调用了Dep.target.addDep
呢? 这是因为,我们不能无脑地直接把当前watcher塞入dep.subs
里,我们要保证dep.subs
里的每个watcher
都是唯一的。 Dep.target
是Watcher类
实例,调用dep.depend()
相当于调用了watcher.addDep
方法,所以我们再来看一下这个方法里做了什么事情:
Watcher.prototype.addDep = function (dep) {
var id = dep.id
if (!this.newDepIds[id]) {
this.newDepIds[id] = true
this.newDeps.push(dep)
if (!this.depIds[id]) {
dep.addSub(this)
}
}
}
概括起来就是:判断本轮计算中是否收集过这个依赖,收集过就不再收集,没有收集过就加入newDeps
。同时,判断有无缓存过依赖,缓存过就不再加入到dep.subs
里了。
3、setter
里进行的,则是在值变更后,通知watcher
进行重新计算。由于setter
能访问到闭包中dep
,所以就能获得dep.subs
,从而知道有哪些watcher依赖于当前数据,如果自己的值变化了,通过调用dep.notify()
,来遍历dep.subs
里的watcher,执行每个watcher
的update()
方法,让每个watcher进行重新计算。
7、困惑点解析
回到开头的例子,我们说举例的option.data
被观测之后,变成了:
{
__ob__, // dep(uid:0)
a: 1, // dep(uid:1)
b: [2, 3, 4], // dep(uid:2), b.__ob__.dep(uid:3)
c: {
__ob__, // dep(uid:4), c.__ob__.dep(uid:5)
d: 5 // dep(uid:6)
}
}
我们不禁好奇,为什么对于数组
和对象
,配置依赖观测后,会实例化两个Dep类
实例呢? 这是因为:数组
和对象
,都是引用类型数据,对于引用类型数据,存在两种操作:改变引用
和改变内容
,即为:
data.b = [4, 5, 6]; // 改变引用
data.b.push(7); // 改变内容
而其实,改变引用
这种情况,我们前面在说到Object.defineProperty()
的限制时说过,是可以被检测到的,所以闭包
里的dep
可以收集这种依赖。而改变内容
,却没办法通过Object.defineProperty()
检测到,所以对数组变异操作进行了封装,所以就需要在数组上挂在__ob__
属性,在__ob__
上挂载dep
实例,用来处理改变内容
的情况,以便能够形成追踪链路。
三、总结
总结而言,Vue的依赖收集,是观察者
模式的一种应用。其原理总结如图:

1、配置依赖观测

2、收集依赖

3、数据值变更

转载自:https://juejin.cn/post/6844903702881386504