likes
comments
collection
share

深入解析Vue依赖收集原理

作者站长头像
站长
· 阅读数 56

一、先谈观察者模式

观察者模式是一种实现一对多关系解耦的行为设计模式。它主要涉及两个角色:观察目标、观察者。如图:

它的特点:观察者要直接订阅观察目标观察目标一做出通知,观察者就要进行处理(这也是观察者模式区别于发布/订阅模式的最大区别)

解释: 有些地方说观察者模式和发布/订阅模式是一样的,其实是不完全等同的,发布/订阅模式中,其解耦能力更近一步,发布者只要做好消息的发布,而不关心消息有没有订阅者订阅。而观察者模式则要求两端同时存在

观察者模式,实现如下:

// 观察者集合
class ObserverList {
    constructor() {
        this.list = [];
    }
    add(obj) {
        this.list.push(obj);
    }
    removeAt(index) {
        this.list.splice(index, 1);
    }
    count() {
        return this.list.length;
    }
    get(index) {
        if (index < 0 || index >= this.count()) {
            return;
        }
        return this.list[index];
    }
    indexOf(obj, start = 0) {
        let pos = start;
        while (pos < this.count()) {
            if (this.list[pos] === obj) {
                return pos;
            }
            pos++;
        }
        return -1;
    }
}
// 观察者类
class Observer {
    constructor(fn) {
        this.update = fn;
    }
}
// 观察目标类
class Subject {
    constructor() {
        this.observers = new ObserverList(); 
    }
    addObserver(observer) {
        this.observers.add(observer);
    }
    removeObserver(observer) {
        this.observers.removeAt(
            this.observers.indexOf(observer)
        );
    }
    notify(context) {
        const count = this.observers.count();
        for (let i = 0; i < count; ++i) {
            this.observers.get(i).update(context);
        }
    }
}

现在,假设我们需要在数据A变更时,打印A的最新值,则用上述的代码实现如下:

const observer = new Observer((newval) => {
    console.log(`A的最新值是${newval}`);
})
const subject = new Subject();
subject.addObserver(observer);
// 现在,做出A最新值改变的通知
> subject.notify('Hello, world');
// 控制台输出:
< 'Hello, world'

二、Vue与Vue的依赖收集

~~Vue是一个实现数据驱动视图的框架~~(废话,大家都知道,说重点) 我们都知道,Vue能够实现当一个数据变更时,视图就进行刷新,而且用到这个数据的其他地方也会同步变更;而且,这个数据必须是在有被依赖的情况下,视图和其他用到数据的地方才会变更。 所以,Vue要能够知道一个数据是否被使用,实现这种机制的技术叫做依赖收集根据Vue官方文档的介绍,其原理如下图所示:

深入解析Vue依赖收集原理

- 每个组件实例都有相应的watcher实例 - 渲染组件的过程,会把属性记录为依赖 - 当我们操纵一个数据时,依赖项的setter会被调用,从而通知watcher重新计算,从而致使与之相关联的组件得以更新

那么,现在问题来了:~~挖掘机技术哪家强,……~~ 如果我们现在模板里用到了3个数据A、B、C,那么我们怎么处理A、B、C变更时能刷新视图呢? 这就要先考虑以下两个问题: 1、我们怎么知道模板里用到了哪些数据? 2、数据变更了,我们怎么告诉render()函数?

那么很自然的,可以联想到有没有时机能够进行这么个处理,即: 1、既然模板渲染需要用到某个数据,那么一定会对这个数据进行访问,所以只要拦截getter,就有时机做出处理 2、在值变更的时候,也有setter可供拦截,那么拦截setter,也就能做出下一步动作。

所以在getter里,我们进行依赖收集(所谓依赖,就是这个组件所需要依赖到的数据),当依赖的数据被设置时,setter能获得这个通知,从而告诉render()函数进行重新计算。

三、依赖收集与观察者模式

我们会发现,上述vue依赖收集的场景,正是一种一对多的方式(一个数据变更了,多个用到这个数据的地方要能够做出处理),而且,依赖的数据变更了,就一定要做出处理,所以观察者模式天然适用于解决依赖收集的问题。 那么,在Vue依赖收集里:谁是观察者?谁是观察目标? 显然: - 依赖的数据是观察目标 - 视图、计算属性、侦听器这些是观察者

和文章开头里观察者模式实现代码相对应的,做出notify动作可以在setter里进行,做出addObserver()动作,则可以在getter里进行。

四、从源码解析Vue的依赖收集

下面开始我们的源码解析之旅吧。这里主要阅读的是Vue2早期commit的版本,源码比较精简,适合用来掌握精髓。

1、角色

Vue源码中实现依赖收集,实现了三个类: - Dep:扮演观察目标的角色,每一个数据都会有Dep类实例,它内部有个subs队列,subs就是subscribers的意思,保存着依赖本数据的观察者,当本数据变更时,调用dep.notify()通知观察者 - Watcher:扮演观察者的角色,进行观察者函数的包装处理。如render()函数,会被进行包装成一个Watcher实例 - Observer:辅助的可观测类,数组/对象通过它的转化,可成为可观测数据

2、每一个数据都有的Dep类实例

Dep类实例依附于每个数据而出来,用来管理依赖数据的Watcher类实例

let uid = 0; 
class Dep {
    static target = null;  // 巧妙的设计!
    constructor() {
        this.id = uid++;
        this.subs = [];
    }
    addSub(sub) {
        this.subs.push(sub);
    }
    removeSub(sub) {
        this.subs.$remove(sub);
    }
    depend() {
        Dep.target.addDep(this);
    }
    notify() {
        const subs = this.subs.slice();
        for (let i = 0, l = subs.length; i < l; i++) {
            subs[i].update();
        }
    }
}

由于JavaScript是单线程模型,所以虽然有多个观察者函数,但是一个时刻内,就只会有一个观察者函数在执行,那么此刻正在执行的那个观察者函数,所对应的Watcher实例,便会被赋给Dep.target这一类变量,从而只要访问Dep.target就能知道当前的观察者是谁。 在后续的依赖收集工作里,getter里会调用dep.depend(),而setter里则会调用dep.notify()

3、配置数据观测

上面我们说每一个数据都会有一个Dep类的实例,具体是什么意思呢?在讲解数据观测之前,我们先给个具体的例子,表明处理前后的变化,如下所示的对象(即为options.data):

{
    a: 1,
    b: [2, 3, 4],
    c: {
        d: 5
    }
}

在配置完数据观测后,会变成这样子:

{
    __ob__, // Observer类的实例,里面保存着Dep实例__ob__.dep => dep(uid:0)
    a: 1,   // 在闭包里存在dep(uid:1)
    b: [2, 3, 4], // 在闭包里存在着dep(uid:2),还有b.__ob__.dep => dep(uid:4)
    c: {
        __ob__, // Observer类的实例,里面保存着Dep实例__ob__.dep => dep(uid:5)
        d: 5 // 在闭包里存在着dep(uid:6)
    }
}

我们会发现,新角色Observer类登场啦,要说这个Observer类,那还得从生产每个组件Component类的构造函数说起,在Component类的构造函数里,会进行一个组件实例化前的一系列动作,其中与依赖收集相关的源码如下:

this._ob = observe(options.data)
    this._watchers = []
    this._watcher = new Watcher(this, render, this._update)
    this._update(this._watcher.value)

看到没有啊,observe(options.data),咦?不对,不是说好的Observer吗?怎么是小写的observe?~~怕不是拼夕夕上买的对象?~~ 别急,我们首先来看一下observe函数里做了什么事情:

function observe (value, vm) {
    if (!value || typeof value !== 'object') {
        return
    }
    var ob
    if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
        ob = value.__ob__
    } else if (shouldConvert && (isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue) {
        ob = new Observer(value)
    }
    if (ob && vm) {
        ob.addVm(vm)
    }
    return ob
}

总结来说就是: 只为对象/数组 实例一个Observer类的实例,而且就只会实例化一次,并且需要数据是可配置的时候才会实例化Observer类实例。 那么,Observer类又干嘛了呢?且看以下源码:

class Observer {
    constructor(value) {
        this.value = value
        this.dep = new Dep()
        def(value, '__ob__', this)
        if (isArray(value)) {
            var augment = hasProto
              ? protoAugment
              : copyAugment
            augment(value, arrayMethods, arrayKeys)
            this.observeArray(value)
        } else {
            this.walk(value)
        }
    }
    walk(obj) {
        var keys = Object.keys(obj)
        for (var i = 0, l = keys.length; i < l; i++) {
            this.convert(keys[i], obj[keys[i]])
        }
    }
    observeArray(items) {
        // 对数组每个元素进行处理
        // 主要是处理数组元素中还有数组的情况
        for (var i = 0, l = items.length; i < l; i++) {
            observe(items[i])
        }
    }
    convert(key, val) {
        defineReactive(this.value, key, val)
    }
    addVm(vm) {
        (this.vms || (this.vms = [])).push(vm)
    }
    removeVm(vm) {
        this.vms.$remove(vm)
    }
}

总结起来,就是: - 将Observer类的实例挂载在__ob__属性上,提供后续观测数据使用,以及避免被重复实例化。然后,实例化Dep类实例,并且将对象/数组作为value属性保存下来 - 如果value是个对象,就执行walk()过程,遍历对象把每一项数据都变为可观测数据(调用defineReactive方法处理) - 如果value是个数组,就执行observeArray()过程,递归地对数组元素调用observe(),以便能够对元素还是数组的情况进行处理

4、如何观测数组?

访问对象属性,其取值与赋值操作,都能被Object.defineProperty()成功拦截,但是Object.defineProperty()在处理数组上却存在一些问题,下面我们通过例子来了解一下:

const data = {
    arr: [1, 2, 3]
}

function defineReactive(obj, key, val) {
    const property = Object.getOwnPropertyDescriptor(obj, key);
    if (property && property.configurable === false) {
        return;
    }
    const getter = property && property.get;
    const setter = property && property.set;
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get() {
            console.log('取值过程被拦截了');
            const value = getter ? getter.call(obj) : val;
            return value;
        },
        set(newval) {
            console.log(`新的值是${newval}`)
            if (setter) {
                setter.call(obj, newval);
            } else {
                val = newval;
            }
        }
    })
}

defineReactive(data, 'arr', data.arr);

然后,我们进行一组测试,其结果如下:

data.arr; // 取值过程被拦截了
data.arr[0] = 1;  // 取值过程被拦截了
data.arr.push(4); // 取值过程被拦截了
data.arr.pop(); // 取值过程被拦截了
data.arr.shift(); // 取值过程被拦截了
data.arr.unshift(5); // 取值过程被拦截了
data.arr.splice(0, 1); // 取值过程被拦截了
data.arr.sort((a, b) => a - b); // 取值过程被拦截了
data.arr.reverse(); // 取值过程被拦截了
data.arr = [4, 5, 6] // 新的值是4,5,6

可见,除了对arr重新赋值一个数组外,其他的操作都不会被setter检测到。所以为了能检测到数组的变更操作,在传入的数据项是一个数组时,Vue会进行以下处理:

var augment = hasProto
  ? protoAugment
  : copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)

也就是对先对数组进行一个增强操作,这个增强操作呢,实际上是在数组的原型链上定义一系列操作方法,以此实现数组变更的检测,即定义一组原型方法在arr.__proto__指向的那个原型对象上,如果浏览器不支持__proto__,那么就直接挂载在数组对象本身上),最后再进行数组项的观测操作。 那么,增强操作又是怎么做到检测数组变更的呢?,那么就需要用到AOP的思想了,即保留原来操作的基础上,植入我们的特定的操作代码。 一个例子如下:

const arrayMethods = Object.create(Array.prototype); 
// 形成:arrayMethods.__proto__ -> Array.prototype
const originalPush = arrayMethods.push;
Object.defineProperty(arrayMethods, 'push', {
    configurable: true,
    enumerable: false,
    writable: true,
    value(...args) {
        const result = originalPush.apply(this, args);
        console.log('对数组进行了push操作,加入了值:', args);
        return result;
    }
})
data.arr.__proto__ = arrayMethods
data.arr.push([5, 6], 7) // 对数组进行了push操作,加入了值:[5, 6], 7

所以,只要对每一个数组操作方法进行这么一个处理,那么我们也就有办法在数组变更时,通知观察者了。Vue具体的实现如下:

;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
.forEach(function (method) {
  var original = arrayProto[method]
  def(arrayMethods, method, function mutator () {
    var i = arguments.length
    var args = new Array(i)
    while (i--) {
      args[i] = arguments[i]
    }
    var result = original.apply(this, args)
    var ob = this.__ob__
    var inserted
    switch (method) {
      case 'push':
        inserted = args
        break
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    ob.dep.notify()
    return result
  })
})

思路仍然是一样的: - 保留数组原来的操作 - pushunshiftsplice这些方法,会带来新的数据元素,而新带来的数据元素,我们是有办法得知的(即为传入的参数) - 那么新增的元素也是需要被配置为可观测数据的,这样子后续数据的变更才能得以处理。所以要对新增的元素调用observer实例上的observeArray方法进行一遍观测处理 - 由于数组变更了,那么就需要通知观察者,所以通过ob.dep.notify()对数组的观察者watchers进行通知

5、Watcher

Watcher扮演的角色是观察者,它关心数据,在数据变化后能够获得通知,并作出处理。一个组件里可以有多个Watcher类实例,Watcher类包装观察者函数,而观察者函数使用数据。 观察者函数经过Watcher是这么被包装的: - 模板渲染:this._watcher = new Watcher(this, render, this._update) - 计算属性:

computed: {
    name() {
        return `${this.firstName} ${this.lastName}`;
    }
}
/*
会形成
new Watcher(this, function name() {
    return `${this.firstName} ${this.lastName}`
}, callback);
*/

Watcher类里做的事情,概括起来则是: 1、传入组件实例观察者函数回调函数选项,然后我们先解释清楚4个变量:depsdepIdsnewDepsnewDepIds,它们的作用如下: - deps:缓存上一轮执行观察者函数用到的dep实例 - depIds:Hash表,用于快速查找 - newDeps:存储本轮执行观察者函数用到的dep实例 - newDepIds:Hash表,用于快速查找

2、进行初始求值,初始求值时,会调用watcher.get()方法 3、watcher.get()会做以下处理:初始准备工作、调用观察者函数计算、事后清理工作 4、在初始准备工作里,会将当前Watcher实例赋给Dep.target,清空数组newDepsnewDepIds 5、执行观察者函数,进行计算。由于数据观测阶段执行了defineReactive(),所以计算过程用到的数据会得以访问,从而触发数据的getter,从而执行watcher.addDep()方法,将特定的数据记为依赖 6、对每个数据执行watcher.addDep(dep)后,数据对应的dep如果在newDeps里不存在,就会加入到newDeps里,这是因为一次计算过程数据有可能被多次使用,但是同样的依赖只能收集一次。并且如果在deps不存在,表示上一轮计算中,当前watcher未依赖过某个数据,那个数据相应的dep.subs里也不存在当前watcher,所以要将当前watcher加入到数据的dep.subs里 7、进行事后清理工作,首先释放Dep.target,然后拿newDepsdeps进行对比,接着进行以下的处理: - newDeps里不存在,deps里存在的数据,表示是过期的缓存数据。相应的,从数据对应的dep.subs移除掉当前watcher - 将newDeps赋给deps,表示缓存本轮的计算结果,这样子下轮计算如果再依赖同一个数据,就不需要再收集了

8、当某个数据更新时,由于进行了setter拦截,所以会对该数据的dep.subs这一观察者队列里的watchers进行通知,从而执行watcher.update()方法,而update()方法会重复求值过程(即为步骤3-7),从而使得观察者函数重新计算,而render()这种观察者函数重新计算的结果,就使得视图同步了最新的数据

6、defineReative

我们都知道,Vue实现数据劫持使用的是Object.defineProperty(),而使用Object.defineProperty()来拦截数据的操作,都封装在了defineReactive里。接下来,我们来解析下defineReactive()源码:

function defineReactive (obj, key, val) {
    var dep = new Dep()
    var property = Object.getOwnPropertyDescriptor(obj, key)
    if (property && property.configurable === false) {
        return
    }
    var getter = property && property.get
    var setter = property && property.set

    var childOb = observe(val)
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter () {
            var value = getter ? getter.call(obj) : val
            if (Dep.target) {
                dep.depend()
                if (childOb) {
                    childOb.dep.depend()
                }
                if (isArray(value)) {
                    for (var e, i = 0, l = value.length; i < l; i++) {
                        e = value[i]
                        e && e.__ob__ && e.__ob__.dep.depend()
                    }
                }
            }
            return value
        },
        set: function reactiveSetter (newVal) {
            var value = getter ? getter.call(obj) : val
            if (newVal === value) {
                return
            }
            if (setter) {
                setter.call(obj, newVal)
            } else {
                val = newVal
            }
            childOb = observe(newVal)
            dep.notify()
        }
    })
}

1、闭包的妙用:上述代码里Object.defineProperty()里的get/set方法相对于var dep = new Dep()形成了闭包,从而很巧妙地保存了dep实例 2、getter里进行的是依赖的收集工作。如果某个观察者函数访问了某个数据,我们就可以把这个观察者函数认为是依赖这个数据的,所以举个具体的例子:data.a,在以下地方被使用:

<template>
    <div>{{a}}</div>
</template>
computed: {
    newValue() {
        return this.a + 1;
    }
}

那么,template被编译后,会形成AST,在执行render()函数过程中就会触发data.a的getter,并且这个过程是惰性收集的(如newValue虽然用到 了a,但如果它没有被调用执行,就不会触发getter,也就不会被添加到data.adep.subs里) 现在,假设template变成了这样子:

<template>
    <div>I am {{a}},plus 1 is {{newValue}}</div>
</template>

那么,可以看到就对应了两个观察者函数:计算属性newValuerender()函数,它们会被包装为两个watcher。 在执行render()函数渲染的过程中,访问了data.a,从而使得data.adep.subs里加入了render@watcher 又访问了计算属性newValue,计算属性里访问了data.a,使得data.adep.subs里加入了newValue@watcher。所以data.adep.subs里就有了[render@watcher, newValue@watcher] 为什么访问特定数据就使能让数据的deps.subs里加入了watcher呢? 这是因为,在访问getter之前,就已经进入了某个watcher的上下文了,所以有一件事情是可以保证的:Watcher类的实例watcher已经准备好了,并且已经调用了watcher.get()Dep.target是有值的 所以,我们看到getter里进行依赖收集的写法是dep.depend(),并没有传入什么参数,这是因为,我们只需要把Dep.target加入当前dep.subs里就好了。 但是我们又发现,Dep.prototype.depend()的实现是:

depend() {
    Dep.target.addDep(this);
}

为什么depend()的时候,不直接把Dep.target加入dep.subs,而是调用了Dep.target.addDep呢? 这是因为,我们不能无脑地直接把当前watcher塞入dep.subs里,我们要保证dep.subs里的每个watcher都是唯一的。 Dep.targetWatcher类实例,调用dep.depend()相当于调用了watcher.addDep方法,所以我们再来看一下这个方法里做了什么事情:

Watcher.prototype.addDep = function (dep) {
    var id = dep.id
    if (!this.newDepIds[id]) {
        this.newDepIds[id] = true
        this.newDeps.push(dep)
        if (!this.depIds[id]) {
            dep.addSub(this)
        }
    }
}

概括起来就是:判断本轮计算中是否收集过这个依赖,收集过就不再收集,没有收集过就加入newDeps。同时,判断有无缓存过依赖,缓存过就不再加入到dep.subs里了。

3、setter里进行的,则是在值变更后,通知watcher进行重新计算。由于setter能访问到闭包中dep,所以就能获得dep.subs,从而知道有哪些watcher依赖于当前数据,如果自己的值变化了,通过调用dep.notify(),来遍历dep.subs里的watcher,执行每个watcherupdate()方法,让每个watcher进行重新计算。

7、困惑点解析

回到开头的例子,我们说举例的option.data被观测之后,变成了:

{
    __ob__, // dep(uid:0)
    a: 1, // dep(uid:1)
    b: [2, 3, 4], // dep(uid:2), b.__ob__.dep(uid:3)
    c: {
        __ob__, // dep(uid:4), c.__ob__.dep(uid:5)
        d: 5 // dep(uid:6)
    }
}

我们不禁好奇,为什么对于数组对象,配置依赖观测后,会实例化两个Dep类实例呢? 这是因为:数组对象,都是引用类型数据,对于引用类型数据,存在两种操作:改变引用改变内容,即为:

data.b = [4, 5, 6]; // 改变引用
data.b.push(7); // 改变内容

而其实,改变引用这种情况,我们前面在说到Object.defineProperty()的限制时说过,是可以被检测到的,所以闭包里的dep可以收集这种依赖。而改变内容,却没办法通过Object.defineProperty()检测到,所以对数组变异操作进行了封装,所以就需要在数组上挂在__ob__属性,在__ob__上挂载dep实例,用来处理改变内容的情况,以便能够形成追踪链路。

三、总结

总结而言,Vue的依赖收集,是观察者模式的一种应用。其原理总结如图:

深入解析Vue依赖收集原理

1、配置依赖观测

深入解析Vue依赖收集原理

2、收集依赖

深入解析Vue依赖收集原理

3、数据值变更

深入解析Vue依赖收集原理

转载自:https://juejin.cn/post/6844903702881386504
评论
请登录