大家在面试中都会遇见问,你知道vue响应式的实现原理吗?我们常常会通过背面试题说通过Object.defineProperty,通过改写原型上的get和set方法从而实现数据挟持。但vue的响应式真的就这么简单吗?
什么是Object.defineProperty
首先我们来了解一下Object.defineProperty,看文档介绍:
Object.defineProperty(obj, prop, desc)
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
obj:需要被重新定义属性的对象
prop:对象上需要被重新定义的属性名
desc:该属性被重新定义的值
在vue中应用
那么在vue中我们又是如何使用它的呢
//该代码位于vue/src/core/observer/index
//注册一个数据挟持方法 proxy
function defineReactive(data,key,value){
Object.defineProperty(data,key,{
get(){
return value
},
set(newValue){
if(newValue == value) return;//如果新值和旧值相同
value = newValue;
}
})
}
上述就是一个简单的数据挟持的实现,但这个方法又如何在我们的vue中生效的呢?
那就是在vue初始化的时候,在初始化initState的时候(可以去了解下initState,就是将我们写在data,computed上的变量挂载在this上,让我们可以this.XXX使用的过程),每个属性挂载的同时,就对每个属性进行数据挟持,就完成了我们最初步的响应式。
但响应式远远不止这些,首先问题就是这只解决了挂载在this上的数据进行了响应式变化,但是如果这个数据是个对象,例如this.a.b。当b发生变化时,我们的vue也是可以做出响应的,那他是如何做到的呢?
其实很简单,那就是在赋值a的时候,同时循环a,递归的对数据进行挟持,这个方法在vue源码中叫walk
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
这样,如果我们要赋的值是一个对象,我们也可以对这个对象的属性进行挟持,从而达到深度挟持。
现在出现了另外一个问题,数组也是对象,数组有很多方法可以直接改变数组本身而不会改变数组的引用地址,所以无法触发set和get,如果检测数据内部每一个的变化,就需要循环观察每个值,但问题在于我们对数组的操作很多时候是用的数组原生的方法(如push),而不会通过角标去改变,如a[0]=XXXXX;而且如果要循环数组从而给数组的每个元素都加上观察者,那么这将是一个巨大的内存消耗,因为数组的长度可能会很大(但如果该元素是对象还是需要增加observer)。这时我们采用的方法是重写可能数组的方法:
首先我们要知道那些方法会改变数组,在vue中被重写的方法包括push,pop,shift,splice,sort,reverse;其中push,unshift,splice会改变数组长度,需要对新增的数据(如果是对象)需要加上observer。(如果是删除操作,元素如果是对象就已经加上observer了不需要重复添加),最后返回被改写的方法。
//该代码位于vue/src/core/observer/array.js
const arrayProto = Array.prototype//先把数组的方法拷贝一份出来,以免影响到其他数组也触发
export const arrayMethods = Object.create(arrayProto) //导出改写后的
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]//定义出要重写的数组方法
methodsToPatch.forEach(function (method) {
const original = arrayProto[method]//缓存原方法
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)//要执行以下原方法,并把this指向改为数组本身
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)//对新增的元素如果是对象,则绑定observe
return result
})
})
所以最终我们的Observe的定义方法就是如果是对象,那么我们就递归增加观察者,如果是数组,我们观察数组本身,并改写数组原生方法,当数组调用原生方法的时候,我们实际执行的是被重写后的数组方法(执行原数组方法并通知改变视图),所以数组元素上并没有观察者observe,所以我们直接通过this.a[0] = {}是不能触发视图变化的,但这个数组元素如果是个对象,那他的所有属性是增加了观察者的,所以this.a[0].b = XXX又是能触发视图变化的
//简写版observe
class Observer{
constructor(data){
if(Array.isArray(data)){
// 监控改变数组本身的方法
//arrayMethods就是刚刚我们重写的数组方法并重置给当前的数组
data.__proto__ = arrayMethods; // 通过原型链 向上查找的方式
this.observeArray(data);
}else{
this.walk(data); // 可以对数据一步一步的处理
}
}
observeArray(data){
for(let i =0 ; i< data.length;i++){
observe(data[i]);// 检测数组的对象类型
}
}
walk(data){
// 对象的循环 data:{msg:'zf',age:11}
Object.keys(data).forEach(key=>{
defineReactive(data,key,data[key]);// 定义响应式的数据变化
})
}
}
//最终的observe,如果是对象就观察他,如果不是就忽略
export function observe(data){
// 对象就是使用defineProperty 来实现响应式原理
// 如果这个数据不是对象 或者是null 那就不用监控了
if(!isObject(data)){
return;
}
// 对数据进行defineProperty
return new Observer(data); // 可以看到当前数据是否被观测过
}
所以当在问到vue的响应式的时候,你可以这么回答或许会更好:
通过创建观察者,在vue初始化赋值data等值的时候,递归观察所有属性,通过Object.defineProperty来改写数据的set和get方法,从而获取到每个值得变化,(数组不是用的递归而是改写数组内部方法获取到数据变化),获取到数据变化就可以操作虚拟dom,从而达到响应式
这就是vue响应式的相关代码,你学到了吗?