三、vue依赖收集
Vue 会把普通对象变成响应式对象,响应式对象 getter 相关的逻辑就是做依赖收集,这一节我们来详细分析这个过程
Dep
Dep 是整个 getter 依赖收集的核心,它的定义在 src/core/observer/dep.js 中
import type Watcher from './watcher'
import { remove } from '../util/index'
let uid = 0
/**
* A dep is an observable that can have multiple
* directives subscribing to it.
*/
export default class Dep {
// 一个静态属性 target,这是一个全局唯一 Watcher
// 同一时间只能有一个全局的 Watcher 被计算
static target: ?Watcher;
id: number;
// 自身属性 subs 也是 Watcher 的数组
subs: Array<Watcher>;
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
// stabilize the subscriber list first
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
// the current target watcher being evaluated.
// this is globally unique because there could be only one
// watcher being evaluated at any time.
Dep.target = null
const targetStack = []
export function pushTarget (_target: ?Watcher) {
if (Dep.target) targetStack.push(Dep.target)
Dep.target = _target
}
export function popTarget () {
Dep.target = targetStack.pop()
}
使用 Object.defineProperty 函数定义访问器属性
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
// 省略...
},
set: function reactiveSetter (newVal) {
// 省略...
})
当执行完以上代码实际上 defineReactive 函数就执行完毕了,对于访问器属性的 get 和 set 函数是不会执行的,因为此时没有触发属性的读取和设置操作。
当属性被读取的时候都做了哪些事情,get 函数
get 函数做了两件事:正确地返回属性值以及收集依赖
get: function reactiveGetter () {
// 正确地返回属性值,有getter就使用,没有放val
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
}
首先判断 Dep.target 是否存在,那么 Dep.target 是什么呢?其实 Dep.target 与我们在 数据响应系统基本思路 一节中所讲的 Target 作用相同,所以 Dep.target 中保存的值就是要被收集的依赖(观察者)。所以如果 Dep.target 存在的话说明有依赖需要被收集,这个时候才需要执行 if 语句块内的代码,如果 Dep.target 不存在就意味着没有需要被收集的依赖,所以当然就不需要执行 if 语句块内的代码了。
在 if 语句块内第一句执行的代码就是:dep.depend(),执行 dep 对象的 depend 方法将依赖收集到 dep 中,这里的 dep 对象就是属性的 getter/setter 通过闭包引用的“筐”。
接着又判断了 childOb 是否存在,如果存在那么就执行 childOb.dep.depend(),这段代码是什么意思呢?要想搞清楚这段代码的作用,你需要知道 childOb 是什么,前面我们分析过,假设有如下数据对象:
const data = {
a: {
b: 1
}
}
该数据对象经过观测处理之后,将被添加 __ob__ 属性,如下:
const data = {
a: {
b: 1,
__ob__: {value, dep, vmCount}
},
__ob__: {value, dep, vmCount}
}
对于属性 a 来讲,访问器属性 a 的 setter/getter 通过闭包引用了一个 Dep 实例对象,即属性 a 用来收集依赖的“筐”。除此之外访问器属性 a 的 setter/getter 还通过闭包引用着 childOb,且 childOb === data.a.ob 所以 childOb.dep === data.a.ob.dep。也就是说 childOb.dep.depend() 这句话的执行说明除了要将依赖收集到属性 a 自己的“筐”里之外,还要将同样的依赖收集到 data.a.ob.dep 这里”筐“里,为什么要将同样的依赖分别收集到这两个不同的”筐“里呢?其实答案就在于这两个”筐“里收集的依赖的触发时机是不同的,即作用不同,两个”筐“如下:
- 第一个”筐“是 dep
- 第二个”筐“是 childOb.dep
第一个”筐“里收集的依赖的触发时机是当属性值被修改时触发,即在 set 函数中触发:dep.notify()。而第二个”筐“里收集的依赖的触发时机是在使用 $set 或 Vue.set 给数据对象添加新属性时触发,我们知道由于 js 语言的限制,在没有 Proxy 之前 Vue 没办法拦截到给对象添加属性的操作。所以 Vue 才提供了 $set 和 Vue.set 等方法让我们有能力给对象添加新属性的同时触发依赖,那么触发依赖是怎么做到的呢?就是通过数据对象的 ob 属性做到的。因为 ob.dep 这个”筐“里收集了与 dep 这个”筐“同样的依赖。假设 Vue.set 函数代码如下:
Vue.set = function (obj, key, val) {
defineReactive(obj, key, val)
obj.__ob__.dep.notify()
}
当我们使用上面的代码给 data.a 对象添加新的属性:
Vue.set(data.a, 'c', 1)
上面的代码之所以能够触发依赖,就是因为 Vue.set 函数中触发了收集在 data.a.ob.dep 这个”筐“中的依赖:
Vue.set = function (obj, key, val) {
defineReactive(obj, key, val)
obj.__ob__.dep.notify() // 相当于 data.a.__ob__.dep.notify()
}
Vue.set(data.a, 'c', 1)
所以 ob 属性以及 ob.dep 的主要作用是为了添加、删除属性时有能力触发依赖,而这就是 Vue.set 或 Vue.delete 的原理
在 childOb.dep.depend() 这句话的下面还有一个 if 条件语句,如下:
if (Array.isArray(value)) {
dependArray(value)
}
如果读取的属性值是数组,那么需要调用 dependArray 函数逐个触发数组每个元素的依赖收集,为什么这么做呢?那是因为 Observer 类在定义响应式属性时对于纯对象和数组的处理方式是不同,对于上面这段 if 语句的目的等到我们讲解到对于数组的处理时,会详细说明。
渲染函数的观察者 render watcher
无论是完整版 Vue 的 $mount 函数还是运行时版 Vue 的 $mount 函数,他们最终都将通过 mountComponent 函数去真正的挂载组件,接下来我们就看一看在 mountComponent 函数中发生了什么,打开 src/core/instance/lifecycle.js 文件找到 mountComponent 如下:
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
// 省略...
}
继续查看 mountComponent 函数的代码,接下来是一段 if 语句块:
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode
if (process.env.NODE_ENV !== 'production') {
/* istanbul ignore if */
if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
vm.$options.el || el) {
warn(
'You are using the runtime-only build of Vue where the template ' +
'compiler is not available. Either pre-compile the templates into ' +
'render functions, or use the compiler-included build.',
vm
)
} else {
warn(
'Failed to mount component: template or render function not defined.',
vm
)
}
}
}
这段 if 条件语句块首先检查渲染函数是否存在,即 vm.$options.render 是否为真,如果不为真说明渲染函数不存在,这时将会执行 if 语句块内的代码,在 if 语句块内首先将 vm.$options.render 的值设置为 createEmptyVNode 函数,也就是说此时渲染函数的作用将仅仅渲染一个空的 vnode 对象,然后在非生产环境下会根据相应的情况打印警告信息。
在上面这段 if 语句块的下面,执行了 callHook 函数,触发 beforeMount 生命周期钩子:
callHook(vm, 'beforeMount')
在触发 beforeMount 生命周期钩子之后,组件将开始挂载工作,首先是如下这段代码:
let updateComponent
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
updateComponent = () => {
const name = vm._name
const id = vm._uid
const startTag = `vue-perf-start:${id}`
const endTag = `vue-perf-end:${id}`
mark(startTag)
const vnode = vm._render()
mark(endTag)
measure(`vue ${name} render`, startTag, endTag)
mark(startTag)
vm._update(vnode, hydrating)
mark(endTag)
measure(`vue ${name} patch`, startTag, endTag)
}
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
这段代码的作用只有一个,即定义并初始化 updateComponent 函数,这个函数将用作创建 Watcher 实例时传递给 Watcher 构造函数的第二个参数,这也将是我们第一次真正地接触 Watcher 构造函数,不过现在我们需要先把 updateComponent 函数搞清楚,在上面的代码中首先定义了 updateComponent 变量,虽然是一个 if...else 语句块,其中 if 语句块的条件我们已经遇到过很多次了,在满足该条件的情况下会做一些性能统计,可以看到在 if 语句块中分别统计了 vm._render() 函数以及 vm._update() 函数的运行性能。也就是说无论是执行 if 语句块还是执行 else 语句块,最终 updateComponent 函数的功能是不变的。
既然功能相同,我们就直接看 else 语句块的代码,因为它要简洁的多
let updateComponent
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
// 省略...
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
可以看到 updateComponent 是一个函数,该函数的作用是以 vm._render() 函数的返回值作为第一个参数调用 vm._update() 函数。由于我们还没有讲解 vm._render 函数和 vm._update 函数的作用,所以为了让大家更好理解,我们可以简单地认为:
- vm._render 函数的作用是调用 vm.$options.render 函数并返回生成的虚拟节点(vnode)
- vm._update 函数的作用是把 vm._render 函数生成的虚拟节点渲染成真正的 DOM
也就是说目前我们可以简单地认为 updateComponent 函数的作用就是:把渲染函数生成的虚拟DOM渲染成真正的DOM,其实在 vm._update 内部是通过虚拟DOM的补丁算法(patch)来完成的
再往下,我们将遇到创建观察者(Watcher)实例的代码:
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
因为 watcher 对表达式的求值,触发了数据属性的 get 拦截器函数,从而收集到了依赖,当数据变化时能够触发响应。在上面的代码中 Watcher 观察者实例将对 updateComponent 函数求值,我们知道 updateComponent 函数的执行会间接触发渲染函数(vm.$options.render)的执行,而渲染函数的执行则会触发数据属性的 get 拦截器函数,从而将依赖(观察者)收集,当数据变化时将重新执行 updateComponent 函数,这就完成了重新渲染。同时我们把上面代码中实例化的观察者对象称为 渲染函数的观察者
初识 Watcher
接下来我们就以渲染函数的观察者对象为例,顺着脉络了解 Watcher 类,Watcher 类定义在 src/core/observer/watcher.js 文件中,如下是 Watcher 类的全部内容:
export default class Watcher {
// 实例时可以传递五个参数
constructor (
// 组件实例对象 vm
vm: Component,
// 要观察的表达式 expOrFn
expOrFn: string | Function,
// 当被观察的表达式的值变化时的回调函数 cb
cb: Function,
// 一些传递给当前观察者对象的选项 options
options?: ?Object,
// 一个布尔值 isRenderWatcher 用来标识该观察者实例是否是渲染函数的观察者。
isRenderWatcher?: boolean
) {
}
get () {
// 省略...
}
addDep (dep: Dep) {
// 省略...
}
cleanupDeps () {
// 省略...
}
update () {
// 省略...
}
run () {
// 省略...
}
getAndInvoke (cb: Function) {
// 省略...
}
evaluate () {
// 省略...
}
depend () {
// 省略...
}
teardown () {
// 省略...
}
}
如下是在 mountComponent 函数中创建渲染函数观察者实例的代码:
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
在创建渲染函数观察者实例对象时传递了全部的五个参数
- 第一个参数 vm 很显然就是当前组件实例对象;
- 第二个参数 updateComponent 就是被观察的目标,它是一个函数;
- 第三个参数 noop 是一个空函数;
- 第四个参数是一个包含 before 函数的对象,这个对象将作为传递给该观察者的选项;
- 第五个参数为 true,我们知道这个参数标识着该观察者实例对象是否是渲染函数的观察者,很显然上面的代码是在为渲染函数创建观察者对象,所以第五个参数自然为 true
这里有几个问题需要注意,首先被观察的表达式是一个函数,即 updateComponent 函数,我们知道 Watcher 的原理是通过对“被观测目标”的求值,触发数据属性的 get 拦截器函数从而收集依赖,至于“被观测目标”到底是表达式还是函数或者是其他形式的内容都不重要,重要的是“被观测目标”能否触发数据属性的 get 拦截器函数,很显然函数是具备这个能力的。另外一个我们需要注意的是传递给 Watcher 构造函数的第三个参数 noop 是一个空函数,它什么事情都不会做,有的同学可能会有疑问:“不是说好了当数据变化时重新渲染吗,现在怎么什么都不做了?”,实际上数据的变化不仅仅会执行回调,还会重新对“被观察目标”求值,也就是说 updateComponent 也会被调用,所以不需要通过执行回调去重新渲染。说到这里大家或许又产生了一个疑问:“再次执行 updateComponent 函数难道不会导致再次触发数据属性的 get 拦截器函数导致重复收集依赖吗?”,这是个好问题,不过不用担心,因为 Vue 已经实现了避免收集重复依赖的处理,我们后面会讲到的。
constructor 函数开始,看一下创建渲染函数观察者实例对象的过程,进一步了解一个观察者,如下是 constructor 函数开头的一段代码:
this.vm = vm
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
首先将当前组件实例对象 vm 赋值给该观察者实例的 this.vm 属性,也就是说每一个观察者实例对象都有一个 vm 实例属性,该属性指明了这个观察者是属于哪一个组件的。接着使用 if 条件语句判断 isRenderWatcher 是否为真,前面说过 isRenderWatcher 标识着是否是渲染函数的观察者,只有在 mountComponent 函数中创建渲染函数观察者时这个参数为真,如果 isRenderWatcher 为真那么则会将当前观察者实例赋值给 vm._watcher 属性,也就是说组件实例的 _watcher 属性的值引用着该组件的渲染函数观察者。大家还记得 _watcher 属性是在哪里初始化的吗?是在 initLifecycle 函数中被初始化的,其初始值为 null。在 if 语句块的后面将当前观察者实例对象 push 到 vm._watchers 数组中,也就是说属于该组件实例的观察者都会被添加到该组件实例对象的 vm._watchers 数组中,包括渲染函数的观察者和非渲染函数的观察者。另外组件实例的 vm._watchers 属性是在 initState 函数中初始化的,其初始值是一个空数组。
// 判断是否传递了 options 参数
if (options) {
this.deep = !!options.deep
this.user = !!options.user
this.computed = !!options.computed
this.sync = !!options.sync
this.before = options.before
} else {
this.deep = this.user = this.computed = this.sync = false
}
-
options.deep,用来告诉当前观察者实例对象是否是深度观测
我们平时在使用 Vue 的 watch 选项或者 vm.$watch 函数去观测某个数据时,可以通过设置 deep 选项的值为 true 来深度观测该数据。 -
options.user,用来标识当前观察者实例对象是 开发者定义的 还是 内部定义的
实际上无论是 Vue 的 watch 选项还是 vm.$watch 函数,他们的实现都是通过实例化 Watcher 类完成的,等到我们讲解 Vue 的 watch 选项和 vm.$watch 的具体实现时大家会看到,除了内部定义的观察者(如:渲染函数的观察者、计算属性的观察者等)之外,所有观察者都被认为是开发者定义的,这时 options.user 会自动被设置为 true。 -
options.computed,用来标识当前观察者实例对象是否是计算属性的观察者
这里需要明确的是,计算属性的观察者并不是指一个观察某个计算属性变化的观察者,而是指 Vue 内部在实现计算属性这个功能时为计算属性创建的观察者。等到我们讲解计算属性的实现时再详细说明。 -
options.sync,用来告诉观察者当数据变化时是否同步求值并执行回调
默认情况下当数据变化时不会同步求值并执行回调,而是将需要重新求值并执行回调的观察者放到一个异步队列中,当所有数据的变化结束之后统一求值并执行回调,这么做的好处有很多,我们后面会详细讲解。 -
options.before,可以理解为 Watcher 实例的钩子,当数据变化之后,触发更新之前,调用在创建渲染函数的观察者实例对象时传递的 before 选项。
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
可以看到当数据变化之后,触发更新之前,如果 vm._isMounted 属性的值为真,则会调用 beforeUpdate 生命周期钩子。
再往下又定义了一些实例属性,如下:
// 定义了 this.cb 属性,它的值为 cb 回调函数
this.cb = cb
// 定义了 this.id 属性,它是观察者实例对象的唯一标识。
this.id = ++uid // uid for batching
// 定义了 this.active 属性,它标识着该观察者实例对象是否是激活状态,默认值为 true 代表激活。
this.active = true
//定义了 this.dirty 属性,该属性的值与 this.computed 属性的值相同,也就是说只有计算属性的观察者实例对象的 this.dirty 属性的值才会为真,因为计算属性是惰性求值。
this.dirty = this.computed // for computed watchers
接着往下看代码,如下:
// 其实它们就是传说中用来实现避免收集重复依赖,且移除无用依赖的功能也依赖于它们
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
来到一段 if...else 语句块:
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = function () {}
process.env.NODE_ENV !== 'production' && warn(
`Failed watching path: "${expOrFn}" ` +
'Watcher only accepts simple dot-delimited paths. ' +
'For full control, use a function instead.',
vm
)
}
}
这段代码检测了 expOrFn 的类型,如果 expOrFn 是函数,那么直接使用 expOrFn 作为 this.getter 属性的值。如果 expOrFn 不是函数,那么将 expOrFn 透传给 parsePath 函数,并以 parsePath 函数的返回值作为 this.getter 属性的值。那么 parsePath 函数做了什么呢?parsePath 函数定义在 src/core/util/lang.js 文件,源码如下:
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
}
}
首先定义 segments 常量,它的值是通过字符 . 分割 path 字符串产生的数组,随后 parsePath 函数将返回值一个函数,该函数的作用是遍历 segments 数组循环访问 path 指定的属性值。这样就触发了数据属性的 get 拦截器函数。但要注意 parsePath 返回的新函数将作为 this.getter 的值,只有当 this.getter 被调用的时候,这个函数才会执行,目的就是支持a.b.c那种多层的对象的调用。
再往下我们来到了 constructor 函数的最后一段代码:
if (this.computed) {
this.value = undefined
this.dep = new Dep()
} else {
this.value = this.get()
}
通过这段代码我们可以发现,计算属性的观察者和其他观察者实例对象的处理方式是不同的,对于计算属性的观察者我们会在讲解计算属性时详细说明。除计算属性的观察者之外的所有观察者实例对象都将执行如上代码的 else 分支语句,即调用 this.get() 方法。
依赖收集的过程
this.get() 是我们遇到的第一个观察者对象的实例方法,它的作用可以用两个字描述:求值。求值的目的有两个
- 第一个是能够触发访问器属性的 get 拦截器函数
- 第二个是能够获得被观察目标的值
而且能够触发访问器属性的 get 拦截器函数是依赖被收集的关键,下面我们具体查看一下 this.get() 方法的内容:
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}
一上来调用了 pushTarget(this) 函数,并将当前观察者实例对象作为参数传递,这里的 pushTarget 函数来自于 src/core/observer/dep.js 文件,如下代码所示:
export default class Dep {
// 省略...
}
Dep.target = null
const targetStack = []
export function pushTarget (_target: ?Watcher) {
if (Dep.target) targetStack.push(Dep.target)
Dep.target = _target
}
export function popTarget () {
Dep.target = targetStack.pop()
}
当时我们说每个响应式数据的属性都通过闭包引用着一个用来收集属于自身依赖的“筐”,实际上那个“筐”就是 Dep 类的实例对象。更多关于 Dep 类的内容我们会在合适的地方讲解,现在我们的主要目的是搞清楚 pushTarget 函数是做什么的。在上面这段代码中我们可以看到 Dep 类拥有一个静态属性,即 Dep.target 属性,该属性的初始值为 null,其实 pushTarget 函数的作用就是用来为 Dep.target 属性赋值的,pushTarget 函数会将接收到的参数赋值给 Dep.target 属性,我们知道传递给 pushTarget 函数的参数就是调用该函数的观察者对象,所以 Dep.target 保存着一个观察者对象,其实这个观察者对象就是即将要收集的目标。
this.get() 方法中,如下是简化后的代码:
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
// 省略...
} finally {
// 省略...
}
return value
}
在调用 pushTarget 函数之后,定义了 value 变量,该变量的值为 this.getter 函数的返回值,我们知道观察者对象的 this.getter 属性是一个函数,这个函数的执行就意味着对被观察目标的求值,并将得到的值赋值给 value 变量,而且我们可以看到 this.get 方法的最后将 value 返回为什么要强调这一点呢?如下代码所示:
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
// 省略...
if (this.computed) {
this.value = undefined
this.dep = new Dep()
} else {
this.value = this.get()
}
}
这句高亮的代码将 this.get() 方法的返回值赋值给了观察者实例对象的 this.value 属性。也就是说 this.value 属性保存着被观察目标的值。
this.get() 方法除了对被观察目标求值之外,大家别忘了正是因为对被观察目标的求值才得以触发数据属性的 get 拦截器函数,还是以渲染函数的观察者为例,假设我们有如下模板:
<div id="demo">
<p>{{name}}</p>
</div>
这段模板被编译将生成如下渲染函数:
// 编译生成的渲染函数是一个匿名函数
function anonymous () {
with (this) {
return _c('div',
{ attrs:{ "id": "demo" } },
[_v("\n "+_s(name)+"\n ")]
)
}
}
可以发现渲染函数的执行会读取数据属性 name 的值,这将会触发 name 属性的 get 拦截器函数
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
}
这段代码我们已经很熟悉了,它是数据属性的 get 拦截器函数,由于渲染函数读取了 name 属性的值,所以 name 属性的 get 拦截器函数将被执行,大家注意如上代码中高亮的两句代码,首先判断了 Dep.target 是否存在,如果存在则调用 dep.depend 方法收集依赖。那么 Dep.target 是否存在呢?答案是存在,这就是为什么 pushTarget 函数要在调用 this.getter 函数之前被调用的原因。既然 dep.depend 方法被执行,那么我们就找到 dep.depend 方法,如下:
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
为了搞清楚这么做的目的,我们找到观察者实例对象的 addDep 方法,如下:
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
可以看到 addDep 方法接收一个参数,这个参数是一个 Dep 对象,在 addDep 方法内部首先定义了常量 id,它的值是 Dep 实例对象的唯一 id 值。接着是一段 if 语句块,该 if 语句块的代码很关键,因为它的作用就是用来 避免收集重复依赖 的,既然是用来避免收集重复的依赖,那么就不得不用到我们前面提到过的两组属性,即 newDepIds、newDeps 以及 depIds、deps。
我们思考一下可不可以把 addDep 方法修改成如下这样:
addDep (dep: Dep) {
dep.addSub(this)
}
首先解释一下 dep.addSub 方法,它的源码如下:
addSub (sub: Watcher) {
this.subs.push(sub)
}
addSub 方法接收观察者对象作为参数,并将接收到的观察者添加到 Dep 实例对象的 subs 数组中,其实 addSub 方法才是真正用来收集观察者的方法,并且收集到的观察者都会被添加到 subs 数组中存起来。
了解了 addSub 方法之后,我们再回到如下这段代码:
addDep (dep: Dep) {
dep.addSub(this)
}
我们修改了 addDep 方法,直接在 addDep 方法内调用 dep.addSub 方法,并将当前观察者对象作为参数传递。这不是很好吗?难道有什么问题吗?当然有问题,假如我们有如下模板:
<div id="demo">
{{name}}{{name}}
</div>
这段模板的不同之处在于我们使用了两次 name 数据,那么相应的渲染函数也将变为如下这样:
function anonymous () {
with (this) {
return _c('div',
{ attrs:{ "id": "demo" } },
[_v("\n "+_s(name)+_s(name)+"\n ")]
)
}
}
可以看到,渲染函数的执行将读取两次数据对象 name 属性的值,这必然会触发两次 name 属性的 get 拦截器函数,同样的道理,dep.depend 也将被触发两次,最后导致 dep.addSub 方法被执行了两次,且参数一模一样,这样就产生了同一个观察者被收集多次的问题。所以我们不能像如上那样修改 addDep 函数的代码,那么此时我相信大家也应该知道如下高亮代码的含义了:
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
在 addDep 内部并不是直接调用 dep.addSub 收集观察者,而是先根据 dep.id 属性检测该 Dep 实例对象是否已经存在于 newDepIds 中,如果存在那么说明已经收集过依赖了,什么都不会做。如果不存在才会继续执行 if 语句块的代码,同时将 dep.id 属性和 Dep 实例对象本身分别添加到 newDepIds 和 newDeps 属性中,这样无论一个数据属性被读取了多少次,对于同一个观察者它只会收集一次。
!this.depIds.has(id) 是什么意思呢?
newDepIds 属性用来避免在 一次求值 的过程中收集重复的依赖,其实 depIds 属性是用来在 多次求值 中避免收集重复依赖的
什么是多次求值,其实所谓多次求值是指当数据变化时重新求值的过程。大家可能会疑惑,难道重新求值的时候不能用 newDepIds 属性来避免收集重复的依赖吗?不能,原因在于每一次求值之后 newDepIds 属性都会被清空,也就是说每次重新求值的时候对于观察者实例对象来讲 newDepIds 属性始终是全新的。虽然每次求值之后会清空 newDepIds 属性的值,但在清空之前会把 newDepIds 属性的值以及 newDeps 属性的值赋值给 depIds 属性和 deps 属性,这样重新求值的时候 depIds 属性和 deps 属性将会保存着上一次求值中 newDepIds 属性以及 newDeps 属性的值。为了证明这一点,我们来看一下观察者对象的求值方法,即 get() 方法:
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
// 省略...
} finally {
// 省略...
popTarget()
this.cleanupDeps()
}
return value
}
可以看到在 finally 语句块内调用了观察者对象的 cleanupDeps 方法,这个方法的作用正如我们前面所说的那样,每次求值完毕后都会使用 depIds 属性和 deps 属性保存 newDepIds 属性和 newDeps 属性的值,然后再清空 newDepIds 属性和 newDeps 属性的值,如下是 cleanupDeps 方法的源码:
cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
let tmp = this.depIds
this.depIds = this.newDepIds
this.newDepIds = tmp
this.newDepIds.clear()
tmp = this.deps
this.deps = this.newDeps
this.newDeps = tmp
this.newDeps.length = 0
}
在 cleanupDeps 方法内部,首先是一个 while 循环,我们暂且不关心这个循环的作用,我们看循环下面的代码,即高亮的部分,这段代码是典型的引用类型变量交换值的过程,最终的结果就是 newDepIds 属性和 newDeps 属性被清空,并且在被清空之前把值分别赋给了 depIds 属性和 deps 属性,这两个属性将会用在下一次求值时避免依赖的重复收集。
现在我们可以做几点总结:
- 1、newDepIds 属性用来在一次求值中避免收集重复的观察者
- 2、每次求值并收集观察者完成之后会清空 newDepIds 和 newDeps 这两个属性的值,并且在被清空之前把值分别赋给了 depIds 属性和 deps 属性
- 3、depIds 属性用来避免重复求值时收集重复的观察者
通过以上三点内容我们可以总结出一个结论,即 newDepIds 和 newDeps 这两个属性的值所存储的总是当次求值所收集到的 Dep 实例对象,而 depIds 和 deps 这两个属性的值所存储的总是上一次求值过程中所收集到的 Dep 实例对象。
除了以上三点之外,其实 deps 属性还能够用来移除废弃的观察者,cleanupDeps 方法中开头的那段 while 循环就是用来实现这个功能的,如下代码所示:
cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
// 省略...
}
这段 while 循环就是对 deps 数组进行遍历,也就是对上一次求值所收集到的 Dep 对象进行遍历,然后在循环内部检查上一次求值所收集到的 Dep 实例对象是否存在于当前这次求值所收集到的 Dep 实例对象中,如果不存在则说明该 Dep 实例对象已经和该观察者不存在依赖关系了,这时就会调用 dep.removeSub(this) 方法并以该观察者实例对象作为参数传递,从而将该观察者对象从 Dep 实例对象中移除。
我们可以找到 Dep 类的 removeSub 实例方法,如下:
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
它的内容很简单,接收一个要被移除的观察者作为参数,然后使用 remove 工具函数,将该观察者从 this.subs 数组中移除。