【VUE】源码分析 - 数据劫持的基本原理
从源码出发,分析vue对Array和Object不同的数据劫持原理
tips:本系列博客的代码部分(示例等除外),均出自vue源码内容,版本为2.6.14。但是为了增加易读性,会对不相关内容做选择性省略。如果大家想了解完整的源码,建议自行从官方下载。https://github.com/vuejs/vuehttps://github.com/vuejs/vue
本系列博客 ( 不定期更新 ):
【VUE】源码分析 - computed计算属性的实现原理_依然范特西fantasy的博客-CSDN博客
【VUE】源码分析 - watch侦听器的实现原理_依然范特西fantasy的博客-CSDN博客
【VUE】源码分析 - 数据劫持的基本原理_依然范特西fantasy的博客-CSDN博客
一,基本实现原理
1,核心原理:defineProperty 和 Array的实例方法劫持
defineProperty是JS的元编程手段之一。Object类型,可以通过defineProperty来实现对数据的劫持监听。
vue中的defineReactive函数正是做了这样一件事。其简化形式如下:
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
//收集依赖
return val
},
set(newValue) {
//触发依赖
val = newValue
},
});
}
通过对obj的defineProperty,在其get和set函数中做了某些操作,其中就包括了依赖的收集,和对依赖的触发。get和set函数对defineReactive函数闭包中val的操作,从而实现私有变量的对外隐蔽。
而对于数组类型的数据劫持,vue给出的方案是对其某些实例方法的进行劫持。
const methodsToPatch = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];
methodsToPatch.forEach(function (method) {
const original = Array.property[method];
def(arrayMethods, method, function mutator(...args) {
//拦截Array的几个原型方法,监听触发,并下发通知
const result = original.apply(this, args);
return result;
});
});
当使用这几种方法操作数组的时候,就可以正常的触发依赖了。
2,Watcher简介
Watcher通俗的理解,就是vue中观察者实例,其有三种类型:当前组件实例的Watcher,watch的回调函数,computed的get函数。
我们暂时不需要深究其具体的实现细节,只需要做如下理解:这些观察者会在数据变化的时候接收通知,并给出相应的行为。
3,Dep简介
Dep,即为一个个的依赖。在一个vue实例中,会有很多Dep实例,其中每一个数据,都会有其对应的Dep。比如:
data(){
return {
information:{
name:{
shortName:'abc'
}
}
}
}
上述例子中,information、name、shortName 都存在其相应的Dep。而Dep,就是去通知Watcher更新的那一个角色。一个Dep会有很多Watcher,一个Watcher也会有多个Dep。
以上是对数据劫持基本知识点的简介,下面步入正题。
二,收集依赖的实现
在收集依赖的过程中,Object和Array在vue中有不同的实现。
1,Object类型的依赖收集
通过上述的介绍我们知道,Object依赖的收集是在defineProperty的get中实现的,那么现在先单独来看defineProperty中的get函数:
function defineReactive(obj, key, value) {
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
if (Dep.target) {
dep.depend();
}
return value;
},
});
}
与之前所说的一样,每一个key,都会在其defineReactive函数闭包中创建一个Dep。而Dep.target,是一个全局唯一的属性,会在每一次需要收集依赖的时候,将某个Watcher设置为Dep.target,并在执行完之后立即重置为null。如下就是Watcher中设置target的过程:
get () {
//设置target
pushTarget(this)
//触发get
let value = this.getter.call(vm, vm)
//释放target
popTarget()
return value
}
而dep.depend(),就是将target添加上此依赖项。
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
而与此同时,随着target.addDep()触发,target也会被添加至Dep的subs中。
addDep (dep) {
//此处还会有对重复依赖的过滤,但与现在所讲的内容无关,将其忽略
this.deps.push(dep)
dep.addSub(this)
}
以上,就完成了通过get的依赖收集。但是不知道大家有没有注意到这样一个问题:新赋值(一开始就存在)的某个属性,其设置的值也会变成响应式的;但是新设置(一开始不存在)的某个属性,却不会变成响应式的。这是因为,其实在defineReactive的时候,会在set的函数内部,也为新赋值的内容执行"defineReactive"操作。如:
function defineReactive(obj, key, value) {
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
set: function reactiveSetter(newVal) {
val = newVal
//会在observe递归的将每一个属性都执行defineReactive操作
observe(newVal);
},
});
}
因为当前key原本就存在,因此其set方法中已经实现了对新设置值添加observe。
而如果是一个新设置的key,是没有对其执行过defineReactive的,因此,也就不存在"响应式"了。
2,数组的依赖收集
数组中依赖的收集,就显得简单粗暴许多:
function defineReactive(obj, key, value) {
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
if (Array.isArray(value)) {
//dependArray就是递归数组的每一项,并手动添加依赖
dependArray(value);
}
return value;
},
});
}
无论数据类型是什么,其终归是data的某个属性的值。在对data进行defineReactive的时候,如果发现值为数组,那么就会对这个数组进行"暴力依赖"。也就是无论用到的是数组的那一层,都会将数组的每一项的依赖给添加至Watcher上。如:
<template> <div :arr="arr"></div> <template>
data(){
return {
arr:[[[[1]]]]
}
}
arr是一个嵌套了4层的数组,在div中只用到了最外层。可是此时最内层数组的改变,依旧会触发div的响应。而如果是Object类型,则不会有这种问题。对于Object类型,用到了几层,就响应几层的变化。
3,$set的依赖收集
在之前有提到过,如果设置的属性是之前没有定义了,那么其就不会再是响应式的了。而vue对于这种情况,也给出了解决方案,this.$set。比如:
data(){
return {
info:{}
}
},
created(){
//直接新增的name属性并不是响应式的。
this.info.name = 'zhang san'
//通过$set新增的age属性是响应式的,对其的改变,能触发页面的刷新,watcher的回调,computed的更新
this.$set(this.info,'age',22)
}
同样的,由于底层实现的原因,对于数组的某一项的操作,是无法触发响应的。即使它是一开始就存在的某一项:
data(){
return {
arr:[1,2,3]
}
},
created(){
//不会触发响应!
arr[1] = 123
//需要通过$set
this.$set(arr,1,123)
}
其原因是对于数组的项,并没有通过Object.defineProperty去定义其get和set方法。
实际上,数组也可以通过defineProperty定义,因为数组也是对象的一种。之所以不使用,是因为数组的某些方法在操作项的时候,会表现得很"怪异"。如,unshift会触发下标为0的set,这并没有什么问题,但是同时也会触发其的get方法。并且在实际日常使用中,对数组的操作更多的是基于实例方法的操作,因此,vue选择另外一种方案——操作原型方法,来实现数组的数据劫持。
对于$set,我们暂时不需要去研究其实现原理,只需要知道,在$set内部,也使用了defineReactive(obj, key, val);方法,去为新增的属性添加依赖。
Vue.prototpye.$set = function(obj,key,val){
defineReactive(obj, key, val);
}
三,如何触发依赖
1,Object类型依赖触发的实现
正如之前所说,Object触发依赖是通过set方法实现的:
function defineReactive(obj, key, value) {
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
set() {
val = newVal;
dep.notify();
},
});
}
其中,在上述set函数中的notify(),就是一个通知所有相关Watcher的方法:
notify() {
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
}
因此,只需要相关属性值发生了变化,那么就会触发相应的依赖项。
<template>
<son1 :information="information"></son1>
<son2 :name="information.person.name"></son2>
</template>
data(){
return{
information:{person:{name:'Fantasy'}}
}
}
如上述所示,当information发生改变的时候,son1和son2都会接到通知;而当person或者name属性发生改变的时候,只有son2会接到通知。
2,数组依赖触发的实现
相对于数组的暴力添加依赖,数组触发依赖的方式就显得优雅许多。其底层原理就是通过更改一些常见的实例方法,来做某些特殊的操作,以此触发相应依赖:
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
const methodsToPatch = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method];
def(arrayMethods, method, function mutator(...args) {
const result = original.apply(this, args);
//__ob__就是Watcher
const ob = this.__ob__;
let inserted;
switch (method) {
case "push":
case "unshift":
inserted = args;
break;
case "splice":
inserted = args.slice(2);
break;
}
//如果Array有新增项的话,为其添加observe
if (inserted) ob.observeArray(inserted);
// notify change
//下发通知
ob.dep.notify();
return result;
});
});
首先简单分析一下上述代码:
1,首先取得Array原始的原型,并基于此原型创建了一个新的"vue的数组原型"。这样做的好处是,除了需要处理的几种方法,其他的数组方法依旧可以通过原型链调用,而不需要额外的添加。
2,定义了一系列需要处理的方法名,并基于这些方法名做循环,为"vue的数组原型"新增methods。
3,在循环的内部,定义这些方法。并且在这些方法之前,会先调用原始的原型方法。意味着vue并不会干预原型方法的调用,只是在此基础上添加了一些额外的操作。
4,根据事件类型的不同,做特殊处理(push,shift,splice)。这三种方法皆可用来做新增操作,因此这一步的处理,其实际上就是对某些新增项的处理。其中,对于新增的项,push,unshift为第一个参数,而splice为第三个参数。
5,对于新增项inserted做observe处理,也就是将其观察起来,变成响应式的数据。这里我们之前并没有讲到,起始对于数组的三种新增行为,也是会自动的将值变为响应式对象的,同Object的set方法一样。
6,下发通知。告诉每一个Watcher当前数组发生了变化。
7,返回结果。并且结果就为原始原型的返回值。这么做的目的,也是尽可能的不去改变原型方法的行为特性。
以上,就是整个数组触发依赖的原理及过程。并且从上述源码中可以看到,vue一共对这7种方法做了劫持处理:push,pop,shift,unshift,splice,sort,reverse。其中5种增删操作,和两种排序操作。也就意味着,如果在这7种方法之外的数组操作,vue是无法监听到的。但实际上,这几种方法已经覆盖了90%的开发场景。
3,$set触发依赖的实现
$set触发依赖有两种:1,通过数组或Object的响应式原理来触发依赖;2,vue响应式原理无法涵盖的场景,则手动触发依赖:
Vue.prototype.$set = function (target, key, val) {
if (Array.isArray(target)) {
//对于数组,则先更新其长度,再用splice实现添加并同时触发依赖
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;
}
//如果都不满足,则手动设置,并手动触发依赖
defineReactive(target, key, val);
target.__ob__.dep.notify();
return val;
};
其中有一点,当某个属性已存在之时,$set并不会改变其特性(响应或非响应)。或许是vue有意为之,又或许是个漏洞,谁知道呢!如果觉得疑惑,可以去vue的github下提交代码申请,或许会有人为你解惑。
顺带提一句,我在很多地方,看到一些对于vue的解释是很不准确的。比如子组件和父组件的update生命周期的执行顺序,其实是并没有严格的顺序可言的。但是很多人会将其解释为:
父beforeUpdate -> 子beforeUpdate -> 子undated -> 父updated。
子组件的更新依赖于父组件和父组件传递的props,但并不是说子组件更新了,父组件就一定同时更新。这个现象可以用本篇博客所讲到的依赖添加来解释:若子组件绑定了某个props的子属性,但是父组件中并不依赖于此属性,那么这个时候,若此子属性发生变化,父组件并不会更新,但是子组件会更新。
网上是一个言论过于自由的地方,或许有些人只是出于热心,误传了一些不准确的信息和言论。但是主观而言,应该对所有的这些,非官方的言论抱有怀疑的态度。比如我现在的这篇博客,大家只需要相信50%即可,因为我也只是一个vue的使用者,很多理解是主观的,有偏差的。
如果大家遇到了某些问题无法解决,那么官网一定作为你的第一渠道。其他途径,仅供参考。
文中内容均带有个人理解,并不保证权威。若有错误,欢迎随时批评指正。
更多推荐
所有评论(0)