Vue 依赖收集原理分析
Vue实例在初始化时,可以接受以下几类数据:
- 模板
- 初始化数据
- 传递给组件的属性值
- computed
- watch
- methods
Vue 根据实例化时接受的数据,在将数据和模板转化成DOM节点的同时,分析其依赖的数据。在特定数据改变时,自动在下一个周期重新渲染DOM节点
本文主要分析Vue是如何进行依赖收集的。
Vue中,与依赖收集相关的类有:
Dep : 一个订阅者的列表类,可以增加或删除订阅者,可以向订阅者发送消息
Watcher : 订阅者类。它在初始化时可以接受getter
, callback
两个函数作为参数。getter
用来计算Watcher对象的值。当Watcher被触发时,会重新通过getter
计算当前Watcher的值,如果值改变,则会执行callback
.
对初始化数据的处理
对于一个Vue组件,需要一个初始化数据的生成函数。如下:
export default {
data () {
return {
text: 'some texts',
arr: [],
obj: {}
}
}
}
Vue为数据中的每一个key维护一个订阅者列表。对于生成的数据,通过Object.defineProperty
对其中的每一个key进行处理,主要是为每一个key设置get
, set
方法,以此来为对应的key收集订阅者,并在值改变时通知对应的订阅者。部分代码如下:
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set
let childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
}
if (Array.isArray(value)) {
dependArray(value)
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = observe(newVal)
dep.notify()
}
})
每一key都有一个订阅者列表
const dep = new Dep()
在为key进行赋值时,如果值发生了改变,则会通知所有的订阅者
dep.notify()
在对key进行取值时,如果Dep.target
有值,除正常的取值操作外会进行一些额外的操作来添加订阅者。大多数时间里,Dep.target
的值都为null
,只有订阅者在进行订阅操作时,Dep.target
才有值,为正在进行订阅的订阅者。此时进行取值操作,会将订阅者加入到对应的订阅者列表中。
订阅者在进行订阅操作时,主要包含以下3个步骤:
- 将自己放在
Dep.target
上 - 对自己依赖的key进行取值
- 将自己从
Dep.target
移除
在执行订阅操作后,订阅者会被加入到相关key的订阅者列表中。
针对对象和数组的处理
如果为key赋的值为对象:
- 会递归地对这个对象中的每一key进行处理
如果为key赋的值为数组:
- 递归地对这个数组中的每一个对象进行处理
- 重新定义数组的
push
,pop
,shift
,unshift
,splice
,sort
,reverse
方法,调用以上方法时key的订阅者列表会通知订阅者们“值已改变”。如果调用的是push
,unshift
,splice
方法,递归处理新增加的项
对模板的处理
Vue将模板处理成一个render
函数。需要重新渲染DOM时,render
函数结合Vue实例中的数据生成一个虚拟节点。新的虚拟节点和原虚拟节点进行对比,对需要修改的DOM节点进行修改。
订阅者
订阅者在初始化时主要接受2个参数getter
, callback
。getter
用来计算订阅者的值,所以其在执行时会对订阅者所有需要订阅的key进行取值。订阅者的订阅操作主要是通过getter
来实现。
部分代码如下:
/**
* Evaluate the getter, and re-collect dependencies.
*/
get () {
pushTarget(this)
let value
const vm = this.vm
if (this.user) {
try {
value = this.getter.call(vm, vm)
} catch (e) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
}
} else {
value = this.getter.call(vm, vm)
}
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
return value
}
主要步骤:
- 将自己放在
Dep.target
上(pushTarget(this)
) - 执行
getter
(this.getter.call(vm, vm)
) - 将自己从
Dep.target
移除(popTarget()
) - 清理之前的订阅(
this.cleanupDeps()
)
此后,订阅者在依赖的key的值发生变化会得到通知。获得通知的订阅者并不会立即被触发,而是会被加入到一个待触发的数组中,在下一个周期统一被触发。
订阅者在被触发时,会执行getter
来计算订阅者的值,如果值改变,则会执行callback
.
负责渲染DOM的订阅者
Vue实例化后都会生成一个用于渲染DOM的订阅者。此订阅者在实例化时传入的getter
方法为渲染DOM的方法。
部分代码如下:
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
vm._watcher = new Watcher(vm, updateComponent, noop)
vm._render()
结合模板和数据,计算出虚拟DOM
vm._update()
根据虚拟DOM渲染真实的DOM节点
此订阅者在初始化时就会进行订阅操作。实例化时传入的getter
为updateComponent
。其中的vm._render()
在执行时一定会对所有依赖的key进行取值,能完成对依赖的key的订阅。同时vm._update()
完成了第一次DOM渲染。当前依赖的key的值发生变化,订阅者被触发时,作为getter
的updateComponent
会重新执行,重新渲染DOM。因为getter
返回的值一直为undefined
,所以此订阅者中的callback
并没有被用到,于是传入了一个空函数noop
作为callback
对computed的处理
通过computed可以定义一组计算属性,通过计算属性可以将一些复杂的计算过程抽离出来,保持模板的简单和清晰。
代码示例:
export default {
data () {
return {
text: 'some texts',
arr: [],
obj: {}
}
},
computed: {
key1: function () {
return this.text + this.arr.length
}
}
}
在定义一个计算属性时,需要定义一个key和一个计算方法。
Vue在对computed进行处理时,会为每一个计算属性生成一个lazy状态的订阅者。普通的订阅者在实例化和触发时会执行getter
来计算自身的值和进行订阅操作。而lazy状态的订阅者在上述情况下只会将自身置为dirty状态,不进行其它操作。在订阅者执行自身的evaluate
方法时,会清除自身的dirty状态并执行getter
来计算自身的值和进行订阅。
Vue在为计算属性生成订阅者时的示例代码如下:
const computedWatcherOptions = { lazy: true }
// create internal watcher for the computed property.
watchers[key] = new Watcher(vm, getter, noop, computedWatcherOptions)
传入的getter
为自定义的计算方法,callback
为空函数。(lazy状态的订阅者永远都没有机会执行callback
)
Vue 在自身实例上为指定key定义get
方法,使可以通过Vue实例获取计算属性的值。
部分代码如下:
function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
}
}
在对计算属性定义的key进行取值时,会首先获取之前生成好的订阅者。只有订阅者处于dirty状态时,才会执行evaluate
计算订阅者的值。所以为计算属性定义的计算方法只有在对计算属性的key进行取值并且计算属性依赖的key曾经改变时才会执行。
假如对上文定义的计算属性key1
进行取值
vm.key1; //第一次取值,自定义计算方法执行
vm.key1; //第二次取值,依赖的key的值没有变化,自定义计算方法不会执行
vm.text = '' //改变计算属性依赖的key的值,计算属性对应的订阅者会进入dirty状态,自定义计算方法不会执行
vm.key1; //第三次取值,计算属性依赖的key的值发生了变化并且对计算属性进行取值,自定义的计算方法执行
订阅计算属性值的变化
计算属性的key不会维护一个订阅者列表,也不能通过计算属性的set
方法在触发所有订阅者。(计算属性不能被赋值)。一个订阅者执行订阅操作来订阅计算属性值的变化其实是订阅了计算属性依赖的key的值的变化。
在计算属性的get
方法中
if (Dep.target) {
watcher.depend()
}
如果有订阅者来订阅计算属性的变化,计算属性会将自己的订阅复制到正在进行订阅的订阅者上。watcher.depend()
的作用就是如此。
例如:
//初始化订阅者watcher, 依赖计算属性key1
var watcher = new Watcher(function () {
return vm.key1
}, noop)
vm.text = '' //计算属性key1依赖的text的值发生变化,watcher会被触发
对watch的处理
Vue实例化时可以传入watch对象,来监听某些值的变化。
例如:
export default {
watch: {
'a.b.c': function (val, oldVal) {
console.log(val)
console.log(oldVal)
}
}
}
Vue 会为watch中的每一项生成一个订阅者。订阅者的getter
通过处理字符串得到。如'a.b.c'会被处理成
function (vm) {
var a = vm.a
var b = a.b
var c = b.c
return c
}
处理字符串的源码如下:
/**
* Parse simple path.
*/
const bailRE = /[^\w.$]/
export function parsePath (path: string): any {
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
订阅者的callback
为定义watch时传入的监听函数。当订阅者被触发时,如果订阅者的值发生变化,则会执行callback
。callback
执行时会传入变化后的值,变化前的值作为参数。