前言

前两篇文章让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.athis._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,但是思路就是这样一步一步梳理出来的。

标签: none

添加新评论