Vue中Observer的简要说明(1)
前言
前两篇文章让Watcher可以处理单一状态的观察和多个状态计算过程的观察,都是针对基础类型数据,对于引用类型,甚至更深层次的数据结构应当如何处理。直观的来看,引用类型的状态,其包含的数据字段也应当通过getter/setter的包裹,从而能被深层次的观察。所以为属性设置getter和setter拦截,需要作为一个通用过程,处理引用类型数据的时候可以在递归处理时进行正常的工作
Observer的基本说明
梳理一下数据访问
首先,初始化Vue实例的options中,data属性提供一个生成初始状态集合的函数,防止组件复用时不必要的数据错乱。initData过程中调用该函数得到初始化状态集合,并将其存放在Vue实例内部的_data属性上,当通过实例访问属性字段,会通过get拦截从_data中读取响应数据。
// 注意getters['a']只是一种表达,并不是实际代码
this.a => getters['a'] => this._data.a
如果对a设置watch,就是在this上对a施加的getter中收集依赖,_data单纯是一个数据源头。
假如需要一个Observer类
Observer类的源码注释:
/**
* Observer class that is attached to each observed
* object. Once attached, the observer converts the target
* object's property keys into getter/setters that
* collect dependencies and dispatch updates.
*/
Observer类被附加到每一个可观察的对象。一旦附加,Observer会将目标对象的属性转化为getter/setter模式用于收集依赖和触发更新。
也就是说Observer类的作用,是让目标对象的属性转化成getter/setter模式,并且将自身附加到目标对象上。这个操作过程在之前的代码中,类似initData的处理过程。也就是说Observer可以是这样的:
class Observer {
constructor(value: Object) {
const keys = Object.keys(value)
keys.forEach(key => {
let watchers: Watcher[] = []
// 提前把值拿出来,用闭包的形式保存起来,避免循环
let val = value[key]
Object.defineProperty(value, key, {
get() {
Watcher.target && watchers.push(Watcher.target)
return val
},
set(v) {
val = v
watchers.forEach(watcher => watcher.update())
}
})
})
}
}
仅仅是为了复用设置getter/setter的逻辑,似乎用一个类有些浪费,把这个流程先用一个observe过程呈现:
function observe(value: any) {
const keys = Object.keys(value)
keys.forEach(key => {
let watchers: Watcher[] = []
let val = value[key]
Object.defineProperty(value, key, {
get() {
Watcher.target && watchers.push(Watcher.target)
return val
},
set(v) {
val = v
watchers.forEach(watcher => watcher.update())
}
})
})
}
现在根据这个形式,再来思考一下数据访问。getter中包含两个动作:1.收集依赖(或者说观察者提交注册);2.取值并返回。那么访问value.property,是在value上追加的getter收集依赖,并从value中获取对应值返回。按照之前的数据访问流程,访问this.a
,在this上追加getter收集对a的依赖,从_data
中获取对应值返回。
转念一想,_data本质是个朴素的对象,this.a
和this._data.a
是等价的,让_data成为可观察的,this上的getter只作为访问转移,那就可以让Observer的逻辑得到复用。initData的逻辑应该转化成:
initData() {
let data = this._data = this.$options.data()
// 获取state的key
let keys = Object.keys(data)
// Vue实例上将状态的访问交给_data,真正可观察的是_data
keys.forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key]
},
set(v: any) {
data[key] = v
}
})
})
observe(data)
}
进一步丰富observe过程
实际使用中,data往往包含着层次更深,结构更复杂的引用类型数据,比如字典或者列表。那么假设下一步要解决的问题是这样的:
const v = new Vue({
data() {
return {
rooms: [
{
id: 1,
occupied: false
},
{
id: 2,
occupied: true
}
]
}
},
computed: {
canCheckIn() {
return this.rooms.some(room => !room.occupied)
}
},
watch: {
canCheckIn(newValue, oldValue) {
if (!newValue && oldValue) {
console.log("Last room has been occupied")
}
if (newValue && !oldValue) {
console.log("There are some new rooms can use")
}
}
}
})
// @ts-ignore
expect(v.canCheckIn).toBe(true)
// @ts-ignore
v.rooms[0].occupied = true
// @ts-ignore
expect(v.canCheckIn).toBe(false)
// @ts-ignore
v.rooms = [{id: 3, occupied: false}]
// @ts-ignore
expect(v.canCheckIn).toBe(true)
rooms表示房间列表,occupied表示是否有人,计算属性canCheckIn表示是否可以入住(是否有房间没有被占用)。那么期望在v.rooms[0].occupied = true
时canCheckIn能够被重新计算,并且被watch到输出相应的提示。
计算属性canCheckIn对应的计算过程中,拆解来看,首先是this.rooms,如果对this.rooms重新赋值,canCheckIn要重新检查是否有房间可用;然后是this.rooms.some,是对this.rooms的一种迭代,注意!即使some会在条件满足时结束迭代,但是在最坏的情况下,每一个元素都会被访问到。所以room的occupied属性发生变化时,也应当重新执行计算过程(即使可能对结果不产生影响,本例中由于some具有特殊性)。上述分析过程中略过了this.rooms[1] = {id: 3, occupied: false}
这种情形,有意而为之,后续会进行说明。
这样看来,rooms中的每个对象都将成为可观察对象,而且会发现如果只靠执行canCheckIn计算过程,可能无法完全访问每一个对象,那就要求在访问rooms时,得知其是数组类型,就直接对其进行完整遍历并完成可观察改造。
function observe(value: any) {
const keys = Object.keys(value)
keys.forEach(key => {
let watchers: Watcher[] = []
let val = value[key]
Object.defineProperty(value, key, {
get() {
Watcher.target && watchers.push(Watcher.target)
return val
},
set(v) {
val = v
watchers.forEach(watcher => watcher.update())
}
})
// 先只处理数组类型
if (Array.isArray(val)) {
val.forEach(i => observe(i))
}
})
}
这样处理之后,可以向this.rooms提交注册,也可以向每个room的各个属性提交注册,但是对this.rooms[0]的赋值,计算属性并不会重新计算,watch也不会观察到变化。那么就探究一下数组下标访问应该向谁提注册。
this.rooms[0]
对象属性的访问可以是a.b或者a['b'],那数组的下标访问,是不是也可以当做是类似的。
let a = [1, 2, 3]
for (let i = 0; i < a.length; i++) {
let val = a[i]
Object.defineProperty(a, i, {
get() {
console.log(`get a[${i}]:${val}`)
return val
},
set(v) {
console.log(`get a[${i}]:${v}`)
val = v
}
})
}
a[1] = 5
放心大胆的写出这样的代码,跑一下感觉很不错,加入到observe过程中:
function observe(value: any) {
if (Array.isArray(value)) {
observeArray(value)
}
if (typeof value === 'object' && value !== null) {
observeObject(value)
}
}
function observeObject(value: any) {
const keys = Object.keys(value)
keys.forEach(key => {
let val = value[key]
defineProperty(value, key, val)
observe(val)
})
}
function observeArray(value: any[]) {
for (let i = 0; i < value.length; i++) {
let val = value[i]
defineProperty(value, i, val)
observe(val)
}
}
function defineProperty(target, key, val) {
let watchers: Watcher[] = []
Object.defineProperty(target, key, {
get() {
Watcher.target && watchers.push(Watcher.target)
return val
},
set(v) {
val = v
watchers.forEach(watcher => watcher.update())
}
})
}
至此,observe过程似乎已经可以通过测试用例,也并没有用到Observer类,这一点后面再说。下面要讨论的问题和数组类型有关。observeArray过程,按照数组的长度分别定义index访问的getter,但是数组在使用过程中,通常长度不是固定的,假如后续向数组中添加元素或者移除元素,就不太适用了。
数组变异方法的代理
测试用例建立在数组长度不变,且操作已知范围内元素,这是很狭隘的。业务逻辑中,很多情况下会在mounted回调中调用接口对一些数据进行初始化,或者点击按钮时请求新的数据追加到已有的数据集合中,删除元素也是类似的,这些动作对于数组来说通常被称为“数组变异”。
const v = new Vue({
data() {
return {
rooms: [
{
id: 2,
occupied: true
}
]
}
},
computed: {
canCheckIn() {
return this.rooms.some(room => !room.occupied)
}
},
watch: {
canCheckIn(newValue, oldValue) {
if (!newValue && oldValue) {
console.log("Last room has been occupied")
}
if (newValue && !oldValue) {
console.log("There are some new rooms can use")
}
}
},
mounted() {
setTimeout(() => {
this.rooms.push({
id: 1,
occupied: false
})
}, 100)
}
})
这个用例,主要是在mounted中模拟一个异步请求,增加一个未被占用的房间,应当引起canCheckIn的重新计算,并且watch输出相应的提示内容。为了方便测试,mounted选项的处理简单做一下。
/**
* 模拟挂载组件,这里只是为了触发hook
*/
$mount() {
const mounted = this.$options.mounted
if (mounted) {
mounted.call(this)
}
}
为了能在数组实例执行push时进行通知,要么用一种方式拦截push操作,要么在observeArray时,用原始数组的数据创建一个继承Array的自定义封装类,后者还要保证封装类在做类型判断时不会产生影响。Vue源码中使用后者,且只包装了push、pop、shift、unshift、splice、sort和reverse六大方法,这些方法也是数组变异的六种手段。先来尝试实现push:
function observeArray(value: any[]) {
const oldPush = Array.prototype.push
Object.defineProperty(value, 'push', {
value: function (...args) {
console.log("proxy push")
oldPush.apply(this, args)
}
})
for (let i = 0; i < value.length; i++) {
let val = value[i]
defineProperty(value, i, val)
observe(val)
}
}
现在确实可以在数组执行push的时候进行一些操作了,目标是通知那些访问数组的观察者,那就还需要一个保存观察者的集合,并且和数组绑定。这就很尴尬了,访问rooms的watcher是在observeObject的时候收集的,由于闭包的关系,并没有办法在observeArray中访问到。
拦截了数组的push方法,但是拿不到和数组直接相关的watcher,怎么办,直观的方法是把watcher集合直接加到数组实例上:
function defineProperty(target, key, val) {
let watchers: Watcher[] = []
// 直接把watchers数组追加到数组实例上
if (Array.isArray(val)) val['watchers'] = watchers
Object.defineProperty(target, key, {
get() {
Watcher.target && watchers.push(Watcher.target)
return val
},
set(v) {
val = v
watchers.forEach(watcher => watcher.update())
}
})
}
function observeArray(value: any[]) {
const oldPush = Array.prototype.push
Object.defineProperty(value, 'push', {
value: function (...args) {
// watchers这里就可用与通知了
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)
}
}
对普通对象类型的状态来说,也可以进行类似的操作,一旦显式地附加了watchers,相当于污染了原始数据,用户编写的程序内部可见,这是极具侵略性的行为。换种隐式的附加方式(配置watchers属性的不可迭代性),适当的能够减轻这种侵略性,一定程度上不会被用户所感知。
总结
其他数组变异方法的取代方式差不多,这里就不具体展开了。有针对性的解决问题,总是会有疏漏,比如按照数组下表对数组施加getter,无法处理数组变异方法,也无法处理变长数组的问题。通过附加包装后的数组变异方法,以及将watchers集合显式或隐式的绑定到被观察目标身上,来实现数组变异时向外通知。这也许很hack,但是思路就是这样一步一步梳理出来的。