一步步带你看懂vue2的响应式原理

前言不知道写什么,所以没有前言
我们从入口开始讲起

从入口开始

// vue/src/core/instance/index.js
function Vue(options) {
    if (process.env.NODE_ENV !== 'production' &&
        !(this instanceof Vue)
    ) {
        warn('Vue is a constructor and should be called with the `new` keyword')
    }
    this._init(options)
}

可以看到,vue调用了一个_init方法,并传入了options,那我们转到_init方法

// vue/src/core/instance/init.js
export function initMixin(Vue: Class<Component>) {
    Vue.prototype._init = function (options?: Object) {

        vm._self = vm;
        initLifecycle(vm);
        initEvents(vm);
        initRender(vm);
        callHook(vm, 'beforeCreate');
        initInjections(vm);  // resolve injections before data/props
        initState(vm);      // 对数据进行处理
        initProvide(vm); // resolve provide after data/props
        callHook(vm, 'created');

        // 如果传入了el, 进行挂载
        if (vm.$options.el) {
            vm.$mount(vm.$options.el)
        }
    }
}

然后对数据进行处理的是initState函数,那我们转到initState

// vue/src/core/instance/state.js
export function initState(vm: Component) {
    vm._watchers = [];
    const opts = vm.$options;
    if (opts.props) initProps(vm, opts.props);
    if (opts.methods) initMethods(vm, opts.methods);
    if (opts.data) {
        initData(vm);
    } else {
        observe(vm._data = {}, true /* asRootData */)
    }
    if (opts.computed) initComputed(vm, opts.computed);
    if (opts.watch && opts.watch !== nativeWatch) {
        initWatch(vm, opts.watch)
    }
}

我们可以从执行的函数名看出,这里完成了props,methods,data,computed,watch的初始化,我们来看看data的初始化,我们转到initData

function initData(vm: Component) {
    let data = vm.$options.data;
    // 如果data是一个函数,就执行函数
    // 并且把data保留到vm._data上
    data = vm._data = typeof data === 'function'
        ? getData(data, vm)
        : data || {};
    // 判断是否是平面对象
    // let obj = {} 这种就是平面对象
    if (!isPlainObject(data)) {
        data = {};
        process.env.NODE_ENV !== 'production' && warn(
            'data functions should return an object:\n' +
            'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
            vm
        )
    }
    // proxy data on instance
    const keys = Object.keys(data);
    const props = vm.$options.props;
    const methods = vm.$options.methods;
    let i = keys.length;
    // 这个循环是检测是否有重名和占据保留字的
    while (i--) {
        const key = keys[i];
        if (process.env.NODE_ENV !== 'production') {
            // 检测是否和methods传入的字段重名
            if (methods && hasOwn(methods, key)) {
                warn(
                    `Method "${key}" has already been defined as a data property.`,
                    vm
                )
            }
        }
        // 检测是否和methods传入的字段重名
        if (props && hasOwn(props, key)) {
            process.env.NODE_ENV !== 'production' && warn(
                `The data property "${key}" is already declared as a prop. ` +
                `Use prop default value instead.`,
                vm
            )
        }
        // 这是检测是否占据了保留字
        // vue把_和$开头的识别为保留字
        else if (!isReserved(key)) {
        // 把vm_data上的数据代理到vm上
        // 以后访问vm.xxx等同于访问vm._data.xxx
            proxy(vm, `_data`, key)
        }
    }
    // observe data
    // 这里开始正式观测数据
    observe(data, true /* asRootData */)
}

我们可以看到,在经过前面的一堆处理和检测后,observe函数被调用了,并且把data作为参数传了进去,从函数名我们也可以看出,这就是实现数据响应式的函数。
然后我们看看observe函数的实现

export function observe(value: any, asRootData: ?boolean): Observer | void {
    // 判断是不是对象
    if (!isObject(value) || value instanceof VNode) {
        return
    }
    // Observe
    let ob: Observer | void;
    // 判断value是否已经被观测
    // 如果已经观测,value.__ob__就是Observer对象
    if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
        ob = value.__ob__
    } else if (
        // 这里做了一些是否要观测的判断
        shouldObserve &&
        !isServerRendering() &&
        (Array.isArray(value) || isPlainObject(value)) &&
        Object.isExtensible(value) &&
        !value._isVue
    ) {
        // 如果判断通过就进行观测
        // 观测是通过new Observer进行的
        ob = new Observer(value)
    }
    if (asRootData && ob) {
        ob.vmCount++
    }
    return ob;
}

在经过了一系列判断后,如果这个对象没有被观测过,就会通过new Observer的方式进行观测,那我们来看看Observe做了什么。

export class Observer {
    value: any;
    dep: Dep;
    vmCount: number; // number of vms that has this object as root $data
    // 这里的value就是你传入的data对象
    constructor(value : any) {
        this.value = value;
        // 这个dep是个数组用的
        this.dep = new Dep();
        this.vmCount = 0;
        // 定义__ob__
        // 这里把定义了访问器属性value.__ob__,值是this,也就是observer
        def(value, '__ob__', this);
        // 判断value是否是数组
        if (Array.isArray(value)) {
            const augment = hasProto
                ? protoAugment
                : copyAugment;
            // 这是把value的原型改成arrayMethods
            augment(value, arrayMethods, arrayKeys);
            // 调用这个方法来观测数组
            this.observeArray(value)
        } else {
            // 如果value不是数组,就调用walk方法观测value
            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) {
        // 获取对象的keys
        const keys = Object.keys(obj);
        for (let i = 0; i < keys.length; i++) {
            // 调用defineReactive进行某个值的观测
            defineReactive(obj, keys[i])
        }
    }

    /**
     * Observe a list of Array items.
     */
     // 观测数组
    observeArray(items: Array<any>) {
        for (let i = 0, l = items.length; i < l; i++) {
            observe(items[i])
        }
    }
}

// 把某个数据弄成访问器属性
export function def(obj: Object, key: string, val: any, enumerable?: boolean) {
    Object.defineProperty(obj, key, {
        value: val,
        enumerable: !!enumerable,
        writable: true,
        configurable: true
    })
}

在上面我已经把源码放了出来,可以看出,vue会把数组和对象作为两种情况讨论,所以下面我们把情况分为对对象和对数组来进行讨论。
不过我们要先花一点时间了解对一些流程做一个整体的了解。首先我们要知道,在vue里,一个组件对应一个渲染watcher,这个watcher上的update方法执行时,就会重新渲染这个组件,那么这个组件怎么知道什么时候要重新渲染呢,其实是这样的,在这个组件渲染时,这个组件会把它对应的渲染watcher推到Dep.target上,而在访问渲染需要的数据时,会触发定义的getter方法,这个时候,数据会从Dep.target上拿到这个watcher,然后把它保存到一个地方,这个地方就是dep实例,dep.subs会缓存对应的watcher对象,在这个数据的值刷新时,定义的setter方法就会被触发,setter方法会调用dep.notify,这个方法会遍历保存在dep里的watcher,调用watcherupdate方法,对渲染watcher来说,调用update方法就相当于调用了重新渲染组件的方法。这就是数据响应式的一个大致流程,接下来我们来看看源码的实现。

对象的观测

第一层

假设我们有以下的代码

let vm = new Vue({
    el : "#app",
    data() {
        return {
            name : "sena",
            age : "16"
        }
    }
})

那么在执行的时候发生了什么呢,我们来看看,首先是调用了new Observer(),然后调用了walk方法,需要关注的代码就只有这些

constructor(value : any) {
    this.value = value;
    this.walk(value)
}

然后在walk方法里,遍历所有属性,用defineReactive定义响应式

walk(obj: Object) {
    const keys = Object.keys(obj);
    for (let i = 0; i < keys.length; i++) {
        defineReactive(obj, keys[i])
    }
}

然后我们看看defineReactive(PS:删除了一部分这种情况不会执行/不重要的代码,之后也会逐步根据不同情况添加代码)

export function defineReactive(
    obj: Object,
    key: string,
    val: any
) {
    const dep = new Dep();

    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            // getter收集依赖
            // 获取属性对应的值
            const value = val;
            // Dep.target会存放当前组件的渲染Watcher
            if (Dep.target) {
                // 这句话会把Dep.target添加到dep的中
                // 也就是保存了watcher
                dep.depend();
            }
            return value
        },
        set: function reactiveSetter(newVal) {
            // setter派发更新
            const value = val;
            // 判断新旧值是否相等, 相等就不触发派发更新
            if (newVal === value || (newVal !== newVal && value !== value)) {
                return
            }
            val = newVal;
            // 这里通知渲染watcher执行update方法,进行组件更新
            dep.notify()
        }
    })
}

通过执行defineReactive,vue的数据得以和watcher关联起来,由此完成了响应式。
显然,如果只完成第一层的观测,代码是很简单的,但是正常情况下要观测的数据比这复杂的多,那我们来看看多层的实现吧

多层实现

实际上,多层实现也没有太复杂,依旧是先调用walk方法,执行defineReactive,但是这里多了一些处理

export function defineReactive(
    obj: Object,
    key: string,
    val: any
) {
    const dep = new Dep();

+   // 新增代码1 : 递归调用进行观测
+   let childOb = observe(val);

    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            // getter收集依赖
            // 获取属性对应的值
            const value = val;
            // Dep.target会存放当前组件的渲染Watcher
            if (Dep.target) {
                // 这句话会把Dep.target添加到dep的中
                // 也就是保存了watcher
                dep.depend();
            }
            return value
        },
        set: function reactiveSetter(newVal) {
            // setter派发更新
            const value = val;
            // 判断新旧值是否相等, 相等就不触发派发更新
            if (newVal === value || (newVal !== newVal && value !== value)) {
                return
            }
            val = newVal;
+           // 新增代码2 : 如果新的值也是对象,也会进行观测
+           childOb = observe(newVal);
            // 这里通知渲染watcher执行update方法,进行组件更新
            dep.notify()
        }
    })
}

要完成观测多重嵌套,只要添加两行代码就可以了,我接下来会用一个组件,然后去源码中分别注释这两行,然后观察效果

<template>
    <div class="home">
        <p>name : {{name}}</p>
        <p>age : {{age}}</p>
        <p>school.name : {{school.name}}</p>
        <button @click="click">click me</button>
    </div>
</template>

<script>
    export default {
        name: 'home',
        data() {
            return {
                name : "sena",
                age : "16",
                school : {
                    name : "school name"
                }
            }
        },
        methods : {
            click() {
                this.school.name = "another school name";
                console.log("click");
                console.log(this);
            }
        }
    }
</script>

首先我们注释的是新增代码1,也就是let childOb = observe(val)这一句,注释后组件可以正常渲染

然后我们点击一下按钮,可以看到,尽管school.name已经更新,但是组件没有刷新,这是因为school.name没有被观测

我们把注释去掉,然后点击按钮

这次school.name在改变的同时也触发了更新,我们也可以见到,school.name变成了一个访问器属性,这证明school.name已经通过defineReactive获得了收集依赖和派发更新的能力。
所以我们可以知道,这一行代码的用处是递归处理深层的数据,给深层数据定义响应式。

现在我们去源码里把第二行注释掉,也就是childOb = observe(newVal)这一句,刷新页面后点击按钮,修改了school.name后依旧可以正常更新组件

那是不是意味着没有问题了呢,当然不是,我们改一下代码

<template>
    <div class="home">
        <p>{{school.name}}</p>
        <button @click="school = {
            name : 'another school name'
        }">button1</button>
        <button @click="click">button2</button>
    </div>
</template>

<script>

    export default {
        name: 'home',
        data() {
            return {
                name : "sena",
                age : "16",
                school : {
                    name : "school name"
                }
            }
        },
        methods : {
            click() {
                this.school.name = "school name";
                console.log("click");
                console.log(this);
            },
        }
    }
</script>

页面刷新后分别点击button1和button2,可以看到这样的现象
file

诶,为什么点击button2时没有更新页面呢,其实很简单,我们直接修改了school的值,那么school就是一个全新的对象,这个新的对象自然是没有被观测过的,我们从图中可以看到school.name已经不是一个访问器属性了,也就是说修改它时setter不会被触发,也就不能触发组件更新的逻辑。顺带一提,这个时候school仍然是一个访问器属性,因为getter和setter是对应在对象的键上的。

然后我们把注释去掉,就能正常更新了,school.name也在school被重新赋值后被观测
file

数据的观测

第一层

new Observer(value)时,如果value是一个数组,要处理的流程就和是对象的情况不一样了

constructor(value: any) {
    this.value = value;
+   // 这个dep是数组专用的,具体使用后面会说
+   this.dep = new Dep();
+   // 定义__ob__, 这个属性会在之后用上
+   // 这里把定义了访问器属性value.__ob__,值是this,也就是observer
+   def(value, '__ob__', this);
+   if (Array.isArray(value)) {
+       // 修改原型
+       value.__proto__ = arrayMethods;
+       this.observeArray(value)
    } else {
        this.walk(value)
    }
}

可以看到,如果要观测的对象是数组类型,会调用observeArray方法,那我们来看看这个方法干了什么。

observeArray(items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
        observe(items[i])
    }
}

observeArray遍历了数组的每一项,并且调用了observe方法去观测他们,我们来看看observe方法

export function observe(value: any, asRootData: ?boolean): Observer | void {
    // 判断是不是对象
    if (!isObject(value) || value instanceof VNode) {
        return
    }
    // Observe
    let ob: Observer | void;
    // 判断value是否已经被观测
    // 如果已经观测,value.__ob__就是Observer对象
    if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
        ob = value.__ob__
    } else if (
        // 这里做了一些是否要观测的判断
        shouldObserve &&
        !isServerRendering() &&
        (Array.isArray(value) || isPlainObject(value)) &&
        Object.isExtensible(value) &&
        !value._isVue
    ) {
        // 如果判断通过就进行观测
        // 观测是通过new Observer进行的
        ob = new Observer(value)
    }
    if (asRootData && ob) {
        ob.vmCount++
    }
    return ob;
}

可以看到,observe只会对没有观测过的数据和对象类型的数据进行观测,所以observeArray实际上是观测数组上的对象,并不观测下标,我们可以验证一下。

<template>
    <div class="home">
        <p v-for="(game, index) in gameList" :key="index">{{game.gameName}}</p>
        <button @click="click">button</button>
    </div>
</template>

<script>

    export default {
        name: 'home',
        data() {
            return {
                name : "sena",
                age : "16",
                school : {
                    name : "school name"
                },
                gameList : [{
                    gameName : "GTA6"
                }, {
                    gameName : "MONSTER HUNTER : WORLD"
                }]
            }
        },
        methods : {
            click() {
                console.log(this);
            }
        },
        mounted() {
            window.vm = this;
        },
        beforeDestroy() {
            window.vm = null;
        }
    }
</script>

file

很明显,数组里的[0], [1]都不是访问器属性的,但是数组里的对象被观测了,这就是为什么我们平时通过下标修改数组是不会触发更新的。

那么数组的变化侦测是怎么实现的呢,虽然你可能现在不清楚,但你一定知道我们平时想要在修改数组时触发更新,一般都是通过调用push,shift这样的函数,这是怎么实现的呢?
在new Observer时有这样几句话

if (Array.isArray(value)) {
+       // 修改原型
+   value.__proto__ = arrayMethods;
+   this.observeArray(value)
}

可以看到,数组的原型被修改成了arrayMethods,那我们来看看arrayMethods的实现

// 数组原来的原型
const arrayProto = Array.prototype;
// 响应式数组原型
export const arrayMethods = Object.create(arrayProto);
// 这些是会修改数组本身的方法
const methodsToPatch = [
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
];

/**
 * Intercept mutating methods and emit events
 * 拦截数组方法
 */
methodsToPatch.forEach(function (method) {
    // cache original method
    const original = arrayProto[method];
    def(arrayMethods, method, 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
        }
        // 如果有新的值插入数组,观测这些值
        if (inserted) ob.observeArray(inserted);
        // notify change
        // 手动触发更新
        ob.dep.notify();
        return result
    })
});

其实原理也很简单,vue对数组的方法做了拦截,如果调用这些会修改数组本身的方法,就会走到被拦截的方法里,然后在观测新添加的值后调用ob.dep.notify(), 通知组件watcher更新,顺带讲点细节问题,还记得Observr的构造函数吗

constructor(value: any) {
    this.value = value;
    // 这个dep是数组专用的,在数组的拦截方法里调用ob.dep.notify();实际上就是调用了这个dep
    this.dep = new Dep();
    // 定义__ob__, 这个属性会在之后用上
    // 这里把定义了访问器属性value.__ob__,值是this,也就是observer
    def(value, '__ob__', this);
    if (Array.isArray(value)) {
        // 修改原型
        value.__proto__ = arrayMethods;
        this.observeArray(value)
    } else {
        this.walk(value)
    }
}

this.depvalue.__ob__都是有目的的,前者是为了能在数组的拦截方法中访问到dep,用于触发组件更新,后者是为了能在数组的拦截方法中访问到Observer,当然也可以标识这个对象是否已经被观测。

除了上面的地方,我们还要知道,针对value是数组的情况,vue也在defineReactive这里做了一些处理,我们来看一下

export function defineReactive(
    obj: Object,
    key: string,
    val: any
) {
    const dep = new Dep();

    //  递归调用进行观测
    let childOb = observe(val);

    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            // getter收集依赖
            // 获取属性对应的值
            const value = val;
            // Dep.target会存放当前组件的渲染Watcher
            if (Dep.target) {
                // 这句话会把Dep.target添加到dep的中
                // 也就是保存了watcher
                dep.depend();
+               if (childOb) {
+                   childOb.dep.depend();
+               }
            }
            return value
        },
        set: function reactiveSetter(newVal) {
            // setter派发更新
            const value = val;
            // 判断新旧值是否相等, 相等就不触发派发更新
            if (newVal === value || (newVal !== newVal && value !== value)) {
                return
            }
            val = newVal;
            // 果新的值也是对象,也会进行观测
            childOb = observe(newVal);
            // 这里通知渲染watcher执行update方法,进行组件更新
            dep.notify()
        }
    })
}

可以看到在get里加了一个调用childOb.dep.depend的逻辑,那么为什么要这么做呢,我们可以在数组的拦截方法里调用dep.notify,但是问题是,怎么把渲染watcher添加到dep中,也许你会说,在get中使用dep.depend(),然而并不是这样的,在get中调用的dep是依赖这个属性的,在数组拦截的方法中能获得的dep,是observer.dep这个属性,那怎么办呢,vue的做法也很简单,首先递归调用observe,把返回值保存在childOb上,然后在get中添加上面的几行,childOb如果存在,value一定是数组或者对象类型,那么就会调用childOb.dep.depend把渲染watcher保存起来,childOb.dep就是在数组拦截的函数中能获取到的dep,这些方法也会用这个dep触发渲染watcher重新渲染组件。

我们来做个对比,如果,在源码中注释掉新增的这几行
然后就会变成这样

可以从图中看到,就算通过push方法修改了数组也没有触发组件更新,这就是因为observe.dep这个位置上没有保存渲染watcher

然后把注释去掉
file
这次在push后,组件成功刷新,原因就是我上面说的dep里保存了渲染watcher

你以为到这里就结束了?不不不,我们还有最后一个问题要解决,那就是数组套数组的问题

多层数组

我们修改下代码

<template>
    <div class="home">
        <p>{{arr[0]}}</p>
    </div>
</template>

<script>
    export default {
        name: 'home',
        data() {
            return {
                arr : [[1,2,3],4]
            }
        },
        mounted() {
            window.vm = this;
        },
        beforeDestroy() {
            window.vm = null;
        }
    }
</script>

然后我们测试一下
file
很明显,生产的数组没有保存渲染watcher,那要怎么做呢,我们还要对defineReactive稍加改进

export function defineReactive(
    obj: Object,
    key: string,
    val: any
) {
    const dep = new Dep();

    //  递归调用进行观测
    let childOb = observe(val);

    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            // getter收集依赖
            // 获取属性对应的值
            const value = val;
            // Dep.target会存放当前组件的渲染Watcher
            if (Dep.target) {
                // 这句话会把Dep.target添加到dep的中
                // 也就是保存了watcher
                dep.depend();
                if (childOb) {
                   childOb.dep.depend();
+                  if (Array.isArray(value)) {
+                       dependArray(value);
+                  }
               }
            }
            return value
        },
        set: function reactiveSetter(newVal) {
            // setter派发更新
            const value = val;
            // 判断新旧值是否相等, 相等就不触发派发更新
            if (newVal === value || (newVal !== newVal && value !== value)) {
                return
            }
            val = newVal;
            // 果新的值也是对象,也会进行观测
            childOb = observe(newVal);
            // 这里通知渲染watcher执行update方法,进行组件更新
            dep.notify()
        }
    })
}

可以看到,新增的代码在判断value是否为Array类型后,调用了一个叫做dependArray的方法,那么这个方法做了什么呢

function dependArray(value) {
    // 遍历数组
    for (var e = (void 0), i = 0, l = value.length; i < l; i++) {
        e = value[i];
        // 看看数组这一项上有没有__ob__这个属性(就是说这一项是不是数组或者对象)
        // 有就调用dep.depend()
        e && e.__ob__ && e.__ob__.dep.depend();
        if (Array.isArray(e)) {
            // 如果这个项是Array类型就继续递归
            dependArray(e);
        }
    }
}

可以看到,vue的处理是,触发深层所有数组保存的observer.dep.depend()方法,从而让所有的子数组都能保存渲染watcher。
我们去掉源码中的注释,然后再测试一下
file
现在就好了,深层的数组也能被观测了

后记

好啦这次的分享就到这里了,如果有错误的地方,欢迎各位大佬指正,那么下次见(咕咕咕)。顺带祝各位大佬们520快乐。

点赞

发表评论

昵称和uid可以选填一个,填邮箱必填(留言回复后将会发邮件给你)
tips:输入uid可以快速获得你的昵称和头像

Title - Artist
0:00