前言

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

标签: none

添加新评论