https://github.com/berwin/Blog/issues/17
关于vue的内部原理其实有很多个重要的部分,变化侦测,模板编译,virtualDOM,整体运行流程等。

1.如何侦测变化?

关于变化侦测首先要问一个问题,在 js 中,如何侦测一个对象的变化,其实这个问题还是比较简单的,学过js的都能知道,js中有两种方法可以侦测到变化,Object.defineProperty 和 ES6 的proxy。
vue2用的是 Object.defineProperty,所以我们拿 Object.defineProperty来举例子说明这个原理。
知道 Object.defineProperty 可以侦测到对象的变化,那么我们瞬间可以写出这样的代码:

  1. function defineReactive(data, key, val) {
  2. Object.defineProperty(data, key, {
  3. enumerable: true,
  4. configurable: true,
  5. get: function() {
  6. return val
  7. },
  8. set: function(newVal) {
  9. if (val === newVal) {
  10. return
  11. }
  12. val = newVal
  13. }
  14. })
  15. }

写一个函数封装一下 Object.defineProperty,毕竟 Object.defineProperty 的用法这么复杂,封装一下我只需要传递一个 data,和 key,val 就行了。
现在封装好了之后每当 data 的 key 读取数据 get 这个函数可以被触发,设置数据的时候 set 这个函数可以被触发,但是,,,,,,,,,,,,,,,,,,发现好像并没什么鸟用?

2. 怎么观察?

现在我要问第二个问题,“怎么观察?”
思考一下,我们之所以要观察一个数据,目的是为了当数据的属性发生变化时,可以通知那些使用了这个 key 的地方。
举个 🌰 :

<template> 
  <div>{{ key }}</div> 
  <p>{{ key }}</p>
 </template>

模板中有两处使用了 key,所以当数据发生变化时,要把这两处都通知到。
所以上面的问题,我的回答是,先收集依赖,把这些使用到 key 的地方先收集起来,然后等属性发生变化时,把收集好的依赖循环触发一遍就好了~
总结起来其实就一句话,getter中,收集依赖,setter中,触发依赖。

3.依赖收集在哪?

现在我们已经有了很明确的目标,就是要在getter中收集依赖,那么我们的依赖收集到哪里去呢??
思考一下,首先想到的是每个 key 都有一个数组,用来存储当前 key 的依赖,假设依赖是一个函数存在 window.target 上,先把 defineReactive 稍微改造一下:

function defineReactive(data, key, val) {
    let dep = [] //新增
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            dep.push(window.target)
            //新增returnval
        },
        set: function(newVal) {
            if (val === newVal) {
                return
            } //新增
            for (leti = 0; i < dep.length; i++) {
                dep[i](newVal, val)
            }
            val = newVal
        }
    })
}

在 defineReactive 中新增了数组 dep,用来存储被收集的依赖。
然后在触发 set 触发时,循环dep把收集到的依赖触发。
但是这样写有点耦合,我们把依赖收集这部分代码封装起来,写成下面的样子:

export default class Dep {
    static target: ? Watcher;
    id: number;
    subs: Array < Watcher > ;
    constructor() {
        this.id = uid++;
        this.subs = [];
    }
    addSub(sub: Watcher) {
        this.subs.push(sub)
    }
    removeSub(sub: Watcher) {
        remove(this.subs, sub)
    }
    depend() {
        if (Dep.target) {
            this.addSub(Dep.target)
        }
    }
    notify() { //stabilize the subscriber list first
        const subs = this.subs.slice();
        for (leti = 0, l = subs.length; i < l; i++) {
            subs[i].update()
        }
    }
}

然后在改造一下 defineReactive:

function defineReactive(data, key, val) {
    let dep = new Dep() //修改
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            dep.depend() //修改
            return val
        },
        set: function(newVal) {
            if (val === newVal) {
                return
            }
            dep.notify() //新增
            val = newVal
        }
    })
}

这一次代码看起来清晰多了,顺便回答一下上面问的问题,依赖收集到哪?收集到Dep中,Dep是专门用来存储依赖的。

4.收集谁?

上面我们假装 window.target 是需要被收集的依赖,细心的同学可能已经看到,上面的代码 window.target 已经改成了 Dep.target,那 Dep.target是什么?我们究竟要收集谁呢??
收集谁,换句话说是当属性发生变化后,通知谁。

我们要通知那个使用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,有可能是模板,有可能是用户写的一个 watch,所以这个时候我们需要抽象出一个能集中处理这些不同情况的类,然后我们在依赖收集的阶段只收集这个封装好的类的实例进来,通知也只通知它一个,然后它在负责通知其他地方,所以我们要抽象的这个东西需要先起一个好听的名字,嗯,就叫它watcher吧~
所以现在可以回答上面的问题,收集谁??收集 Watcher。

5.什么是Watcher?

watcher 是一个中介的角色,数据发生变化通知给 watcher,然后watcher在通知给其他地方。
关于watcher我们先看一个经典的使用方式:

//keypath 
vm.$watch('a.b.c', function(newVal, oldVal) { 
    //do something
})

这段代码表示当 data.a.b.c 这个属性发生变化时,触发第二个参数这个函数。

思考一下怎么实现这个功能呢?
好像只要把这个 watcher 实例添加到 data.a.b.c 这个属性的 Dep 中去就行了,然后 data.a.b.c 触发时,会通知到watcher,然后watcher在执行参数中的这个回调函数。
好,思考完毕,开工,写出如下代码:

class Watch {
    constructor(expOrFn, cb) { //执行this.getter() 就可以拿到 data.a.b.c
        this.getter = parsePath(expOrFn);
        this.cb = cb;
        this.value = this.get();
    }
    get() {
        Dep.target = this;
          value = this.getter.call(vm, vm);
        Dep.target = undefined
    }
    update() {
        const oldValue = this.value;
        this.value = this.get();
        this.cb.call(this.vm, this.value, oldValue)
    }
}

这段代码可以把自己主动 push 到 data.a.b.c 的 Dep 中去。

因为我在 get 这个方法中,先把 Dep.traget 设置成了 this,也就是当前watcher实例,然后在读一下 data.a.b.c 的值。

因为读了 data.a.b.c 的值,所以肯定会触发 getter。

触发了 getter 上面我们封装的 defineReactive 函数中有一段逻辑就会从 Dep.target 里读一个依赖 push 到 Dep 中。

所以就导致,我只要先在 Dep.target 赋一个 this,然后我在读一下值,去触发一下 getter,就可以把 this 主动 push 到 keypath 的依赖中,有没有很神奇~

依赖注入到 Dep 中去之后,当这个 data.a.b.c 的值发生变化,就把所有的依赖循环触发 update 方法,也就是上面代码中 update 那个方法。

update 方法会触发参数中的回调函数,将value 和 oldValue 传到参数中。

所以其实不管是用户执行的 vm.$watch(‘a.b.c’, (value, oldValue) => {}) 还是模板中用到的data,都是通过 watcher 来通知自己是否需要发生变化的。
递归侦测所有key
现在其实已经可以实现变化侦测的功能了,但是我们之前写的代码只能侦测数据中的一个 key,所以我们要加工一下 defineReactive 这个函数:

//新增
function walk(obj: Object) {
    const keys = Object.keys(obj);
    for (leti = 0; i < keys.length; i++) {
        defineReactive(obj, keys[i], obj[keys[i]])
    }
}
function defineReactive(data, key, val) {
    walk(val) //新增
    let dep = newDep()
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            dep.depend();
            return val;
        },
        set: function(newVal) {
            if (val === newVal) {
                return
            }
            dep.notify();
            val = newVal;
        }
    })
}

这样我们就可以通过执行 walk(data),把 data 中的所有 key 都加工成可以被侦测的,因为是一个递归的过程,所以 key 中的 value 如果是一个对象,那这个对象的所有key也会被侦测。

6.Array怎么进行变化侦测?

现在又发现了新的问题,data 中不是所有的 value 都是对象和基本类型,如果是一个数组怎么办??数组是没有办法通过 Object.defineProperty 来侦测到行为的。
vue 中对这个数组问题的解决方案非常的简单粗暴,我说说vue是如何实现的,大体上分三步:

  • 第一步:先把原生 Array 的原型方法继承下来。
  • 第二步:对继承后的对象使用 Object.defineProperty 做一些拦截操作。
  • 第三步:把加工后可以被拦截的原型,赋值到需要被拦截的 Array 类型的数据的原型上。

vue的实现
第一步:

const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);

第二步:

['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function(method) { //cache original method
    const original = arrayProto[method];
    Object.defineProperty(arrayMethods, method, {
        value: function mutator(...args) {
            console.log(methods); //打印数组方法
            return original.apply(this, args)
        },
        enumerable: false,
        writable: true,
        configurable: true
    })
})

现在可以看到,每当被侦测的 array 执行方法操作数组时,我都可以知道他执行的方法是什么,并且打印到 console 中。
现在我要对这个数组方法类型进行判断,如果操作数组的方法是 push unshift splice (这种可以新增数组元素的方法),需要把新增的元素用上面封装的 walk 来进行变化检测。
并且不论操作数组的是什么方法,我都要触发消息,通知依赖列表中的依赖数据发生了变化。
那现在怎么访问依赖列表呢,可能我们需要把上面封装的 walk 加工一下:

//工具函数
function def(obj: Object, key: string, val: any, enumerable ? : boolean) {
    Object.defineProperty(obj, key, {
        value: val,
        enumerable: !!enumerable,
        writable: true,
        configurable: true
    })
}
export class Observer {
    value: any;
    dep: Dep;
    vmCount: number; //number of vms that has this object as root $data
    constructor(value: any) {
        this.value = value;
        this.dep = newDep(); //新增
        this.vmCount = 0;
          def(value, '__ob__', this); //新增//新增
        if (Array.isArray(value)) {
            this.observeArray(value);
        } else {
            this.walk(value);
        }
    } /*** Walk through each property and convert them into* getter/setters. This method should only be called when* value type is Object.*/
    walk(obj: Object) {
        const keys = Object.keys(obj);
          for (let i = 0; i < keys.length; i++) {
            defineReactive(obj, keys[i], obj[keys[i]]);
        }
    } /*** Observe a list of Array items.*/
    observeArray(items: Array < any > ) {
        for (let i = 0, l = items.length; i < l; i++) {
            new Observer(items[i]);
        }
    }
}

我们定义了一个 Observer 类,他的职责是将 data 转换成可以被侦测到变化的 data,并且新增了对类型的判断,如果是 value 的类型是 Array 循环 Array将每一个元素丢到 Observer 中。
并且在 value 上做了一个标记 ob,这样我们就可以通过 value 的 ob 拿到Observer实例,然后使用 ob 上的 dep.notify() 就可以发送通知啦。
然后我们在改进一下Array原型的拦截器:

['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function(method) { //cache original method
    const original = arrayProto[method];
    def(arrayMethods, method, function mutator(...args) {
        const result = original.apply(this, args);
        const ob = this.__ob__letinserted
        switch (method) {
            case 'push':
            case 'unshift':
                inserted = args;
                break;
            case 'splice': 
                inserted = args.slice(2);
                break;
        }
        if (inserted) ob.observeArray(inserted); //notify change
        ob.dep.notify();
        return result
    })
})

可以看到写了一个 switch 对 method 进行判断,如果是 push,unshift,splice 这种可以新增数组元素的方法就使用 ob.observeArray(inserted) 把新增的元素也丢到 Observer 中去转换成可以被侦测到变化的数据。
在最后不论操作数组的方法是什么,都会调用 ob.dep.notify() 去通知 watcher 数据发生了改变。
arrayMethods 是怎么生效的?
现在我们有一个 arrayMenthods 是被加工后的 Array.prototype,那么怎么让这个对象应用到Array 上面呢?
思考一下,我们不能直接修改 Array.prototype因为这样会污染全局的Array,我们希望 arrayMenthods只对 data中的Array 生效。
所以我们只需要把 arrayMenthods 赋值给 value 的 proto 上就好了。
我们改造一下 Observer:

export class Observer {
    constructor(value: any) {
        this.value = value;
        this.dep = newDep();
        this.vmCount = 0;
        def(value, '__ob__', this);
        if (Array.isArray(value)) {
            value.__proto__ = arrayMethods //新增
            this.observeArray(value)
        } else {
            this.walk(value)
        }
    }
}

如果不能使用 proto,就直接循环 arrayMethods 把它身上的这些方法直接装到 value 身上好了。
什么情况不能使用 proto 我也不知道,各位大佬谁知道能否给我留个言?跪谢~
所以我们的代码又要改造一下:

//can we use __proto__?consthasProto='__proto__'in{} //新增
export class Observer {
    constructor(value: any) {
        this.value = value;
        this.dep = newDep();
        this.vmCount = 0;
        def(value, '__ob__', this);
        if (Array.isArray(value)) { //修改
            const augment = hasProto ? protoAugment : copyAugment;
            augment(value, arrayMethods, arrayKeys);
            this.observeArray(value)
        } else {
            this.walk(value)
        }
    }
}
function protoAugment(target, src: Object, keys: any) {
    target.__proto__ = src
}
function copyAugment(target: Object, src: Object, keys: Array < string > ) {
    for (leti = 0, l = keys.length; i < l; i++) {
        constkey = keys[i];
        def(target, key, src[key])
    }
}

关于vue对Array的拦截实现上面刚说完,正因为这种实现方式,其实有些数组操作vue是拦截不到的,例如:
this. list[ 0] =2
修改数组第一个元素的值,无法侦测到数组的变化,所以并不会触发 re-render 或 watch 等。
在例如:
this. list. length=0
清空数组操作,无法侦测到数组的变化,所以也不会触发 re-render 或 watch 等。
因为vue的实现方式就决定了无法对上面举得两个例子做拦截,也就没有办法做到响应,ES6是有能力做到的,在ES6之前是无法做到模拟数组的原生行为的,现在 ES6 的 Proxy 可以模拟数组的原生行为,也可以通过 ES6 的继承来继承数组原生行为,从而进行拦截。

7b18e43726c54d12ba510f31b89933ed.jpeg
最后掏出vue官网上的一张图,这张图其实非常清晰,就是一个变化侦测的原理图。
getter 到 watcher 有一条线,上面写着收集依赖,意思是说 getter 里收集 watcher,也就是说当数据发生 get 动作时开始收集 watcher。
setter 到 watcher 有一条线,写着 Notify 意思是说在 setter 中触发消息,也就是当数据发生 set动作时,通知 watcher。
Watcher 到 ComponentRenderFunction 有一条线,写着 Trigger re-render 意思很明显了