Vue中Watcher的简要说明(3)
前言
前文提到了Observer类和observe过程,由于遇到的场景简单,暂时没有去使用Observer类来解决问题。通过observe过程的子过程observeObject和observeArray来特殊处理引用类型的可观察拦截。结合使用Vue的经验,假设有关于数组类型数据array的列表循环渲染,也有关于array的watch。那么当按照下标对array赋值时,列表渲染会被更新,但是普通的watch并无法被触发;使用push等数组变异方法操作array时,列表渲染会被更新,watch也可以被触发。
function observeArray(value: any[]) {
const oldPush = Array.prototype.push
Object.defineProperty(value, 'push', {
value: function (...args) {
const watchers: Watcher[] = this['watchers']
oldPush.apply(this, args)
watchers.forEach(watcher => watcher.update())
}
})
for (let i = 0; i < value.length; i++) {
let val = value[i]
defineProperty(value, i, val)
observe(val)
}
}
通过循环的方式,按下标为数组定义getter和setter,这样使得那些观察数组元素的watcher可以在按下标赋值时得到通知。但是考虑实际的使用情况,往往是对数组本身的观察,需要在按下标赋值时被观察。push和sort这种数组编译的方法,能够触发对数组的观察,因为对变异数组方法进行了封装。对数组元素的修改,由于无法被set拦截,并不能触发数组上的watch。
简单思索一下什么是变化
先来探讨一下observe过程的目的:当被观察数据发生变化时通知观察者。那么这个发生变化可能有很多含义。对于基本类型数据来说,值发生变化就是变化,但是对于引用类型数据来说,可能会稍微复杂一些。
指针变化
const data = {
obj: {
name: 'obj'
}
}
data.obj = {
name: 'obj2'
}
这种情况obj已经发生了翻天覆地的变化,也是最直观的变化。由于observe(data)时,obj属性有set拦截,所以指针变化可以直接被观察到。
属性值变化
const data = {
obj: {
name: 'obj',
objInner: {
id: 1
}
}
}
data.obj.name = 'obj1'
// 或者
data.obj.objInner = {
id: 2
}
这两种情况都是引用类型的属性值发生变化,换句话说“=”赋值操作的右边,总是obj的直接属性。由于之前编写的observe的递归性,obj上施加了对name和objInner的setter,因此对obj.name的观察和obj.innerObj的观察是可以被触发的,但是对obj的观察无法被触发。再通俗点说就是ojb属性的属性值没有发生变化。还要注意,对data.obj.objInner的赋值,可以归化到指针变化的范围内,data.obj.objInner.id的变化无法触发objInner和obj的观察的原因就不细说了。
数组的情况
由于数组可以在一定程度上当做对象来看待:
const array = ['obj', {id: 1}]
// 看做
const arrayToObj = {
0: 'obj',
1: {
id: 1
}
}
因此讨论起来和对象类似,只不过访问的属性形式不同罢了。
但是数组比对象要多一些方法,push之类的数组变异方法,在调用前后,array指针没有变化,内部元素或多或少的发生了变化,这些变化也可以归到指针变化或者属性值变化。
array.push([1,2,3])
// 相当于
array[2] = [1,2,3]
// 假设array预先申请了10个位置,array[2]的值是undefined,因此在赋值后相当于属性值变化。
虽然Vue中并不支持‘array[2]’这种形式的观察,但是从通用的概念上来说和对象的种种赋值行为是类似的。
observe过程的重新思考
之前实现的observe过程,非常朴素的附加了getter和setter,观察者通过对观察目标(计算过程)的求值,访问并触发相关属性的getter,getter就负责将正在求值的观察者记录下来作为观察关系,以便在setter阶段进行通知。那么问题来说,如果观察obj,观察者的求值只会触及到obj的getter,也只有obj指针指向发生变化(也就是指针类型变量的值发生变化)时才能通知到观察者。
至于引用类型内部的变化到底需不需要触发观察,有时也是根据需求来定的,所以Vue中watch支持deep属性,虽然观察的是obj,但是内部的变化也是关心的。那么deep属性让watcher在求值过程中,不止步于目标本身的访问,还决定了是否进行更深层次的访问,毕竟在当前体系中,访问代表了建立观察关系。本阶段解决的用例:
const v = new Vue({
data() {
return {
obj: {
name: 'obj',
address: {
street: 'St.V',
no: '117'
},
keys: [1, 9, 21]
},
array: [
1,
{
id: 2
},
[3, 10]
]
}
},
watch: {
obj: {
handler(newValue, oldValue) {
console.log('obj is changed:', oldValue, newValue)
},
deep: true
},
array: {
handler(newValue, oldValue) {
console.log('array is changed:', oldValue, newValue)
},
deep: true
},
}
})
test('Obj nasted', () => {
v.obj.name = 'newObj'
v.obj.address.no = '200'
v.obj.address = {
street: 'St.V',
no: '118'
}
expect(v.objWatchCount).toBe(3)
})
test('array nasted', () => {
v.array[0] = 99
v.array[1].id = 99
v.array[2][0] = 99
expect(v.arrayWatchCount).toBe(3)
expect(v.array[0]).toBe(99)
expect(v.array[1].id).toBe(99)
expect(v.array[2][0]).toBe(99)
})
Watcher的deep属性
访问的越深,建立的观察关系就越多,这是显而易见的。deep属性决定了在观察某个数据时,是否也关心其内部数据的变化,因此在watcher内部求值的过程,是否进行更深层次的遍历是关键。
注:引用类型数据可能存在循环引用,如果在深层次遍历访问时遇到这种情况,可能出现死循环,因此参照Vue源码使用Set防止该问题。
// watcher.ts代码片段
get() {
let value = this.getter.call(this.vm)
if (this.deep) {
// 遍历这个value,尝试触发更深层次数据上的setter
traverse(value)
}
return value
}
// traverse.ts
const unique = new Set()
export function traverse(val: any) {
_traverse(val)
// 递归完了要清空
unique.clear()
}
/**
* 递归操作
* @param val
*/
function _traverse(val) {
if (isArray(val)) {
for (let i = 0; i < val.length; i++) {
const child = val[i]
if (!unique.has(child)) {
unique.add(child)
_traverse(child)
}
}
}
// 注意:要么这里使用else区分,要么isObject判断剔除数组
if (isObject(val)) {
Object.keys(val).forEach(key => {
const child = val[key]
if (!unique.has(child)) {
unique.add(child)
_traverse(child)
}
})
}
}
为了方便说明,观察obj的watcher成为objWatcher,obj.name的setter叫做nameSetter。
在deep属性为true的情况下,观察obj的watcher就会进一步观察深层次数据的情况。那么当执行obj.name = 'obj2'时,nameSetter会通知objWatcher进行更新。Watcher的更新逻辑是先对观察目标(计算过程)进行求值,然后判断新旧值,引用类型浅比较导致并不会顺利的执行cb。那么就在update中区分一下,如果objWatcher设置了deep,那就不进行新旧值对比,而是信任nameSetter发来的通知信号。
update() {
// 取得观察目标的新值
const newValue = this.get()
// 更新值
const oldValue = this.value
this.value = newValue
// 条件要把deep考虑进去
if (newValue !== oldValue || this.deep) {
// 仅在结果发生变化时触发cb,减少不必要的动作
this.cb.call(this.vm, newValue, oldValue)
}
}
这个时候又一个问题来了,oldValue和newValue其实是指向同一个对象的两个指针,实际上并无法得到更新了内部属性前的对象。
总结
Watcher通过求值(执行计算过程)的方式来触发可被观察数据的getter,从而建立观察关系。那么求值的深浅,决定了观察关系的深浅,对于引用类型的数据,其内部变化在很多业务中,也被视作其自身发生了变化,因此Vue中为watch设计了deep属性,进行更深层次的求值从而建立更深层次的观察关系。
注意:本案例之所以能够在v.array[0] = 99
时正确触发watch,是因为observeArray中按下标定义了getter和setter(这是一个hack的实现,请勿参考)。