三、依赖收集
目标
- 了解什么是依赖收集
- 了解依赖收集的流程以及它的目的
上面我们了解 Vue 会把普通对象变成响应式对象,响应式对象 getter 相关的逻辑就是做依赖收集,现在我们来详细分析这个过程。
我们先来回顾一下 getter 部分的逻辑:
export function defineReactive(obj, key, val, customSetter, shallow) {
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
// 递归观测,当然前提val不是基础类型,observe中已作判断
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
const value = getter ? getter.call(obj) : val
console.log('getter:', value)
/***********************依赖收集*************************/
if (Dep.target) {
dep.depend()
}
/***********************依赖收集*************************/
return value
},
// ...
})
}
这段代码我们需要关注的是在 get
函数中通过 dep.depend
做依赖收集,还有就是访问该属性,最终肯定要返回该属性对应的值。
Dep
Dep
是整个 getter 依赖收集的核心,它是建立这个数据与 watcher
之间的桥梁:
import { remove, isObject } from './util'
let depId = 0
/**
* 一个dep是一个可观察的对象,可以有多个订阅它的指令。
* 建立数据与watcher之间的桥梁
*/
export class Dep {
constructor() {
this.id = depId++
this.subs = []
}
addSub(sub) {
this.subs.push(sub)
}
removeSub(sub) {
remove(this.subs, sub)
}
depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify() {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
/**
* 当前正在评估的目标观察者。
* 这是全局唯一的,因为一次只有一个观察者可以评估
*/
Dep.target = null
const targetStack = []
export function pushTarget(target) {
targetStack.push(target)
Dep.target = target
}
export function popTarget() {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
Dep
是一个 Class,它定义了一些属性和方法,这里需要特别注意的是它有一个静态属性 Dep.target
,这是一个全局唯一 Watcher
,这是一个非常巧妙的设计,因为在同一时间只能有一个全局的 Watcher
被计算,另外它的自身属性 subs
也是 Watcher
的数组。
Dep
实际上就是对 Watcher
的一种管理,Dep
脱离 Watcher
单独存在是没有意义的,为了完整地讲清楚依赖收集过程,我们有必要看一下 Watcher
的一些相关实现:
Watcher
let watcherId = 0
export class Watcher {
constructor(vm, expOrFn, cb, options, isRenderWatcher) {
console.log('================ new Watcher ================')
this.id = ++watcherId
this.active = true
this.vm = vm
this.getter = expOrFn
this.cb = cb
this.expression = expOrFn.toString()
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
this.value = this.get()
}
/**
* 评估getter,然后重新收集依赖关系。
*/
get() {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
throw e
} finally {
popTarget()
this.cleanupDeps()
}
return value
}
/**
* 向此指令添加依赖项。
* @param dep
*/
addDep(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)
}
}
}
/**
* 清理依赖项集合。
*/
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
}
/**
* Subscriber 接口
* 依赖项更改时将调用
*/
update() {
queueWatcher(this)
}
/**
* 调度作业接口。
* 由调度程序调用。
*/
run() {
if (this.active) {
const value = this.get()
// ... TODO
}
}
}
Watcher
是一个 Class,在它的构造函数中,定义了一些和 Dep
相关的属性:
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
其中,this.deps
和 this.newDeps
表示 Watcher
实例持有的 Dep
实例的数组;而 this.depIds
和 this.newDepIds
分别代表 this.deps
和 this.newDeps
的 id
Set(这个 Set 是 ES6 的数据结构)。那么这里为何需要有 2 个 Dep
实例数组呢,稍后我们会解释。
Watcher
还定义了一些原型的方法,和依赖收集相关的有 get
、addDep
和 cleanupDeps
方法,单个介绍它们的实现不方便理解,我会结合整个依赖收集的过程把这几个方法讲清楚。
过程分析
之前我们介绍当对数据对象的访问会触发他们的 getter 方法,那么这些对象什么时候被访问呢?那就是 Vue 的 $mount
方法中,其中有一段比较重要的逻辑,大致如下:
let updateComponent = () => {
this._update(this._render())
}
new Watcher(this, updateComponent, () => { }, {}, true)/* isRenderWatcher */
当我们去实例化一个渲染 watcher
的时候,首先进入 watcher
的构造函数逻辑,然后会执行它的 this.get()
方法,进入 get
函数,首先会执行:
pushTarget(this)
export function pushTarget(target) {
targetStack.push(target)
Dep.target = target
}
实际上就是把 Dep.target
赋值为当前的渲染 watcher
并压栈(为了恢复用)。接着又执行了:
value = this.getter.call(vm, vm)
this.getter
对应就是 updateComponent
函数,这实际上就是在执行:
this._update(this._render())
它会先执行 this._render()
方法,在这个过程中会对 this.message
数据的访问,这个时候就触发了数据对象的 getter。
那么每个对象值的 getter 都持有一个 dep
,在触发 getter 的时候会调用 dep.depend()
方法,也就会执行 Dep.target.addDep(this)
。
刚才我们提到这个时候 Dep.target
已经被赋值为渲染 watcher
,那么就执行到 addDep
方法:
addDep(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)
}
}
}
这时候会做一些逻辑判断(保证同一数据不会被添加多次)后执行 dep.addSub(this)
,那么就会执行 this.subs.push(sub)
,也就是说把当前的 watcher
订阅到这个数据持有的 dep
的 subs
中,这时候就将 watcher
收集到了,这个目的是为后续数据变化时候能通知到哪些 subs
做准备。
至此,dep.depend()
执行结束,message
属性的依赖收集完毕,同时访问到 message
的值,于是完成初始渲染页面。
若有多个属性需要依赖收集,则会有多个 dep
对应一个 watcher
, 这里是多对一的关系,后续讲到计算、侦听属性,就是多对多的关系。
所以在 vm._render()
过程中,会触发所有数据的 getter,这样实际上已经完成了一个依赖收集的过程。那么到这里就结束了么,其实并没有,在完成依赖收集后,还有几个逻辑要执行,首先是是要递归去访问 value
,触发它所有子项的 getter
,这个属于 deepWatch
的逻辑之后会详细讲。接下来执行:
popTarget()
popTarget
:
export function popTarget() {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
实际上就是把 Dep.target
恢复成上一个状态,因为当前 vm 的数据依赖收集已经完成,那么对应的渲染Dep.target
也需要改变。
为什么有这样一个入栈和出栈的过程呢?因为我们考虑到有嵌套组件的情况,在执行父组件的 $mount
以后,又会执行子组件的 $mount
;那父组件的 $mount
执行时,会把父组件的渲染 watcher
push到 targetStack
中,依次就是子组件的渲染 watcher
;当子组件渲染完成,就会 popTarget
,此时 Dep.target
又恢复到父组件的渲染 watcher
,所以巧妙的利用了栈的数据结构来保留当前计算的 target
。
最后执行:
this.cleanupDeps()
其实很多人都分析过并了解到 Vue 有依赖收集的过程,但我几乎没有看到有人分析 清除依赖
的过程,其实这是大部分同学会忽视的一点,也是 Vue 考虑特别细的一点。
/**
* 清理依赖项集合。
*/
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
}
考虑到 Vue 是数据驱动的,所以每次数据变化都会重新 render,那么 this._render()
方法又会再次执行,并再次触发数据的 getters,即每次渲染都会进行 addDep
操作,所以 Wathcer
在构造函数中会初始化 2 个 Dep
实例数组;
newDeps
表示新一轮添加的Dep
实例数组,deps
表示上一次添加的Dep
实例数组。
在执行 cleanupDeps
函数的时候,会首先遍历 deps
,若其中没有新增依赖收集中的 dep
, 则移除对 dep.subs
数组中 Wathcer
的订阅,然后把 newDepIds
和 depIds
交换,newDeps
和 deps
交换,并把 newDepIds
和 newDeps
清空。
在添加 deps
的订阅过程,已经能通过 id
去重避免重复订阅了,那么为什么需要做 deps
订阅的移除呢?这其实就是一种性能优化的手段。
考虑到一种场景,我们的模板会根据 v-if
去渲染不同子模板 a 和 b,当我们满足某种条件的时候渲染 a 的时候,会访问到 a 中的数据,这时候我们对 a 使用的数据添加了 getter,做了依赖收集,那么当我们去修改 a 的数据的时候,理应通知到这些订阅者。那么如果我们一旦改变了条件渲染了 b 模板,又会对 b 使用的数据添加了 getter,如果我们没有依赖移除的过程,那么这时候我去修改 a 模板的数据,会通知 a 数据的订阅的回调,这显然是有浪费的。
因此 Vue 设计了在每次添加完新的订阅,会移除掉旧的订阅,这样就保证了在我们刚才的场景中,如果渲染 b 模板的时候去修改 a 模板的数据,a 数据订阅回调已经被移除了,所以不会有任何浪费,真的是非常赞叹 Vue 对一些细节上的处理。
来看个例子:
const vm = new Vue({
el: '#app',
data: {
display: true,
message: 'vue-1'
},
render() {
let message = ''
let display = this.display
if (display) {
message = this.message
console.log('(display为true,则访问message ======>', message + ')')
}
console.log('(页面渲染为:', display + ' ' + message + ')')
return display + ' ' + message
}
})
setTimeout(() => {
vm.display = false
}, 1000)
setTimeout(() => {
vm.message = 'vue-2'
}, 2000)
setTimeout(() => {
vm.display = true
}, 3000)
- 这里初始化时,
display
属性为true
,display
和message
属性我们都会进行访问,即两者都会被依赖收集,页面渲染结果为true vue-1
;
1000ms
过后,将display
属性修改为false
,这时只会访问display
属性,message
属性不会被依赖收集,页面渲染结果为false
;
2000ms
过后,将message
属性修改为vue-2
,因为上次更新并没有对message
进行依赖收集,因此渲染watcher
不会执行update
操作,也就不会触发vm
的更新渲染;
3000ms
过后,将display
属性修改为true
,这时又会对两者进行依赖收集,页面渲染结果为true vue-2
。
总结
-
依赖收集就是订阅数据变化的
watcher
的收集 -
依赖收集的目的是为了当这些响应式数据发生变化,触发它们的 setter 的时候,能知道应该通知哪些订阅者去做相应的逻辑处理,我们把这个过程叫派发更新,下一节我们来详细分析一下派发更新的过程。
通过这一节的分析,我们对 Vue 数据的依赖收集过程已经有了认识,并且对这其中的一些细节做了分析,其实 Watcher
和 Dep
就是一个非常经典的观察者设计模式的实现。
四、派发更新
目标
- 了解什么是派发更新
- 了解派发更新的流程以及其中做的一些优化
通过上一节分析我们了解了响应式数据依赖收集过程,收集的目的就是为了当我们修改数据的时候,可以对相关的依赖派发更新,那么这一节我们来详细分析这个过程。
我们先来回顾一下 setter 部分的逻辑:
export function defineReactive(obj, key, val, customSetter, shallow) {
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
// 递归观测,当然前提val不是基础类型,observe中已作判断
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
// ...
set: function reactiveSetter(newVal) {
const value = getter ? getter.call(obj) : val
console.log('setter:', newVal)
// 新旧的值没有发生改变
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
if (customSetter) {
customSetter()
}
// 用于没有setter的访问器属性
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
// 递归观测,当然前提val不是基础类型,observe中已作判断
childOb = !shallow && observe(newVal)
/***********************派发更新*************************/
dep.notify()
/***********************派发更新*************************/
}
})
}
setter 的逻辑有 2 个关键的点,一个是 childOb = !shallow && observe(newVal)
,如果 shallow
为 false 的情况,会对新设置的值变成一个响应式对象;另一个是 dep.notify()
,通知所有的订阅者,这是本节的关键,接下来我会带大家完整的分析整个派发更新的过程。
过程分析
当我们在组件中对响应的数据做了修改,就会触发 setter 的逻辑,最后调用 dep.notify()
方法,它是 Dep
的一个实例方法:
class Dep {
// ...
notify () {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
这里的逻辑非常简单,遍历所有的 subs
,也就是 Watcher
的实例数组,然后调用每一个 watcher
的 update
方法:
class Watcher {
// ...
update () {
queueWatcher(this)
}
}
这里对于 Watcher
的不同状态,会执行不同的逻辑,computed
和 sync
等状态的分析我会之后抽一小节详细介绍,在一般组件数据更新的场景,会走到最后一个 queueWatcher(this)
的逻辑,queueWatcher
的实现:
export const MAX_UPDATE_COUNT = 100
const queue = []
let has = {}
let circular = {}
let waiting = false
let flushing = false
let index = 0
/**
* 将观察者推送到观察者队列中。
* 具有重复ID的作业将被跳过,除非在刷新队列时,被推送到队列中
* @param watcher
*/
export function queueWatcher(watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
/**
* 在flush的过程中,又执行queueWatcher时
* 需要将当前的watcher插入到按id排序的合适位置,
* 如果watcher已经在queue队列中,那么它将立即被运行
*/
let i = queue.length - 1
while (i < index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
/**
* queue the flush
* 保证只执行一次
*/
if (!waiting) {
waiting = true
/**
* TODO
* nextTick(flushSchedulerQueue)
*/
setTimeout(flushSchedulerQueue, 0)
}
}
}
这里引入了一个队列的概念,这也是 Vue 在做派发更新的时候的一个优化的点,它并不会每次数据改变都触发 watcher
的回调,而是把这些 watcher
先添加到一个队列里,然后在 nextTick
后执行 flushSchedulerQueue
。
这里有几个细节要注意一下,首先用 has
对象保证同一个 Watcher
只添加一次;接着对 flushing
的判断,else 部分的逻辑稍后我会讲;最后通过 waiting
保证对 nextTick(flushSchedulerQueue)
的调用逻辑只有一次,另外 nextTick
的实现我之后会抽一小节专门去讲,目前就可以理解它是在下一个 tick,也就是异步的去执行 flushSchedulerQueue
,这里我们使用 setTimeout
代替 nextTick
的逻辑。
接下来我们来看 flushSchedulerQueue
的实现:
/**
* 刷新队列并运行watchers。
*/
function flushSchedulerQueue() {
flushing = true
let watcher, id
/**
* 先排序,然后再flush
* 这样可以确保:
* 1. 组件从父到子更新。 (因为父级总是在子级之前创建的)
* 2. 组件用户自定义的watchers在渲染watcher之前运行
* (因为用户自定义的watchers是在渲染watcher之前创建的)
* 3. 如果在父组件的watcher运行期间销毁了一个组件,则可以跳过该watcher
*/
queue.sort((a, b) => a.id - b.id)
/**
* 不缓存长度,因为在我们运行现有watchers时可能会推送更多的watchers
*/
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
id = watcher.id
has[id] = null
watcher.run()
// 在开发版本中,检查并停止循环更新。
if (has[id] != null) {
circular[id] = (circular[id] || 0) + 1
if (circular[id] > MAX_UPDATE_COUNT) {
console.warn(
'You may have an infinite update loop' + (
watcher.user
? `in watcher with expression "${watcher.expression}"`
: `in a component render function.`
),
watcher.vm
)
break
}
}
}
resetSchedulerState()
}
- 队列排序
queue.sort((a, b) => a.id - b.id)
对队列做了从小到大的排序,这么做主要有以下要确保以下几点:
1.组件的更新由父到子;因为父组件的创建过程是先于子的,所以 watcher
的创建也是先父后子,执行顺序也应该保持先父后子。
2.用户的自定义 watcher
要优先于渲染 watcher
执行;因为用户自定义 watcher
是在渲染 watcher
之前创建的。
3.如果一个组件在父组件的 watcher
执行期间被销毁,那么它对应的 watcher
执行都可以被跳过,所以父组件的 watcher
应该先执行。
- 队列遍历
在对 queue
排序后,接着就是要对它做遍历,拿到对应的 watcher
,执行 watcher.run()
。这里需要注意一个细节,在遍历的时候每次都会对 queue.length
求值,因为在 watcher.run()
的时候,很可能用户会再次添加新的 watcher
,这样会再次执行到 queueWatcher
,如下:
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
/**
* 在flush的过程中,又执行queueWatcher时
* 需要将当前的watcher插入到按id排序的合适位置,
* 如果watcher已经在queue队列中,那么它将立即被运行
*/
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// ...
}
}
可以看到,这时候 flushing
为 true,就会执行到 else 的逻辑,然后就会从后往前找,找到第一个待插入 watcher
的 id 比当前队列中 watcher
的 id 大的位置。把 watcher
按照 id
的插入到队列中,因此 queue
的长度发生了变化。
- 状态恢复
这个过程就是执行 resetSchedulerState
函数:
/**
* 重置调度程序的状态。
*/
function resetSchedulerState() {
index = queue.length = 0
has = {}
circular = {}
waiting = flushing = false
}
逻辑非常简单,就是把这些控制流程状态的一些变量恢复到初始值,把 watcher
队列清空。
接下来我们继续分析 watcher.run()
的逻辑:
class Watcher {
/**
* 调度作业接口。
* 由调度程序调用。
*/
run() {
if (this.active) {
const value = this.get()
// ... TODO
}
}
}
那么对于渲染 watcher
而言,它在执行 this.get()
方法求值的时候,会执行 getter
方法:
updateComponent = () => {
this._update(this._render())
}
所以这就是当我们去修改组件相关的响应式数据的时候,会触发组件重新渲染的原因,接着就会重新执行 patch
的过程,但它和首次渲染有所不同,之后我们会花一小节去详细介绍。
总结
通过这一节的分析,我们对 Vue 数据修改派发更新的过程也有了认识,实际上就是当数据发生变化的时候,触发 setter 逻辑,把在依赖过程中订阅的的所有观察者,也就是 watcher
,都触发它们的 update
过程,这个过程又利用了队列做了进一步优化,就是把所有要执行 update
的 watcher
推入到队列中,在 nextTick
后执行 flush
操作,遍历所有的 watcher
执行 run
,最后执行它们的回调函数。nextTick
是 Vue 一个比较核心的实现了,我们后面有空会来重点分析它的实现。