最近参加了很多场面试,几乎每场面试中都会问到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
函数,相互调用形成一个递归调用,保证了一个对象无论多么复杂,其子属性都能被深度遍历监听到。