2020年11月

前言

前文提到了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的实现,请勿参考)。

前言

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

前言

前一篇文章中,通过watch属性的工作来简要说明Watcher类的最最基本结构,以及最简单工作流程,除此之外,Vue.prototype.$watch的工作原理也是类似的。同时也看到了状态变动时,watch指定的函数被执行,照这个思路延伸下去,computed属性也可以用这种方式实现。先执行一遍computed指定的函数(实际上就是一个运算过程),在运算的过程中会访问到所依赖的数据,这些依赖的数据发生变化的时候,在执行一下这个运算过程得到新值,大体看来就是computed属性的作用了。所以本文的目标用例:

const v = new Vue({
    data() {
        return {
            a: 1,
            b: 2
        }
    },
    computed: {
        sum() {
            return this.a + this.b
        }
    }
})

expect(v.sum).toBe(3)
v.b = 3
expect(v.sum).toBe(4)

分析

computed选项提供计算属性的相关算法,计算属性是基于实例基本状态以及其他计算属性的扩展,或者说是多个可计算属性的聚合。这个聚合通过函数的方式呈现,也可以理解成一个计算过程,计算过程产生的结果,就是计算属性。因此,为了得到计算属性的结果,必须要在需要计算的时机执行响应的计算过程,而这个时机也通过订阅/通知体系来完成。

回顾一下watch选项的动机,想要在某个属性发生变化时执行一些操作,观察的目标是单一的。如果扩展一下:

const v = new Vue({
    data() {
        return {
            a: 1,
            b: 1,
            sum: 2
        }
    },
    watch: {
        a(newValue) {
            this.sum = this.b + newValue
        },
        b(newValue) {
            this.sum = this.a + newValue
        }
    }
})

期望的是sum作为a和b求和的结果,a和b变化的时候可以重新计算。假设watch可以处理这样的情形:

watch: {
    'a+b'(newValue) {
        this.sum = newValue
    }
}

也是可以解决,但是对于复杂计算,Watcher还要具备解析表达式的功能,对于计算属性这个需求来说就太重了。那么关注点回到sum上,访问this.sum的时候通过getter,除了收集依赖的逻辑,就是从数据源拿数据返回出去,如果在getter中知道sum的结果总是通过a+b来确定的,那么就不需要那么多观察,只需要在访问sum的时候去求一下值即可。除此之外,计算属性通常是为了解决多个状态聚合使用时的代码复用性,依赖的属性是响应式的,计算属性也应当具备响应式能力。但是计算属性的结果由计算过程决定,不像普通的属性可以直接复制操作,因此通知并不能在setter中完成(甚至应当屏蔽计算属性的setter过程),考虑到与被聚合状态的一致性,被聚合状态发生改变时会触发计算过程的重新执行,因此计算过程的执行可以作为通知的重要时机。

编写

处理computed选项

在之前代码的基础上,要为Vue增加处理computed选项的能力。计算属性是可以通过实例直接访问到,所以第一步应该是这样的:

/**
 * 处理options中的computed部分
 */
initComputed() {
    let computed = this.$options.computed

    for(let key in computed) {
        const fn = computed[key]

        Object.defineProperty(this, key, {
            get() {
                return fn()
            }
        })
    }
}

这样只实现了一半,只有在主动访问计算属性时,才能计算最新值,这个时候就需要扩展一下Watcher的思路。之前实现的Watcher只能处理单一属性,可以理解为Watcher实例与目标属性建立一对一关系;计算过程包含对多个属性的访问,按照访问就是提交订阅的思路,计算过程会与接触到的属性建立一对多的关系。与单一属性建立关系的Watcher被通知,执行用户绑定的回调;与多个属性建立关系的Watcher被通知,重新执行一下计算过程,然后通知那些订阅了计算属性的Watcher。

扩展Watcher

Watcher可以观察单一属性,现在要让它能够观察一组属性,这一组属性之所以成组,是因为它们在同一个计算过程中被使用。按照这个思路,构造Watcher时,观察目标就需要进行扩展:

!!!注:观察单一属性时,可以理解为只访问该属性的计算过程。因此expOrFn虽然传入的形式不同,但是可以转化成相同的逻辑。

constructor(vm: Vue, expOrFn: string | Function, cb: Function)

被观察的一组属性发生变化时,Watcher实例被通知,首先要重新执行计算过程,那这个执行过程的结果需要进行保存。构造的时候求值是为了提交一组订阅,更新的时候先求值再对外通知,求值的过程可以提出来:

constructor(vm: Vue, expOrFn: string | Function, cb: Function) {
    this.vm = vm
    this.expOrFn = expOrFn
    this.cb = cb

    // 将要观察的目标,不管是单一属性,还是计算过程(相当于一组属性)
    // 统一化保存起来
    if (typeof expOrFn === 'string') {
        // 将观察单一属性的要求,也转换成一个计算过程
        this.getter = function () {
            return vm[expOrFn]
        }
    }

    if (typeof expOrFn === 'function') {
        this.getter = expOrFn
    }

    // 兜底
    if (!this.getter) {
        this.getter = noop
    }

    // 已知对vm.expOrFn进行求值相当于一个订阅
    // 先把自身记录在一个全局的范围内,标记自己是正在求值的观察者
    pushTarget(this)
    this.value = this.get()
    popTarget()
}

/**
 * 求观察目标的值
 * 由于已经将expOrFn转化成统一的形式,这里只需要合理的call一下
 */
get() {
    return this.getter.call(this.vm)
}

扩展后的Watcher已经观察一个计算过程,所以在处理computed属性时,将计算过程提取出来构造一个Watcher实例(后面叫它computedWatcher)。计算过程中用到的那些属性发生变化时,computedWatcher会执行update重新求值,此时应当通知观察了计算属性的那些Watcher。如果Watcher本身也可以观察,那么对计算属性的观察,就可以代理到computedWatcher上。

如果Watcher也能被观察

计算属性可以认为是一个Watcher实例的替身(通过闭包保持一致),因此对计算属性的求值可以借助Watcher的求值;对计算属性的观察,也可以认为是对Watcher的观察。那么Watcher需要一个集合来维护观察者们,并提供一个提交订阅的入口:

// 新增成员
watchers: Watcher[]

// 新增方法
addWatcher(watcher: Watcher) {
    this.watchers.push(watcher)
}

这样就可以让计算属性也被观察:

/**
 * 处理options中的computed部分
 */
initComputed() {
    let computed = this.$options.computed

    for (let key in computed) {
        const fn = computed[key]
        const computedWatcher = new Watcher(this, fn, noop)
        Object.defineProperty(this, key, {
            get() {
                Watcher.target && computedWatcher.addWatcher(Watcher.target)
                return computedWatcher.get()
            }
        })
    }
}

总结

Watcher的基本职责就是,观察一个目标,在目标发生变化时进行更新。而这个目标的概念,在前一篇文章中,是具体的单一状态;如果多个状态相互联系,但结果是单一的,那么这多个状态与单一的结果是等价的,而这个结果是多个状态相互联系的静态表达,这个“联系”才是动态的,有意义的。因此多个状态相互的关联,也可以被当做观察的目标。在此基础上扩展了Watcher的目标。

多个状态的关联,总是在任一状态变化时变化,计算属性就是关联结果。Watcher实例和观察目标之间是一一对应的,同时Watcher通过对目标求值提交注册,因此Watcher在一定程度上和观察目标是统一的。那么Watcher在观察单一状态时,本身就是一对一的关系,而观察多个状态的关系(计算过程)时,和计算结果一一对应,通过Watcher实例可以直达单一状态,或者多个状态的计算结果,而后者则成为computed计算属性的支撑。

通过computed属性指定了一个计算过程,也就是多个状态的相关关系(当然也有可能是单一状态的装饰过程),Watcher将这个计算过程包裹起来,与计算过程的结果直接对应,那么对于computed属性的访问,就是对Watcher实例的访问。而computed属性也具有响应式特征,遂在Watcher上也增加了订阅/通知的行为模式。

前置

Vue的响应式原理的核心,表面上就是观察者模式,state作为被观察的主体,视图等作为观察者。观察者模式(或者叫发布/订阅模式,这里不做纠结)除了观察者和被观察者这两个概念主题,最重要的就是“观察”和“通知”两个行为。从这些内容,不难推断出Vue中核心类—Watcher的基本形态和行为。

观察者模式

观察者模式和发布/订阅模式本质上差距不大,具体实现上可能有其他角色参与,这里不做过多区分。观察者模式是一种处理一对多关系的通知机制,主要由被观察者(Subject)和观察者(Observer)两个抽象角色组成,注意,这里Subject和Observer是概念,不涉及具体实现。不难看出,被观察者通常不关心观察者的具体行为,职责相对单一,及时的通知就已经非常的棒棒了;而观察者则借助抽象,可以有不同的行为。换句话说,被观察者不关心观察者的行为和种类,观察者之间也是相互没有影响。

响应式框架中,关注状态,“忽略”渲染。数据层和表示层借助观察者模式建立联系,而这种联系以及联系的数量并不需要关心,数据层的逻辑可以通过这种联系自动映射到表现层的种种行为。这使得数据层和表示层,在代码层面合理解耦。

Object.defineProperty和Proxy

对象属性的描述对象是JavaScript的一大语言特性,用于限制对象某个属性的访问。访问对象属性可以有getter函数决定具体行为,对象属性赋值可以有setter函数决定具体行为。使用Object.defineProperty可以设置对象属性的描述特征。而Proxy类,可以在目标对象指向,定义很多基本操作的自定义行为,这个范围要比对象属性的描述对象所能涉及的行为要大的多。具体用法在这里就不展开了,都可以实现在数据的访问过程中植入必要的逻辑。

初步学习Vue时,就已经知道其内部通过Object.defineProperty处理组件的状态,使其可以在get时“收集依赖”,在set时“触发更新”。而在最新的Vue3中,使用Proxy代替原来的Object.defineProperty,能够实现在更多行为中植入逻辑。至于Proxy的兼容性这里也不做过多的讨论。

Vue源码中的核心类Watcher

Watcher类是今天讨论的主题,如果按照观察者模式的角色分配,一个Watcher实例就是一个具体的观察者。相应的,被观察者就应当是被Object.defineProperty设置了getter和setter之后的局部状态。Watcher在Vue内部实现了很多功能,源码中对于Watcher的注释是这样的:

/**

  • A watcher parses an expression, collects dependencies,
  • and fires callback when the expression value changes.
  • This is used for both the $watch() api and directives.
    */

可见其行为包括转换表达式,收集依赖以及,当表达式值变更时触发回调,也说明了在源码中的运用点。再结合其具有观察者的身份,必有一个成员变量保存被观察者,或者保存被观察者的标识,另外还需要一个方法来响应被观察者的通知。从这两个基本属性触发,来尝试自己写一写Watcher,从而了解Watcher的基本工作方式。

最最最最最简单的Watcher

极简场景

先来安排一个最简单的场景:

const v = new Vue({
    data() {
        return {
            a: 1,
            test: 0
        }
    },
    watch: {
        a(newValue, oldValue) {
            console.log(newValue, oldValue)
            this.test++
        }
    }
})

v.a = 100
// 期望输出 100 1
console.log(v.test === 1)
// 期望输出 true

关注状态a(状态test仅用于测试结果的表现)和其相应的watch,整个流程其实就是当a发生变化时,打印a的新旧值,并且将test计数+1。安排这个场景,主要是为了解释最基本的Watcher应用,也就是能够解析观察的是谁(状态a),当被观察的对象发生变化时触发什么回调(打印和test计数增加)。

结合之前对于观察者模式的分析,先对Watcher有一个基本规划:

class Watcher {
    target: string // 观察的目标,或者其指示物

    /**
     * 当观察目标发生变化时,以这个方法作为响应
     * 或者说被观察目标调用该方法来实现通知
     */
    update() {}
}

极简Vue

由于Watcher的作用是在Vue实例的内部,由于本文主要关心Watcher工作方式,所以先实现一个极简的Vue作为展示环境。根据之前对观察者模式的分析,先对state进行处理,使其可以作为被观察者存在,也就是具备被订阅和通知的能力。因此先来实现一个极简的Vue,做一些前置和背景操作。

已知Vue应当具备的格式:

// 本例中仅处理原始类型state,且不处理其他options
interface SimpleVueOptions {
    data: () => { [key: string]: any },
    watch: { [key: string]: (newValue?: any, oldValue?: any) => void }
}

class Vue {
    constructor(options: SimpleVueOptions) {}
}

在安排的极简场景下,通过options对象的data属性提供一个用于产生初始状态的函数,同时Vue实例可以进行访问到状态,那么就可以发现Object.defineProperty的第一个用处,使用实例访问状态时进行代理:

class Vue {
        $data: { [key: string]: any }
    constructor(options: SimpleVueOptions) {
        let $data = options.data()
                this.$data = $data
        Object.keys($data).forEach(key => {
            Object.defineProperty(this, key, {
                get() {
                                        // TODO 1
                                        // getter就是从状态集合中得到对应的状态
                    return this.$data[key]
                },
                set(v: any) {
                                        // TODO 2
                                        // setter就是更新状态集合中对应的状态
                    this.$data[key] = v
                }
            })
        })
    }
}

通过这样的处理,可以理解为让Vue实例根据options拥有相应的状态。这个时候,watch也有了具体的含义,观察的不是别人的状态,也不是游离的数据,正是自身的状态。

订阅与通知

观察者模式的主要行为,一个是观察者向被观察者提交订阅,一个是被观察者在自身变化时通知关联的观察者。以下内容可能为了方便描述,会用到发布/订阅等易于表达的概念。

观察者向被观察对象提交订阅,最直观的方式就是主动调阅订阅函数,比如EventTarget.addEventListener()或者EventEmitter.addListener(),这样就需要将状态集合或者状态条目转换成一个具有EventEmitter形式的对象,然后处理watch时进行显式调用,听上去合情合理且可行。但是再想下一步,触发就需要主动调用emit。但是在使用Vue的时候就是简单的属性赋值,因此还是回归到Vue的体系中来。

再想一个提交订阅的方式,在getter的加持下,访问某个属性,就相当于关注这个属性,那么在访问的同时进行订阅就顺理成章了。

// 用一个全局变量当做记录订阅者的容器
// Watcher作为观察者,是一对多的关系
// map的key是观察对象的标识,value是观察者的集合
const WatcherMap = new Map<string, Watcher[]>()

// 下面是一段放大getter的代码
get() {
    WatcherMap.get(key).push(watcher)
    return this.$data[key]
}

watcher这个东西暂时当做一个全局的变量,它在执行某些操作的时候访问属性,因此把它作为观察者记录下来,就相当于存在订阅关系。相应的,setter中进行通知:

set(v: any) {
    this.$data[key] = v
    let currentWatcher = null

class Watcher {
    context: Vue
    cb: Function

    constructor(context: Vue, target: string, cb: Function) {
        this.context = context
        this.cb = cb

        currentWatcher = this
        // 理论上任意形式的访问就会建立关系,这里用于最直观的演示
        let value = context[target]
        currentWatcher = null
    }

    /**
     * 当观察目标发生变化时,以这个方法作为响应
     * 或者说被观察目标调用该方法来实现通知
     */
    update() {
        // 先不去考虑其他参数,展示最最简单的格式
        this.cb.call(this.context)
    }
}

interface SimpleVueOptions {
    data: () => { [key: string]: any },
    watch: { [key: string]: (newValue?: any, oldValue?: any) => void }
}

// 用一个全局变量当做记录订阅者的容器
// Watcher作为观察者,是一对多的关系
// map的key是观察对象的标识,value是观察者的集合
const WatcherMap = new Map<string, Watcher[]>()

class Vue {
    constructor(options: SimpleVueOptions) {
        let state = options.data()
        Object.keys(state).forEach(key => {
            Object.defineProperty(this, key, {
                get() {
                    WatcherMap.get(key).push(currentWatcher)
                    return state[key]
                },
                set(v: any) {
                    state[key] = v
                    // 通知观察这个key的watcher,watcher响应目标变化的动作是update这个不能忘记
                    WatcherMap.get(key).forEach(watcher => watcher.update())
                }
            })
        })
    }
}

这里忽略了很多细节,不做具体的解释。

截止到这里,Vue实例具备了该有的状态,并且访问状态会建立联系,并在修改状态时进行通知。如何使用,进入到下面一步处理options中的watch。

处理watch选项

options中提供的watch选项看成key-value模式的话,key就是被观察目标的标识,之所以称之为标识,因为需要凭借此标识进行一定的操作才能真正触及到被观察的目标;value则是被观察目标发生变化时要执行的动作,也就是观察者接到通知后要执行的动作。

let keys = Object.keys(this.$options.watch)

keys.forEach(key => {
    new Watcher(this, key, this.$options.watch[key])
})

因此,初始化Watcher必然需要:1.这里先叫上下文,不和什么组件概念扯在一起,也就是用于将被观察目标的标识转化成被观察目标本身的上下文;2.被观察目标的标识;3.响应通知的方法。有了前两个要素,就可以触及到真正的观察目标,这个过程在getter的加持下成为“订阅”的过程。而第三个要素,则是setter中通知观察者后,观察者在update方法中有了具体的执行动作。

针对watch选项中的每一组key-value都会初始化一个Watcher实例,会发现其实遗漏了一个很重要的点:通过context和target可以触及到被观察目标,但是被观察目标在getter中要记录是谁观察了自己。所以Watcher实例在触及观察目标之前,将自身暴露出去。

let currentWatcher = null

class Watcher {
    context: Vue
    cb: Function

    constructor(context: Vue, target: string, cb: Function) {
        this.context = context
        this.cb = cb

        currentWatcher = this
        // 理论上任意形式的访问就会建立关系,这里用于最直观的演示
        let value = context[target]
        currentWatcher = null
    }

    /**
     * 当观察目标发生变化时,以这个方法作为响应
     * 或者说被观察目标调用该方法来实现通知
     */
    update() {
        // 先不去考虑其他参数,展示最最简单的格式
        this.cb.call(this.context)
    }
}

小结一下

先把之前的这些代码片段都整理起来:

let currentWatcher = null

class Watcher {
    context: Vue
    cb: Function

    constructor(context: Vue, target: string, cb: Function) {
        this.context = context
        this.cb = cb

        currentWatcher = this
        // 理论上任意形式的访问就会建立关系,这里用于最直观的演示
        let value = context[target]
        currentWatcher = null
    }

    /**
     * 当观察目标发生变化时,以这个方法作为响应
     * 或者说被观察目标调用该方法来实现通知
     */
    update() {
        // 先不去考虑其他参数,展示最最简单的格式
        this.cb.call(this.context)
    }
}

interface SimpleVueOptions {
    data: () => { [key: string]: any },
    watch: { [key: string]: (newValue?: any, oldValue?: any) => void }
}

// 用一个全局变量当做记录订阅者的容器
// Watcher作为观察者,是一对多的关系
// map的key是观察对象的标识,value是观察者的集合
const WatcherMap = new Map<string, Watcher[]>()

export default class Vue {
    constructor(options: SimpleVueOptions) {
        let state = options.data()
        Object.keys(state).forEach(key => {
            Object.defineProperty(this, key, {
                get() {
                    let ws = WatcherMap.get(key)
                    if(ws && currentWatcher) ws.push(currentWatcher)
                    return state[key]
                },
                set(v: any) {
                    state[key] = v
                    // 通知观察这个key的watcher,watcher响应目标变化的动作是update这个不能忘记
                    let ws = WatcherMap.get(key)
                    ws && ws.forEach(watcher => watcher.update())
                }
            })
        })

        let keys = Object.keys(options.watch)

        keys.forEach(key => {
            new Watcher(this, key, options.watch[key])
        })
    }
}

应对之前提到的极简场景是没有问题的,再结合已有的Vue实践经验,新旧值应当在watch中都能获取,因此还需要在Watcher中附加保留求值的能力,这也方便之后实现lazy等特性。

Watcher实例在对观察目标求值的时候,需要将自身暴露出去方便进行记录,而这种暴露方式基本原则肯定是借助全局作用域,Vue源码中的做法则是将其通过静态变量的形式附加在Dep类上,这样稍微降低访问权限,并且代码上更加统一。并且,考虑到复杂场景,被观察目标如果也是一个需要观察他人的对象(比如watch一个computed属性),正在求值的Watcher实例应当在特定的阶段先休息一下,防止A观察B,B观察C时跨级别建立联系。因此Vue源码中将“暴露”这一行为,使用栈作为休息区,既能保证观察关系,又能保证当前求职者的唯一性。

对于state的处理,极简的处理使用Object.defineProperty,并在getter和setter中通过一个全局的容器来保存关系,而这个容器实际上并不会再被其他的作用域使用,所以这一部分的逻辑应当封装起来,也就是Vue源码中的Observer类。

通过处理极简场景,Watcher的最基本工作流程大致如此,同时还间接的看到了Observer类和Dep类的身影。下一篇文章试图本文的基础上,看看最基本的Observer类和Dep类是怎么协同工作的。