前置

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类是怎么协同工作的。

标签: none

添加新评论