你不知道的 Vue 响应式

505 阅读5分钟

  大家在面试中都会遇见问,你知道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响应式的相关代码,你学到了吗?