【抽丝剥茧】高仿一个Vue2.x中的观察者模式Observer

603 阅读8分钟

零、目标原型

本文的目标就是高仿一个Vue2.x源码的实现来完成一个基础的Observer,但是我们的目的并不是为了实现而仿写,而是在搞懂Vue2.xObserver的实现原理的基础上再去手动实践,从而让我们对它有一个更深入更具体的认知和理解。

我们要做的目标功能大概像下面这个样子:

前言

Vue3.x已经在2020年9月18日正式发布了,对应的中文文档也已经翻译完毕了,同时也配备了2.x升级到3.x的升级指南。

诚然,Vue3相比于Vue2无论是运行性能还是代码实现上都有了很大的提升,必定有很多值得我们去探索和研究的东西。但是作为一个曾经的只会JavaWeb和jQuery的开发仔,在第一次接触到Vue2.x时从内心深处发出了这样的惊叹:这难道就是魔法吗?(jQuery党的究极福音)。

要说Vue2.x中最典型的功能,“首当其冲”的一定就是数据的双向绑定了,那么双向绑定和观察者模式又有什么关系呢?让我们一一道来。

一、双向绑定

在说双向绑定之前,我们要先知道什么是单向绑定:单向绑定非常简单,就是把Model绑定到View,当我们用JavaScript更新Model时,View就会自动更新。

其中的Model和View指的是经典的开发模式MVC中的M和V,即数据模型Model和用户视图View,而C则是控制器Controller。使用MVC的目的是将M和V的实现代码分离,从而使同一个程序可以使用不同的表现形式。C存在的目的则是确保M和V的同步,一旦M改变,V应该同步更新。

有单向绑定就有双向绑定:在单向绑定的基础上,如果用户更新了View,Model对应的数据也会自动被更新,这种情况就是双向绑定。由此便有了MVVM的概念:

MVVM最早是由微软提出来的,它借鉴了桌面应用程序的MVC思想;在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。Vue就是一个典型的MVVM框架,而其中这个ViewModel的底层实现就需要使用到双向绑定。

因此,我们要想实现双向绑定,就意味着我们要用JavaScript实现一个ViewModel出来,而这个ViewModel在Vue2.x的源码中对应着Observer,即观察者模式。

二、观察者模式

观察者模式(Observer):通常又被称作为发布-订阅者模式。它定义了一种一对多的依赖关系,即当一个对象的状态发生改变的时候,所有依赖于它的对象都会得到通知并自动更新,解决了主体对象与观察者之间功能的耦合。

以下任一场景都可以使用观察者模式:

  1. 当一个抽象模型有两个方面,其中一个方面依赖于另一方面。讲这两者封装在独立的对象中可以让它们可以各自独立的改变和复用;
  2. 当一个对象的改变的时候,需要同时改变其它对象,但是却不知道具体多少对象有待改变;
  3. 当一个对象必须通知其它对象,但是却不知道具体对象到底是谁。换句话说,你不希望这些对象是紧密耦合的。

那么,Vue又是如何实现观察者模式的呢?那就不得不说一下Object.defineProperty() 了。

三、Object.defineProperty

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

MDN上,我们可以看到更详细的关于Object.defineProperty()的描述。其中,和Observer有莫大关系的就是它的第三个参数descriptor

属性描述符descriptor,即要定义或修改的属性描述符。对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。

数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者。

这两种描述符都是对象。它们共享以下可选键值(默认值是指在使用 Object.defineProperty() 定义属性时的默认值):

  1. configurable

    • 当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除;
    • 默认为 false
  2. enumerable

    • 当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中;
    • 默认为 false。

数据描述符还具有以下可选键值:

  1. value
    • 该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。
    • 默认为 undefined
  2. writable
    • 当且仅当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被赋值运算符改变。
    • 默认为 false

存取描述符还具有以下可选键值:

  1. get
    • 属性的 getter 函数,如果没有 getter,则为 undefined;
    • 当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象);
    • 该函数的返回值会被用作属性的值;
    • 默认为 undefined。
  2. set
    • 属性的 setter 函数,如果没有 setter,则为 undefined;
    • 当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。
    • 默认为 undefined

描述符可拥有的键值:

configurableenumerablevaluewritablegetset
数据描述符可以可以可以可以不可以不可以
存取描述符可以可以不可以不可以可以可以

如果一个描述符不具有 valuewritablegetset 中的任意一个键,那么它将被认为是一个数据描述符。如果一个描述符同时拥有 valuewritablegetset 键,则会产生一个异常。

记住,这些选项不一定是自身属性,也要考虑继承来的属性。为了确认保留这些默认值,在设置之前,可能要冻结 Object.prototype,明确指定所有的选项,或者通过 Object.create(null)__proto__ 属性指向 null

可以看到,我们通过定义存取描述符对象中的setget这两个属性对应的函数,来达到自定义对目标对象的读写行为。举个最简单的例子:

const target = Object.create(null)

Object.defineProperty(target, 'name', {
    enumerable: true,
    configurable: true,
    get () {
        return 'Hello Vue'
    },
    set (value) {
        console.log(`我接收到了${value},但是我啥事也不做`)
    }
})

target.name = 'Vue' // 我接收到了Vue,但是我啥事也不做
target.name   // Hello Vue

四、数据劫持

而数据劫持的首要前提就是我们要处理的数据必须是一个对象,因为Object.defineProperty()只能处理对象。而如果我们要想对对象进行属性劫持,那么就需要遍历这个对象的各个属性值来完成劫持动作,我们可以通过以下代码实现:

Object.keys(obj).forEach(key => defineReactive(obj, key))

其中defineReactive是我们实现属性劫持的关键所在,我们先定义一个最简单的劫持模板:

function defineReactive (data, key) {
    let oldValue = data[key]
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get () {
            return oldValue
        },
        set (newValue) {
            oldValue = newValue
        }
    })
}

仔细看这个方法仿佛什么都没做,但是好像又做了点什么,其中值得我们注意的是:

  1. oldValue代理了对data对象key属性值的控制权,即以后对key属性值的读写实质都是在操作oldValue
  2. enumerable设置为true,保证了key可以被正常遍历读取;
  3. configurable设置为true,保证了后续用户可以对该属性值二次配置,要知道configurable设置为false是单向的,即不能再改为true,也不能对其进行二次配置。

接下来,我们需要在这个模板的基础上实现观察模式:

function defineReactive (data, key) {
    let oldValue = data[key]
    let dep = new Dep()
    let childOb = observe(oldValue)
    Object.defineProperty(data, key, {
        configurable: true,
        enumerable: true,
        get () {
            dep.depend()
            if (childOb) {
                childOb.dep.depend()
            }
            return oldValue
        },
        set (newValue) {
            if (newValue === oldValue) {
                return
            }
            oldValue = newValue
            childOb = observe(newValue)
            dep.notify()
        }
    })
}

我们可以看到两个陌生的面孔:Depobserve,在开始代码解析之前,我们先来认识下这两个新朋友,看看它们大概是干什么的:

class Dep {
    constructor () {
        this.deps = new Set()
    }
    depend () {
        if (Dep.target) {
            this.deps.add(Dep.target)
        }
    }
    notify () {
        this.deps.forEach(watcher => watcher.update())
    }
}
Dep.target = null

function observe (target) {
    if (!isObject(target)) {
        return
    }
    let ob
    if (hasOwnKey(target, '__ob__') && target.__ob__ instanceof Observer) {
        ob = target.__ob__
    } else {
        ob = new Observer(target)
    }
    return ob
}

其中Dep是一个构造函数,它有一个实例属性deps,两个实例方法dependnotify,以及一个默认值为null静态属性target;而observe函数也比较简单,就是返回了一个Observer的实例对象。

那么Observer又是什么呢? 我们继续看:

class Observer {
    constructor (value) {
        this.value = value
        this.dep = new Dep()
        def(value, '__ob__', this, false)
        if (Array.isArray(value)) {
            if (hasProto()) {
                setPrototype(value, ArrayPrototypeCopy)
            } else {
                copyProperty(value, ArrayPrototypeCopy, arrayKeys)
            }
            this.observeArray(value)
        } else {
            this.observeObject(value)
        }
    }

    observeArray (array = []) {
        array.forEach(item => observe(item))
    }

    observeObject (obj = {}) {
        Object.keys(obj).forEach(key => defineReactive(obj, key))
    } 
}

看到这里,大家应该差不多就已经明白了,Observer就是我们最终要实现的东西,而它所做的事情就是分不同情况去调用了observedefineReactive,不要把它想得太复杂,其实很简单。

五、逐行分析Observer

下面,就让我们逐行来分析Observer,搞清楚它到底做了哪些事情。

this.value = value
this.dep = new Dep()
def(value, '__ob__', this, false) 

这里最重要的就是它给实例对象创建了一个名为dep、值为Dep实例的一个属性;同时又使用def方法操作了下valuethisdef方法定义如下:

function def (target, key, value, enumerable) {
    Object.defineProperty(target, key, {
        value,
        configurable: true,
        writable: true,
        enumerable: !!enumerable
    })
}

可以看到其实质是为value手动添加了一个名为__ob__、值为this的属性值,其中值得注意的是enumerable指定为false意味着它不希望__ob__不会被枚举遍历到。

if (Array.isArray(value)) {
    if (hasProto()) {
        setPrototype(value, ArrayPrototypeCopy)
    } else {
        copyProperty(value, ArrayPrototypeCopy, arrayKeys)
    }
    this.observeArray(value)
} 

这里涉及到hasProto()setPrototype()copyProperty,我们先来看下它们是做什么的:

export function hasProto () {
    return ({ __proto__: [] } instanceof Array)
}

export function setPrototype (target, prototype) {
    if (Object.setPrototypeOf) {
        Object.setPrototypeOf(target, prototype)
    } else {
        target.__proto__ = prototype
    }
}

export function copyProperty (target, src, keys) {
    keys.forEach(key => def(target, key, src[key], false))
}

其中有以下几点需要注意下:

  1. ({ __proto__: [] } instanceof Array)充分使用了instanceOf的原理(参考【你不知道的JavaScript】搞懂了再手写)来检测当前运行时的Array是否拥有__proto__属性值,即具有原型链;
  2. target.__proto__ = prototype,这也是Vue不兼容IE8及以下浏览器的原因之一。

再回到我们的Observer中的代码:

if (Array.isArray(value)) {
    if (hasProto()) {
        setPrototype(value, ArrayPrototypeCopy)
    } else {
        copyProperty(value, ArrayPrototypeCopy, arrayKeys)
    }
    this.observeArray(value)
} else {
    this.observeObject(value)
}

observeArray (array = []) {
    array.forEach(item => observe(item))
}

observeObject (obj = {}) {
    Object.keys(obj).forEach(key => defineReactive(obj, key))
} 

结合以上分析的内容,我们可以知道Observer做了以下几件事:

  1. 声明dep实例属性,给源对象挂载__ob__不可枚举属性;
  2. 如果源数据是数组,则修改它的原型对象为指定对象ArrayPrototypeCopy,并遍历每一个元素执行observe(item)
  3. 如果源对象是对象,则遍历它的属性执行defineReactive(value, key)

ArrayPrototypeCopy放在稍后单独讨论,兜兜转转绕了一圈,我们又回到了defineReactive()方法,充分说明了它的重要性。接下来我们再回到它的实现上来继续深入分析。

六、逐行分析defineReactive

function defineReactive (data, key) {
    let oldValue = data[key]
    let dep = new Dep()
    let childOb = observe(oldValue)
    Object.defineProperty(data, key, {
        configurable: true,
        enumerable: true,
        get () {
            dep.depend()
            if (childOb) {
                childOb.dep.depend()
            }
            return oldValue
        },
        set (newValue) {
            if (newValue === oldValue) {
                return
            }
            oldValue = newValue
            childOb = observe(newValue)
            dep.notify()
        }
    })
}

其中大部分的代码我们已经分析过了,这里我们来重点分析下我们没见过的:

let dep = new Dep()
let childOb = observe(oldValue)

对JavaScript理解得比较深入的同学可能已经猜到了,这里利用闭包的原理声明了两个变量depchildOb,保证了它们在目标对象datakeygetter()setter()方法如论何时被调用都可以访问到它们。在接下来你会发现使用这个闭包的微妙之处:

get () {
    dep.depend()
    if (childOb) {
        childOb.dep.depend()
    }
    return oldValue
}

其中dep.depend()childOb.dep.depend(),结合Dep的定义,我们会发现depend永远啥事也做不了:

depend () {
    if (Dep.target) {
        this.deps.add(Dep.target)
    }
}

因为我们默认设置了Dep.target = null,那么为什么还要这样做呢?其实质是利用了JavaScript是单线程执行的这条铁律,和最后我们要讲到的Watcher结合在一起使用就可以完美实现依赖收集

set (newValue) {
    if (newValue === oldValue && newValue !== newValue) {
        return
    }
    oldValue = newValue
    childOb = observe(newValue)
    dep.notify()
}

看到newValue !== oldValue是不是一时反应不过来,怎么会有自己不等于自己的值呢?想到这里是不是就想到了NaN。其实这就是用来避免newValue的值是NaN而避免不必要的后续执行。

此外,该方法还会保证如果新旧值浅比较相等的话也不做任何事情,以为这也是无谓的操作。

最后,set方法会把childOb的变量值重置为newValue经过observe方法处理后的值,保证后面再对该key进行get时可以获得最新的childOb(其深层含义就是每一次重新赋值后,取值操作都会重新进行依赖收集depend)。

最后的最后,会调用dep.notify(),即将我们使用depend()方法添加到dep对象的deps属性值中的Dep.target们逐一按序执行。

七、处理数组ArrayPrototypeCopy

在进入Watcher之前,我们先回到ArrayPrototypeCopy的定义。

我们已经知道了Observer在处理数组类型的数据data时会先使用合适的方法(setPrototypecopyProperty)将ArrayPrototypeCopy置为data的原型链引用_proto_或遍历拷贝到data对象上(注意要指定为enumerable: false使其不能被枚举)。

那么这样做的目的是什么呢?这其实利用JavaScript中的原型链知识来修改数组对象data的默认行为,我们来大概描述一下原型链 [[Prototype]]

[[Prototype]] 机制就是存在于对象中的一个内部链接,它会引用其他对象。

通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在 [[Prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的 [[Prototype]] ,以此类推。这一系列对象的链接被称为“原型链”。

因此,我们之后再对data进行一些本身不存在而ArrayPrototypeCopy上存在的属性操作时,就会执行ArrayPrototypeCopy上定义的行为。

我们来看下ArrayPrototypeCopy的定义:

const ArrayPrototypeCopy = Object.create(Array.prototype)  

const arrayMethods = [
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'reverse',
    'sort'
]

arrayMethods.forEach(method => {
    const original = Array.prototype[method]
    def(ArrayPrototypeCopy, method, function mutator (...args) {
        const result = original.apply(this, args)
        const ob = this.__ob__
        let inserted
        switch (method) {
            case 'push':
            case 'unshift':
                inserted = args
                break
            case 'splice':
                inserted = args.slice(2)
                break
        }

        if (inserted) {
            ob.observeArray(inserted)
        }
        ob.dep.notify()
        return result
    })
})

可以看到,ArrayPrototypeCopy的原始值就是一个以Array.prototype作为[[Prototype]]的空对象,这里保证了对数组对象的最大兼容性。

之后在此基础上遍历指定的方法名集合arrayMethods使用defineProperty对它进行属性设置,而每一个属性对应的属性值都有mutator函数来完成,所以,我们来重点关注下mutator的实现:

function mutator (...args) {
    // 执行源操作,取得应有的结果值
    const result = original.apply(this, args)
    // 获取这个被处理过的数组对象上面的`Observer`实例
    const ob = this.__ob__
    let inserted
    switch (method) {
        case 'push':
        case 'unshift':
            inserted = args
            break
        case 'splice':
            inserted = args.slice(2)
            break
    }
    // inserted不为空,则以为这该操作涉及到了向源数组添加了新的数据
    // 需要调用`ob.observeArray`处理它们使得它们也是可追踪的
    if (inserted) {
        ob.observeArray(inserted)
    }
    // 最后会调用该数组对应的`Observer`实例对象上的`dep`实例调用`notify()`通告它的"订阅者"(依赖者)们它发生了变化。
    ob.dep.notify()
    return result
})

从中我们可以发现,其实质就是代理了对数组对象的原有操作行为,从而达到“操作劫持”的目的,即可以在这些指定方法在操作数组时可以触发响应的notify

八、Observer和Watcher的梦幻联动

讲到这里,我们大概已经搞明白了Observer完整实现的大部分,总结下来就是:

  1. 对目标对象进行属性的遍历从而属性劫持;
  2. 对于目标对象的每一个属性key操作都会对当前key的词法作用域中的变量dep做两件事:
    • 取值时,调用dep.depend()和/或childOb.dep.depend(),将此时的Dep.target添加到的dep.deps
    • 赋值时,调用dep.notify,将dep.deps遍历执行其中的每一个子元素。

可以发现,在ObserverDep中,我们只对Dep.target进行了取值,却没有对它进行赋值,而且如果没有对它进行赋值的话,dep.depend一直都是啥事也做不了。那么,我们什么时候会对Dep.target进行赋值呢?

一切答案都在Watcher中。

8.1 Watcher是什么

Watcher见名识意,就是观察者和Observer的中文含义有异曲同工之妙。但是我们有了Observer,为什么还要搞一个Watcher呢?

还记得我们在文章开头讲过,我们要实现MVVM中的VM吗?而VM要做的事情就是可以使得数据和视图可以双向绑定,即:改变数据可以驱动视图的更新改变视图又能够改变数据的内容

此时的Observer并没有达到这个效果,它只实现了针对数据data的处理,却没有任何对视图做更新的操作和实现,而Watcher就是用来做这件事情的。

即,Watcher是用来将View和Model联合在一起的粘合剂,是用来实现数据双向绑定的"药引子":

import Dep from "./dep.js"

const watcherStack = []
function pushWatcher (watcher) {
    if (Dep.target) {
        watcherStack.push(Dep.target)
    }
    Dep.target = watcher
}

function popWatcher () {
    Dep.target = watcherStack.pop()
}

const noop = () => {}

export default class Watcher {
    constructor (vm, expOrFn, callback = noop, options = {}) {
        const { compute } = options
        this.vm = vm
        this.callback = callback
        this.compute = compute
        this.value = undefined
        this.getter = noop
        if (typeof expOrFn === 'function') {
            this.getter = expOrFn
        } else if (typeof expOrFn === 'string') {
            this.getter = this.parseExp2Fn(expOrFn)
        }
        if (this.compute) {
            this.dep = new Dep()
        } else {
            this.value = this.get()
        }
    }

    depend () {
        this.dep.depend()
    }

    get () {
        pushWatcher(this)
        const value = this.getter.call(this.vm, this.vm)
        popWatcher()
        return value
    }

    update () {
        const oldValue = this.value
        const newValue = this.get()
        if (oldValue === newValue) {
            return
        }
        this.callback(newValue, oldValue)
        if (this.compute) {
            this.dep.notify()
        }
    }

    parseGetter (expOrFn) {
        const getter = () => {
            let value = this.vm
            const expArr = expOrFn.split('.')
            expArr.forEach(exp => {
                exp = exp.trim(exp)
                value = value[exp]
            })
            return value
        }
        return getter
    }
}

8.2 Watcher做了什么

Watcher的实现内容脱离不了它和Dep.target的关系,因为在我们的ObserverDep.target一直是个null

那么我们要做的第一件事就是Watcher是如何操作Dep.target的:

const watcherStack = []
function pushWatcher (watcher) {
    if (Dep.target) {
        watcherStack.push(Dep.target)
    }
    Dep.target = watcher
}

function popWatcher () {
    Dep.target = watcherStack.pop()
}

可以看到,Watcher在本地维护了一个,即遵从先进后出的原则。我们只能通过pushWatcherpopWatcher这两个方法来操作这个,如果你直接修改这个,会发生预期之外的效果。

这里尤其需要注意的是,先进后出的原则是为了和JavaScript(或者说大部分编程语言)中函数(方法)的执行顺序保持一致,这也是执行栈压栈出栈名字的由来。

我们会发现在执行pushWatcher(target)的时候会对Dep.target进行赋值,而在此之前还会把之前已存在的Dep.target压进中,而在执行popWatcher()时,Dep.target又会被重置为本地尾部的这个值。

进一步分析pushWatcherpopWatcher被调用的地方:

get () {
    pushWatcher(this)
    const value = this.getter.call(this.vm, this.vm)
    popWatcher()
    return value
}

这时候是不是豁然开朗了,get()方法会先把this进行压栈,然后执行getter(),执行结束再把this进行出栈,最后返回getter()执行返回的结果。

此时,之前的所有疑惑都被揭开了:

  1. Dep.target会在执行Watcher实例的get()方法时被赋值为这个实例watcher;
  2. dep.depend()在执行时,如果存在会将此时的Dep.target也即watcher收集到dep.deps中;
  3. dep.notify()在执行是,会将dep.deps中收集到的所有watcher实例按序执行它们的update()方法。

那么,接下来我们就重点分析下update()方法:

update () {
    const oldValue = this.value
    const newValue = this.get()
    if (oldValue === newValue) {
        return
    }
    this.callback(newValue, oldValue)
    if (this.compute) {
        this.dep.notify()
    }
}

不考虑compute的情况下,update做的事情非常简单,就是再次执行get()方法得到当前的值newValue,和oldValue浅比较,如果不相等的话直接调用对应的callback(newValue, oldValue)

那么,data是什么时候勾搭上watcher的呢?其实,它们存在一个先后关系:即先使用Observer把源对象data变为一个可追踪的对象reactiveData,再使用reactiveData传参给new Watcher(reactiveData, getter, callback, ...)

所以,我们很有必要来瞅一眼Watcher的构造函数:

constructor (vm, getter, callback = noop, options = {}) {
    const { compute } = options
    this.vm = vm
    this.callback = callback
    this.compute = compute
    this.value = undefined
    this.getter = noop
    if (typeof expOrFn === 'function') {
        this.getter = expOrFn
    }
    if (this.compute) {
        this.dep = new Dep()
    } else {
        this.value = this.get()
    }
}

先不看compute相关的内容,我们会发现构造函数值做了以下几件事:

  1. vmcallbackgetter挂载到watcher实例对象上;
  2. 调用一次get()方法,将它的返回值赋值给this.value

大家看到这里应该就明白了,get()方法的默认首次执行就是一切魔法的开端。

  1. 使用Observer把源对象data变为一个可追踪的对象reactiveData
  2. 使用reactiveData和指定的gettercallback调用new Watcher(...)
    • Watcher的构造函数会调用get()
      • 调用pushWatcher(this)watcher挂载到Dep.target上;
      • 执行this.getter.call(reactiveData, reactiveData)
        • 如果getter中含有对reactiveData下某属性值key的访问([Get])则会触发key对应的dep进行依赖收集:dep.depend(),即dep.deps.add(Dep.target)
      • 执行结束调用popWatcher(),将Dep.target重置为之前的值(栈的尾部值即为上一次操作的值)
      • 返回getter()执行的结果值。
  3. 之后,在此基础上去改变reactiveData的某个属性key的值就发生以下的事情:
    • 触发key对应的depnotify()
      • 遍历dep.deps得到各个watcher,按序执行watcher.update()
        • 调用get()获取最新的值value(具体过程同上2-1)
        • 调用callback(value, oldValue)

其中getter函数的定义就很重要了,如果它里面涉及到了对reactiveData属性值的访问,那么数据和视图的绑定关系也就自然而然的关联到一起了。(Vue中通过指定updateComponent作为getter来完成视图和数据的绑定的):

updateComponent = () => {
    vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
    before () {
        if (vm._isMounted && !vm._isDestroyed) {
            callHook(vm, 'beforeUpdate')
        }
    }
}, true /* isRenderWatcher */)  

至此,我们已经基本上搞明白了Watcher做的事情,把它再和Observer联动在一起便有了我们想要的数据变化驱动视图更新。我们现在可以如使用如下代码来尝试下:

<!-- html -->
<div id="app">
    <p>count的值:<span id="count"></span></p>
    <p>deep的name的值:<span id="deep"></span></p>
    <p>list的内容:<span id="list"></span></p>
</div>
// JavaScript
import { Observer } from './observer/index.js'
import Watcher from './observer/watcher.js'

const data = {
    count: 0,
    deep: {
        name: 'ZhangSan'
    },
    list: [1,2,3,4,5]
}

new Observer(data)

new Watcher(data, () => {
    document.getElementById('count').innerHTML = data.count
})

new Watcher(data, () => {
    document.getElementById('deep').innerHTML = data.deep.name
})

new Watcher(data, () => {
    document.getElementById('list').innerHTML = data.list.join(',')
})

const bindClickEvent = (id, event) => {
    document.getElementById(id).addEventListener('click', event, false)
}

bindClickEvent('btn1', () => (data.count = data.count + 1))
bindClickEvent('btn2', () => (data.list.push(1)))
bindClickEvent('btn3', () => (data.list.pop()))
bindClickEvent('btn4', () => (data.deep.name = 'LiSi'))

效果是这个样子:

九、compute和watch

Watcher中有一个compute一直被我们忽略不讲,目的就是为了最后watch一起讲。因为从实现上来说,watchcompute只需要使用合适的参数调用new Watcher()就可以实现它们预期的功能。

9.1 watch

watch的功能很简单,就是观察给定对象data的指定属性key,当这个key的属性值发生改变时,它会做一些事情,而这个事情只需要指定在callback参数里就可以了。

因此,watch的实现已经可以确定了:

function watch (reactiveData, expOrFn, callback) {
    new Watcher(reactiveData, expOrFn, callback)
}

我们的一般用法会指定expOrFnreactiveData点操作符取值方式,如data.deep.name对应的expOrFn就是deep.name,我们可以在上面例子的基础上加入watch的用法:

<!-- html -->
<p id="callback"></p>

// JavaScript
watch(data, 'deep.name', (newValue) => {
    document.getElementById('callback').innerHTML = (`修改后的deep的name值是${newValue}`)
})

效果如下:

9.2 compute

compute的功能相对watch要复杂一点,因为它需要在依赖data指定属性修改操作的基础上还需要做以下几件事:

  1. data对象上创建一个新的属性,初始值是compute指定的getter的返回值;
  2. compute依赖的getter中任一data的其他属性发生改变时需要反应到compute的值上;
  3. 因此,包含compute的视图依赖computecompute依赖data的其他属性值。

在此基础上,我们基本就可以搞明白Watcher中对compute的处理了:

constructor (vm, expOrFn, callback = noop, options = {}) {
    ...
    this.compute = options.compute
    // compute值可能会被视图使用,因此需要创建一个dep用于收集这些依赖
    if (this.compute) {
        this.dep = new Dep()
    } else {
        this.value = this.get()
    }
}

update () {
    ...
    if (this.compute) {
        this.dep.notify()
    }
}
// 手动调用depend将此时正在执行的视图watcher收集到自身的dep中
depend () {
    this.dep.depend()
}

我们可以在这个的基础上进一步实现我们的compute方法:

function compute (vm, name, getter, callback) {
    const computeWatcher = new Watcher(vm,  getter, callback, { compute: true })
    Object.defineProperty(vm, name, {
        get () {
            // 收集依赖(即compute变量收集对它有依赖的watcher)
            computeWatcher.depend()
            // 调用watcher.get(),把compute变量自身的watcher给那些它所依赖的其他变量做收集
            const value = computeWatcher.get()
            return value
        }
    })
}

我们来深入分析下compute的实现原理:

  1. const computeWatcher = new Watcher(vm, getter, callback, { compute: true })创建了一个计算watcher,其实质是创建了一个没有初始值、但是可以手动收集视图依赖的watcher;
  2. Object.defineProperty(vm, name, { ... },给vm定义了一个键位name的属性,并代理了它的取值操作,即getter
  3. computeWatcher.depend(),收集依赖(即compute变量收集对它有依赖的watcher)。
  4. const value = computeWatcher.get(),则会把compute变量自身的watcher给那些它所依赖的其他变量做收集。

最后,我们把compute迭代到上面的例子中:

<!-- html -->
<p>计算属性other的值:<span id="other"></span></p>

// JavaScript
function showCompute () {
    document.getElementById('other').innerText = data.other
}
// 创建一个计算属性other
compute(data, 'other', () => { return data.count + data.list[0] }, showCompute)

showCompute()

效果如下:

十、写在最后

到这儿,Observer的相关内容也差不多介绍完了。虽然和Vue2.x源码中的Observer有诸多出入,但是应该算是一个最小化的,可以帮你快速理解和掌握Observer的版本了。在我看来这才是Vue最初应有的样子,而后续的所有都是渐进式地累加上来逐渐丰富起来的。

Vue2.x中还有很多值得我们深入研究的内容,如nextTick的实现,createElement的巧妙实现组件的递归渲染,patch中的diff算法等等。每一个点都值得深挖和思考总结。

学习一门复杂的学问,我们不仅要有【循序渐进】的思路,也要有【抽丝剥茧】的精神。最后希望大家学习快乐,干饭有劲儿~