阅读 1347

🚩Vue源码——如何监听数据变化

最近参加了很多场面试,几乎每场面试中都会问到Vue源码方面的问题。在此开一个系列的专栏,来总结一下这方面的经验,如果觉得对您有帮助的,不妨点个赞支持一下呗。

前言

Vue 是用数据来驱动来生成视图的,当数据发生改变时视图也跟随改变。要实现这个功能,首先要能监听到数据的变化,然后才能在数据发生变化时通知视图做出对应的改变。数据可分为对象类型和数组类型,其监听的过程是不一样的。

一、数据的初始化

回想一下,在 Vue 开发过程中,当改变 props 、data 中的数据时,视图也会对应的改变,可想而知 props 、data 中的数据是被监听的。 那如何对 props 、data 中的数据进行监听,其实也就是把 props 、data 中的数据变成响应式对象。 想研究 props 、data 中的数据是如何变成响应式对象,要从其初始化开始。props 、data 的初始化是在 this._init 方法中执行 initState(vm) 完成的,来看一下 initState 方法。

function initState(vm) {
    vm._watchers = [];
    var 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);
    }
}
复制代码

initState 方法主要是对 props、methods、data、computed 和 wathcer 等属性做了初始化操作。这里我们重点分析 props 和 data,对于其它属性的初始化我们之后再详细分析。

在其中 props 执行 initProps(vm, opts.props) 来初始化,data 执行 initData(vm) 来初始化,来看一下 initProps 、 initData 方法,代码做了精简,去除一些校验判断。

function initProps(vm, propsOptions) {
    var propsData = vm.$options.propsData || {};
    var props = vm._props = {};
    var isRoot = !vm.$parent;
    if (!isRoot) {
        toggleObserving(false);
    }
    var loop = function(key) {
        keys.push(key);
        var value = validateProp(key, propsOptions, propsData, vm);
        defineReactive(props, key, value);
        if (!(key in vm)) {
            proxy(vm, "_props", key);
        }
    };
    for (var key in propsOptions) loop(key);
    toggleObserving(true);
}
function initData(vm) {
    var data = vm.$options.data;
    data = vm._data = typeof data === 'function' ?
        getData(data, vm) :
        data || {};
    var keys = Object.keys(data);
    var i = keys.length;
    while (i--) {
        proxy(vm, "_data", key);
    }
    observe(data, true);
}
复制代码

在 initProps 方法中遍历 props 的数据,在遍历中调用 defineReactive 方法把每个 prop 对应的值变成响应式对象和调用 proxy 给每个 prop 对应的值做个代理。 在initData方法中遍历 data 的数据,在遍历过程中调用 proxy 给 data 中的每一个值做个代理。最后调用 observe 监听 data 的数据。

1、proxy

先介绍一下 proxy 方法,了解一下做了什么代理。

function proxy(target, sourceKey, key) {
    sharedPropertyDefinition.get = function proxyGetter() {
        return this[sourceKey][key]
    };
    sharedPropertyDefinition.set = function proxySetter(val) {
        this[sourceKey][key] = val;
    };
    Object.defineProperty(target, key, sharedPropertyDefinition);
}
复制代码

其中 Object.defineProperty 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象,先来了解一下语法。

Object.defineProperty(obj, prop, descriptor)

  • obj 要定义属性的对象
  • prop 要定义或修改的属性的名称
  • descriptor 要定义或修改的属性描述符如:
  • get 属性的 getter 函数,当访问该属性时,会调用此函数。
  • set 属性的 setter 函数,当属性值被修改时,会调用此函数。

proxy 方法的作用是把 props 和 data 上的属性代理到 vm (this)。这也就是为什么这样定义了props 和 data ,却可以通过 this.a 和 this.b 访问到 a 和 b 的属性值。

export default{
    props:{
        a: {
            type: String,
            default: ''
        },
    }
    data(){
        return {
            b: 1,
        }
    }
}
复制代码

proxy 方法的实现很简单,先把 props 和 data 上的属性赋值到 vm._props 和 vm._data 上,然后再执行 proxy(vm, "_props", key) 和 proxy(vm, "_data", key) ,通过 Object.defineProperty 把对 target[key] 的读写转成对 target[sourceKey][key] 的读写。

如对 vm.a 的读写转成对 vm._props.a 的读写,可以通过 vm._props.a 访问到定义在 props 中的属性,所以可以通过 vm.a 定义在 props 中的 a 属性。

同理,如 vm.b 的读写转成对 vm._data.b 的读写,可以通过 vm._data.b 访问到定义在 data 函数返回对象中的属性,所以可以通过 vm.b 访问到定义在 data 函数返回对象中的 b 属性。

2、defineReactive

再来看一下 defineReactive 函数,其作用就是把一个对象变成一个响应式对象。

function defineReactive(obj, key, val, customSetter, shallow) {
    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;
    if ((!getter || setter) && arguments.length === 2) {
        val = obj[key];
    }

    var childOb = !shallow && 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 (Array.isArray(value)) {
                        dependArray(value);
                    }
                }
            }
            return value
        },
        set: function reactiveSetter(newVal) {
            var value = getter ? getter.call(obj) : val;
            if (newVal === value || (newVal !== newVal && value !== value)) {
                return
            }
            if (customSetter) {
                customSetter();
            }
            if (getter && !setter) {
                return
            }
            if (setter) {
                setter.call(obj, newVal);
            } else {
                val = newVal;
            }
            childOb = !shallow && observe(newVal);
            dep.notify();
        }
    });
}
复制代码

执行 var dep = new Dep() ,创建一个订阅者收集器,这里不介绍什么是订阅者收集器,什么是订阅者,将在后续的文章中介绍。

调用 Object.getOwnPropertyDescriptor 方法获取对象的属性描述符,当且仅当该对象的属性描述符的 configurable 键值为 true 时,该对象的属性描述符才能够被修改。故当 property.configurable === false 时直接 return。

执行 var getter = property && property.get 获取对象的属性描述符的 get 属性,并缓存到常量 getter 。执行 var setter = property && property.set 获取对象的属性描述符的 set 属性,并缓存到常量 setter 。

由于接下来会使用 Object.defineProperty 方法定义对象的属性描述符的 get 属性 和 set 属性,如果一个对象的属性描述符已经定义了 get 属性 和 set 属性,避免原先的 get 属性 和 set 属性被覆盖,故要先缓存一下。

执行 (!getter || setter) && arguments.length === 2 ,若条件成立执行 val = obj[key] ,根据 key 键去 obj 对象上获取对应的值。

其中 arguments.length === 2 很好理解,当参数只有两个时,说明没有第三个参数 val ,故执行 val = obj[key] 获取 val 。 至于 (!getter || setter) 这个条件要结合一些边界场景来介绍,这里先不介绍,在本文后面会介绍。

执行 var childOb = !shallow && observe(val) 这里调用到 observe 方法,故这部分逻辑在下小节介绍 observe 方法时再介绍。

调用 Object.defineProperty 方法定义对象的属性描述符,enumerable: true 使对象可以被枚举,configurable: true 使对象的属性描述符可以被修改。

在 get 属性上定义的一个 reactiveGetter 函数,称为 getter 函数,在其中执行 var value = getter ? getter.call(obj) : val ,若对象的原先属性描述符有定义 get 属性且在前面逻辑中缓存在 getter ,故执行 getter.call(obj) 调用 getter 获取该对象的值,并赋值给 value 并返回。以下是该函数的其余逻辑,其作用是收集订阅者,先不作介绍,将在后续的专栏中介绍。

if (Dep.target) {
    dep.depend();
    if (childOb) {
        childOb.dep.depend();
        if (Array.isArray(value)) {
            dependArray(value);
        }
    }
}
复制代码

一个对象的属性描述符定义 get 属性后,每当读取这个对象的值时会调用 getter 函数得到 value ,这样就能监听到对这个对象的值的获取。

在 set 属性上定义的一个 reactiveSetter 函数,称为 setter 函数,其参数 newVal 就是给该对象赋值的值,这里称作新值。执行 var value = getter ? getter.call(obj) : val 获取对象原先的值 value 。

执行 newVal === value || (newVal !== newVal && value !== value) 将新旧值对比,要注意对象的值可能为 NaN ,故用 newVal !== newVal && value !== value 来做一下判断。若新旧值相同或者是 NaN,直接 return 。

customSetter 是 defineReactive 函数的第四个参数,是一个函数,若存在则执行。

若对象的属性描述符中原先定义的 get 属性有值,set 属性没有值,也直接 return ,这个场景要结合一些边界条件来介绍,这里先不介绍,在本文后面会介绍原因后面一起介绍。

若对象的属性描述符中原先定义的 set 属性有值,则执行 setter.call(obj, newVal) ,把新值传入原先定义的 setter 函数中执行。若 set 属性没有值,则把新值赋值给 val 。

新值可能是一个数组或者对象,所以要执行 childOb = !shallow && observe(newVal) 。

最后执行 dep.notify() ,通知订阅者执行更新,这也在后续的专栏中介绍。

一个对象的属性描述符定义 set 属性后,每当修改这个对象值时会调用 setter 函数得到 value ,这样就能监听到对这个对象的值的修改。

在 defineReactive 函数中使用 Object.defineProperty 方法定义对象的属性描述符的 get 属性 和 set 属性,故这个对象的读取还是修改都可以被监听到,从而这个对象就变成了一个响应式对象。

另外在其中多次调用 observe 函数,下面来介绍 observe 函数。

3、observe

function observe(value, asRootData) {
    if (!isObject(value) || value instanceof VNode) {
        return
    }
    var ob;
    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
    ) {
        ob = new Observer(value);
    }
    if (asRootData && ob) {
        ob.vmCount++;
    }
    return ob
}
复制代码

observe 函数是用来监听数据的变化。接收两个参数,参数 value 要被监听的数据,参数 asRootData 是一个布尔值,为true表示被监听的数据是根级数据。

执行 !isObject(value) || value instanceof VNode ,若 value 不是对象或数组类型,或是一个 VNode 类实例化的对象,则直接 return 。

定义变量ob,执行 hasOwn(value, '__ob__') && value.__ob__ instanceof Observer,若 value上有 __ob__ 这个属性,且 value.__ob__ 是 Observer 类实例化的对象,则说明 value 已经被监听,直接把 value.__ob__ 赋值给 ob 并返回,避免重复监听数据。

若 value 上没有 __ob__ 这个属性,进入 else if 逻辑。执行 shouldObserve && !isServerRendering() && (Array.isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue ,满足以上条件才执行 ob = new Observer(value) 。

  • shouldObserve 条件相当一个开关,为 true 时才执行,是用 toggleObserving 函数来控制的,因为在某些场景中要控制是否监听数据。

    function toggleObserving(value) {
        shouldObserve = value;
    }
    复制代码
  • !isServerRendering() 条件,isServerRendering 函数的返回值是一个布尔值,用来判断是否是服务端渲染,若不是返回false,就是说不是在服务端渲染时才满足条件。

  • (Array.isArray(value) || isPlainObject(value)) 条件,只有当数据是数组或纯对象时才满足条件。

  • Object.isExtensible(value) 条件,Object.isExtensible 方法用来判断数据是否是可扩展的。只有可扩展才满足条件,一个普通的对象默认就是可扩展的,但是 Object.freeze() 可以使得一个对象变得不可扩展,故要做一下判断。

  • !value._isVue 条件,value 不是 Vue 实例对象才满足条件。

当满足以上五个条件时,执行new Observer(value)并把执行结果赋值给ob。

如果 value 是根级数据且 ob 有值,则执行 ob.vmCount++ 做个标志,最后返回 ob。

下面来介绍一下 Observer 构造函数。

4、Observer构造函数

在 Observer 构造函数中,分别对数组类型和对象类型进行了监听处理。

var Observer = function Observer(value) {
    this.value = value;
    this.dep = new Dep();
    this.vmCount = 0;
    def(value, '__ob__', this);
    if (Array.isArray(value)) {
        if (hasProto) {
            protoAugment(value, arrayMethods);
        } else {
            copyAugment(value, arrayMethods, arrayKeys);
        }
        this.observeArray(value);
    } else {
        this.walk(value);
    }
}
复制代码

执行 this.value = value ,把要监听的数据 value 赋值到 Observer 类的实例对象上。

执行 this.dep = new Dep() ,创建一个订阅者收集器,并把赋值到 Observer 类的实例对象上。这里不介绍什么是订阅者收集器,什么是订阅者,将在后续的文章中介绍。

执行 this.vmCount = 0 ,把 vmCount 赋值到 Observer 类的实例对象上。

这样 Observer 类的实例对象就有三个实例属性:value、dep、vmCount。

执行 def(value, '__ob__', this) ,把自身的实例对象添加到数据 value 的 __ob__ 属性上,使value 的 __ob__ 属性上保存 Observer 类的一些实例对象和实例方法,在后续逻辑中会经常用到。另外一个对象上若有 __ob__ 属性,则代表这个对象已经被监听过。 def 方法是对 Object.defineProperty 方法的封装。这就是用 console.log 打印 data 的数据时会发现多了一个 __ob__ 属性的原因。

function def(obj, key, val, enumerable) {
    Object.defineProperty(obj, key, {
        value: val,
        enumerable: !!enumerable,
        writable: true,
        configurable: true
    });
}
复制代码

执行 if (Array.isArray(value)) 判断 value 是否是数组类型,若不是执行 this.walk(value) ,若是执行以下代码。

if (hasProto) {
    protoAugment(value, arrayMethods);
} else {
    copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
复制代码

可见在 Vue 中对数组类型的数据和对象类型的数据监听的处理方式是不同的,下面分别来介绍各自的处理方式。

二、监听对象类型的数据

在 Observer 构造函数中,对于对象类型的数据,执行 this.walk(value) 来监听。来看一下 this.walk 实例方法。

Observer.prototype.walk = function walk(obj) {
    var keys = Object.keys(obj);
    for (var i = 0; i < keys.length; i++) {
        defineReactive(obj, keys[i]);
    }
};
复制代码

执行 var keys = Object.keys(obj) 获取对象类型的数据的键值集合赋值给常量 keys。

遍历 keys 在其中执行 defineReactive(obj, keys[i]) ,defineReactive 这个函数中大部分逻辑在上面已经介绍,在 initProps 方法中,defineReactive 函数接收了三个参数,而这里的 defineReactive 函数只接收了两个参数,故会执行以下代码

if ((!getter || setter) && arguments.length === 2) {
    val = obj[key];
}
复制代码

arguments.length === 2这个条件好理解。那为什么要设置(!getter || setter)这个条件。其中getter和setter是对象的描述符属性的 get 和 set 属性的值,默认是 undefine,只有人为定义后才有值,

随后会执行 var childOb = !shallow && observe(val) ,若 val 是个对象或数组类型的数据,在 observe 函数中会执行 new Observer(value) ,在Observer构造函数中会调用 defineReactive 函数,在 defineReactive 函数会调用 observe 函数,这样形成一个递归调用,这样就保证了无论数据的结构多复杂,它的所有子属性都会被监听到。

假设一个对象的描述符属性的 get 属性有值,也就是 getter 有值,此时是不会去执行 val = obj[key],也就是 val 的值是 undefine,那么执行 var childOb = !shallow && observe(val) 会直接被 return ,不会对这个对象的子属性进行深度遍历监听。

为什么当对象的描述符属性的 set 属性有值,也就是 setter 有值,此时会执行 val = obj[key] ,然后执行 var childOb = !shallow && observe(val) 对对象的子属性进行深度遍历监听。因为当给一个对象数据赋值时,会调用 setter 函数,在其中会执行 childOb = !shallow && observe(newVal) 对新值监听。若不先对旧值进行监听,给数据赋值后就可以被监听,导致前后行为不一致,为了避免这种情况,所以在对象的描述符属性的 set 属性有值的情况下要对其子属性进行监听。

此时可以得出一个结论,在 Vue 中如果一个对像在描述符属性上自定义了 get 属性,但是没有定义 set 属性,那么这个对象的子属性不会被监听。

综上所述,监听对象类型的数据 value 过程,是先把 value 作为参数传入 observe(value) 函数,在其中执行 new Observer(value) ,然后在 Observer 构造函数中,调用 this.walk 实例方法,在 this.walk 方法中用 Object.keys() 获取 value 的键集合 keys ,然后遍历 keys 在其中执行 defineReactive(value, keys[i]) ,在 defineReactive 函数中在 value 自身的属性描述符上定义 get 和 set 属性用来监听,再通过 value[keys[i]] 获取 value 每个子属性 val ,如果 val 是对象或数组就会执行 observe(val) 来监听子属性 val,重复开始的流程,这样形成了一个递归调用,这样数据 value不管本身还是它的所有子属性都会被监听到。

三、监听数组类型的数据

在 Observer 构造函数中,对于数组类型的数据,执行以下逻辑来监听。

if (hasProto) {
    protoAugment(value, arrayMethods);
} else {
    copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
复制代码

先不管上面的 if 逻辑,来看一下 this.observeArray 实例方法。

Observer.prototype.observeArray = function observeArray (items) {
    for (var i = 0, l = items.length; i < l; i++) {
        observe(items[i]);
    }
}
复制代码

试想一下,为什么在遍历中不直接调用 defineReactive 函数来把数据变成响应式对象来监听,而是调用 observe 函数。这是因为数组的元素可以是对象、数组等,在 Vue 中对数组类型和对象类型的数据监听流程是不同的,在 defineReactive 函数是直接把对象类型的数据变成响应式对象来监听,只有在 observe 函数中才有做区分。

执行 if (hasProto),其中 hasProto 是这么定义的 var hasProto = '__proto__' in {},变量 in 对象,判断变量是否是对象的属性。

来看一下 protoAugment 函数和 copyAugment 函数。

function protoAugment(target, src) {
    target.__proto__ = src;
}
function copyAugment(target, src, keys) {
    for (var i = 0, l = keys.length; i < l; i++) {
        var key = keys[i];
        def(target, key, src[key]);
    }
}
复制代码

在 protoAugment 函数中把参数 src 赋值到参数 target 的 __proto__。对象的 __proto__ 属性的值就是它所对应的原型对象,在JS中,数组也是一个对象。那么 protoAugment 函数的作用就是把参数 target 的原型对象改成参数 src。 但 __proto__ 这个属性在一些版本的浏览器不支持,比如IE9,故要用 '__proto__' in {} 做一下兼容判断。

若是浏览器不支持 __proto__ 这个属性,则调用 copyAugment 函数,在其中通过 def方法,把参数 target 的原型对象中的值更改,def 方法已经在上面介绍过。

执行protoAugment(value, arrayMethods),其中 value 是一个数组,要把 value 的原型对象修改成 arrayMethods,那为什么要数组的原型对象修改成 arrayMethods,先来看一下 arrayMethods 是如何定义的。

var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto);
var methodsToPatch = [
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
];
methodsToPatch.forEach(function(method) {
    var original = arrayProto[method];
    def(arrayMethods, method, function mutator() {
        var args = [],
            len = arguments.length;
        while (len--) args[len] = arguments[len];
        var result = original.apply(this, args);
        var ob = this.__ob__;
        var 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
    });
});
var arrayKeys = Object.getOwnPropertyNames(arrayMethods);
复制代码

执行 var arrayProto = Array.prototype 获取数组的原型对象并赋值给 arrayProto 。执行 var arrayMethods = Object.create(arrayProto) 创建一个新对象 arrayMethods ,其拥有数组的原型对象。变量 methodsToPatch 中定义了一些常见的数组实例方法,遍历 methodsToPatch 在其中调用 def 函数修改 arrayMethods 上和 methodsToPatch 中同名的实例方法,这叫作函数劫持。

试想一下,Vue 中为什么要去劫持数组的实例方法。是因为 Object.defineProperty 方法对数组不起作用,无法在数组上定义 get 和 set 属性,导致数组不能像对象那样被监听,所以要去劫持数组的实例方法,在重新定义的实例方法中监听数组类型的数据的变化,下面来看一下怎么重新定义数组的实例方法。

执行 var original = arrayProto[method] 把数组的原实例方法缓存到常量 original,然后调用 def 函数,在 def 函数的第三个参数传入重新定义的数组实例方法。

在重新定义的数组实例方法中,执行

var args = [],len = arguments.length;
while (len--) args[len] = arguments[len];
复制代码

定义一个变量 args,然后把调用数组实例方法时的参数赋值给变量 args,然后执行 var result = original.apply(this, args),把参数传入 original 该数组原先的实例方法执行,并把执行结果赋值给常量 result 。

执行 var ob = this.__ob__,获取属于要被监听数组的 Observer 类实例化的对象赋值给常量 ob。

定义一个变量 inserted 来缓存数组的新增元素集合,因为新增的元素还未被监听,需要处理一下。因为不同的数组实例方法中的代表新增的元素的参数位置不同,例如 push 和 unshift 方法,其参数都是新增的元素,而 splice 方法只有第三个参数才是新增的元素,故要用以下逻辑处理一下。

switch (method) {
    case 'push':
    case 'unshift':
        inserted = args;
        break
    case 'splice':
        inserted = args.slice(2);
        break
}
复制代码

若新增的元素集合 inserted 存在,因为新增的元素可能存在数组或对象,所以要执行 ob.observeArray(inserted)来监听新增的元素。

当数组调用变量 methodsToPatch 中的实例方法时,执行 ob.dep.notify() 触发订阅者更新。

最后返回数组原先的实例方法的执行结果 result 。保证数组 重新定义后的实例方法 和 原先的实例方法 的功能是一致的。

若浏览器支持 __proto__ 这个属性,则执行 protoAugment(value, arrayMethods),把 value 的原型对象替换成arrayMethods。

若浏览器不支持 __proto__ 这个属性,则执行 copyAugment(value, arrayMethods, arrayKeys),arrayKeys 为 arrayMethods 的健集合,遍历 arrayKeys 在其中调用 def 函数把 value 的原型对象上跟 arrayMethods 中的同名实例方法重新定义。

综上所述,因为 Object.defineProperty 方法对数组不起作用,无法在数组上定义 get 和 set 属性,所以数组不能像对象那样被监听,于是 Vue 就定义一些常见的数组实例方法如 push 、pop 、 shift 、unshift 、 splice 、 sort 、 reverse ,然后对数组的原型对象上同名的实例方法做了函数劫持,并保持原先实例方法的功能,这样当数组使用这些实例方法时就可以被监听到,相当把数组变成一个响应式对象。这也就是在 Vue 官方文档中,使用push 、pop 、 shift 、unshift 、 splice 、 sort 、 reverse 这些实例方法来更新数组才能被监听到的原因。

四、监听数据的边界场景

1、监听数组类型数据的边界场景

在上小节讲到只有用push 、pop 、 shift 、unshift 、 splice 、 sort 、 reverse 这些在 Vue 内部重新定义的数组实例方法,去操作数组才能被监听到。其实这些数组实例方法都会去变更原始数组,称为变更方法。那还有一些数组实例方法如 filter 、concat 和 slice ,这些方法不会去变更原始数组,会返回一个新数组,称为替换方法。那用这些替换方法操作数组,会不会被监听到。 首先来看下 Vue 开发中数组类型的数据是怎么定义的。

data(){
    return {
        a: [1,2]
    }
}
复制代码

其中 a 这个数据是 data 对象中的一个值,会被监听到,这个很好理解,a 的值是个 [1,2],会用劫持数组实例方法来监听这个数组,又因为这个数组的元素不是对象也不是数组,故不监听其元素。那么现在有个疑问,如果把 [1,2]这个数组换成一个新的数组,会不会被监听到。

来看一个例子

let obj = {
    a: [1, 2]
}
let b = obj.a;
Object.defineProperty(obj, 'a', {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
        return b
    },
    set: function reactiveSetter(newVal) {
        console.log(newVal)
        console.log('set')
        b = newVal
    }
})
obj.a.push(2)
obj.a = [3, 4]
复制代码

在控制台上打印出来是 [3, 4] 、set ,所以当一个对象的值是数组,其数组被替换成一个新数组,会被监听到。所以 filter 、concat 和 slice 这些替换方法来操作数组会被监听到。

此外还要两种可以改变数组的方法也不会被监听到,如

利用数组下标直接设置一个数组项时,例如:this.items[indexOfItem] = newValue 修改数组的长度时,例如:this.items.length = newLength

第二种情况好处理,直接用 this.items.splice(newLength) 来解决。第一种情况用this.$set(this.items, indexOfItem, newValue) 来解决,this.$set 是个实例方法,该方法是全局方法 Vue.set 的一个别名。

2、监听对象类型数据的边界场景

对象类型数据的属性的添加和删除,Vue 中无法监听到。原因很简单,对象的属性只有先用Object.defineProperty 方法添加属性描述符的 get 和 set 属性才能被监听,新添加的属性肯定没先用 Object.defineProperty 方法,故无法被监听。删除的属性,其 set 属性监听不到,故无法监听。 对于对象的属性的添加有两种方法可以解决:

用原对象的属性与要新添加的属性一起创建一个新的对象,再赋值给原对象,如 this.someObject = Object.assign({}, this.someObject, { a: 1, b: 2 })。 用 this.$set(this.someObject,'b',2) 来解决,this.$set 是个实例方法,该方法是全局方法 Vue.set 的一个别名。

对于对象的属性的删除可以用 this.$delete(this.someObject,'b') 来解决,this.$delete 是个实例方法,该方法是全局方法 Vue.delete 的一个别名。

3、Vue.set的内部逻辑

Vue.set 是在 initGlobalAPI 函数中定义。initGlobalAPI 函数在定义构造函数 Vue 后马上执行。

function initGlobalAPI(Vue) {
    Vue.set = set;
}
initGlobalAPI(Vue);
复制代码

其中 Vue.set 是 set 函数赋值的,来看一下 set 函数。

function set(target, key, val) {
    if (isUndef(target) || isPrimitive(target)) {
        warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))));
    }
    if (Array.isArray(target) && isValidArrayIndex(key)) {
        target.length = Math.max(target.length, key);
        target.splice(key, 1, val);
        return val
    }
    if (key in target && !(key in Object.prototype)) {
        target[key] = val;
        return val
    }
    var ob = (target).__ob__;
    if (target._isVue || (ob && ob.vmCount)) {
        warn(
            'Avoid adding reactive properties to a Vue instance or its root $data ' +
            'at runtime - declare it upfront in the data option.'
        );
        return val
    }
    if (!ob) {
        target[key] = val;
        return val
    }
    defineReactive(ob.value, key, val);
    ob.dep.notify();
    return val
}
复制代码

执行 if (isUndef(target) || isPrimitive(target)) 判断参数 target 是否为 undefined、null、字符串、布尔值、数字。若是在控制台打出警告,无法对未定义、null或基础类型数据设置属性,其中 isPrimitive 方法代码如下。

function isPrimitive(value) {
    return (
        typeof value === 'string' ||
        typeof value === 'number' ||
        typeof value === 'symbol' ||
        typeof value === 'boolean'
    )
}
复制代码

执行 if (Array.isArray(target) && isValidArrayIndex(key)) 判断参数 target 是否为数组,若是则参数 key 应该为数组下标,用 来判断参数 key 是不是正确的数组下标,

function isValidArrayIndex(val) {
    var n = parseFloat(String(val));
    return n >= 0 && Math.floor(n) === n && isFinite(val)
}
复制代码

数组下标应该是个大于零的整数,且不是无穷大。在 isValidArrayIndex 函数先用 parseFloat 把参数 val,因为 parseFloat 的接收的参数是字符串格式,所以用 String 处理一下参数 val。这里很巧妙利用 Math.floor(n) === n 来判断参数 val 是不是整数,最后用 isFinite 判断参数 val 是不是无穷大。

若参数 key 是个正确的数组下标,执行以下逻辑

target.length = Math.max(target.length, key);
target.splice(key, 1, val);
复制代码

此时 target 是个数组,这里巧妙的应用 splice 这个数组实例方法,实现通过数组下标来添加一个数组项的功能,同时 splice 这个数组实例方法在 Vue 中被劫持过,故会被监听到。

那为什么还要重新设置一下 target 的长度。是因为 splice 方法有个缺陷,下面用一个例子说明。

let a = [1,2]
a[3]=3;
console.log(a)
let b = [1,2]
b.splice(3,1,3)
console.log(b)
复制代码

执行后看一下控制台分别打印出来 [1, 2, empty, 3] 和 [1, 2, 3] ,再看一下下面的列子

let a = [1,2]
a[2]=3;
console.log(a)
let b = [1,2]
b.splice(2,1,3)
console.log(b)
复制代码

执行后看一下控制台分别打印出来 [1, 2, 3] 和 [1, 2, 3] ,说明 splice 实例方法中的参数 key 只要超过数组的长度,那么只会在数组尾部添加上所要的数组元素。

为了避免这个缺陷,执行 target.length = Math.max(target.length, key) ,当 key 比target.length 大,就把 key 赋值给 target.length 先扩充一下数组的长度,保证通过 splice 添加数组元素和通过数组下标添加数组元素的结果是一致的。

执行 if (key in target && !(key in Object.prototype)),判断参数 key 是否是参数 target 的属性,且不是其原型对象的属性。

若是,则 target[key] 已被监听,直接把参数 val 赋值给 target[key] 即可。

执行if (target._isVue || (ob && ob.vmCount)) ,用 target._isVue 来判断参数 target 是否为 Vue 实例对象.

用 ob && ob.vmCount 来判断参数 target 是否为根数据对象(即 data 选项返回的对象),其中 ob 为参数 target.__ob__,__ob__ 为 Observer 类的实例化对象,在 Observer 构造函数中 只有 data 为根数据,才会给 vmCount 实例对象赋值。若是在控制台打出警告注意参数 target 不能是 Vue 实例,或者 Vue 实例的根数据对象。

执行 if (!ob) 判断参数 target 是否是被监听,如果不是,那么也必要去监听其子属性,执行target[key] = val 直接赋值即可。 如果是,执行 defineReactive(ob.value, key, val) 在新增属性的描述符属性上定义 get 和 set 属性来监听新增属性,其中 ob.value 是参数target变成的响应式对象,如果直接用参数 target ,会导致参数 target 本身及其子属性都无法被监听。 执行 ob.dep.notify() ,因为参数 target 新增属性了,那么本身也改变了,故触发其订阅者的更新。 最后返回新增的值 `val 。

4、Vue.delete的内部逻辑

Vue.delete 是在 initGlobalAPI 函数中定义。initGlobalAPI 函数在定义构造函数 Vue 后马上执行。

function initGlobalAPI(Vue) {
    Vue.delete = del;
}
initGlobalAPI(Vue);
复制代码

其中 Vue.delete 是 del 函数赋值的,来看一下 del 函数。

function del(target, key) {
    if (isUndef(target) || isPrimitive(target)) {
        warn(("Cannot delete reactive property on undefined, null, or primitive value: " + ((target))));
    }
    if (Array.isArray(target) && isValidArrayIndex(key)) {
        target.splice(key, 1);
        return
    }
    var ob = (target).__ob__;
    if (target._isVue || (ob && ob.vmCount)) {
        warn(
            'Avoid deleting properties on a Vue instance or its root $data ' +
            '- just set it to null.'
        );
        return
    }
    if (!hasOwn(target, key)) {
        return
    }
    delete target[key];
    if (!ob) {
        return
    }
    ob.dep.notify();
}
复制代码

里面部分逻辑和 set 函数是一模一样的,在上面已经介绍过了。来介绍一下不一样的逻辑。

当参数 target 为数组时,且参数 key 为正确的数组下标,执行 target.splice(key, 1) ,这里巧妙的应用 splice 这个数组实例方法,实现删除数组中某个元素的功能,同时 splice 这个数组实例方法在 Vue 中被劫持过,故会被监听到。

执行 if (!hasOwn(target, key)) ,判断参数 key 是否是参数 target 的属性,若不是,则直接 return 。

若不是,执行delete target[key]删除这个对象属性。

执行 var ob = (target).__ob__; if(!ob) 判断参数 target 是否被监听,若不是,则直接 return 。

若是,执行 ob.dep.notify() ,触发参数 target 本身的订阅者更新。

五、总结

在 Vue 中能监听数据变化的核销原因是利用 Object.defineProperty 方法在对象类型的数据的描述符属性上定义 get 和 set 属性,其属性值分别是 getter 和 setter 函数,当读取对象类型的数据时会触发 getter 函数,当修改对象类型的数据时会触发 setter 函数,这样就可以对对象类型的数据进行监听。又因为 Object.defineProperty 方法对数组类型的数据不起作用,则通过劫持数组实例方法的方式来监听数组类型的数据。另外 Object.defineProperty 方法在 IE8 及以下浏览器中不兼容,这也是为什么 Vue.js 不能兼容 IE8 及以下浏览器的原因。

在初始化数据时调用 observe 函数开始对数据进行监听,在 Observer 构造函数对数组类型和对象类型的数据用不同的逻辑进行监听,在 defineReactive 函数中利用 Object.defineProperty 方法把对象变成响应式对象实现了监听,再利用 observe 函数、Observer 构造函数、defineReactive 函数,相互调用形成一个递归调用,保证了一个对象无论多么复杂,其子属性都能被深度遍历监听到。