【Vue2深度学习】变化侦测篇-Array的变化侦测

417 阅读5分钟

前言

我们知道,Object的变化是靠setter来追踪的,只要一个数据发生变化,就一定会触发setter。那Array是不是这样呢?

我们先来看下面这个例子:

this.list.push(1)

该例子中,我们使用了push方法向list中新增了数字1,改变了list数组,但并没有触发setter。

也就是说,我们可以通过Array原型上的方法来改变数组的内容,而无需触发setter,所以Object那种通过getter/setter来实现侦测的方式用在Array身上就行不通了。

为此,Vue中,专门创建了Array变化侦测机制。

虽然Object和Array的变化侦测机制不同,但是在讲述Object变化侦测机制中提到的Observer、Dep、watcher三个类及其概念,同样适用于Array。

下面我们正式开始Array的变化侦测的介绍。

Array变化如何侦测

前面的例子使用push来改变数组的内容,那么我们只要能在用户使用push操作数组的时候得到通知,那么就能追踪数据的变化了。来看看下面这段代码:

let arr = [1,2,3]
arr.push(4)
Array.prototype.newPush = function(val){
  console.log('arr被修改了')
  this.push(val)
}
arr.newPush(4)

我们针对数组的原生push方法定义了一个新的newPush方法,这个newPush方法内部调用了原生push方法,这样既能保证新的newPush方法跟原生push方法具有相同的功能,而且我们还能得知数组变化。

创建拦截器

基于上述思想,Vue中创建了一个数组方法拦截器,重写了操作数组的方法,并把它拦截在数组实例与Array.prototype之间。当数组实例使用操作数组方法时,其实使用的是拦截器中重写的方法,而不再使用Array.prototype上的原生方法。

具体实现代码如下:

const arrayProto = Array.prototype
// 创建一个对象作为拦截器
export const arrayMethods = Object.create(arrayProto)
​
// 改变数组自身内容的7个方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
​
methodsToPatch.forEach(function (method) {
  const original = arrayProto[method]      // 缓存原生方法
  Object.defineProperty(arrayMethods, method, {
    enumerable: false,
    configurable: true,
    writable: true,
    value:function mutator(...args){
      const result = original.apply(this, args)
      return result
    }
  })
})
​

在上面的代码中,我们创建了变量arrayMethods,它继承自Array.prototype,具备其所有功能。

接下来,在arrayMethods上使用Object.defineProperty方法,将那些可以 改变数组自身内容的方法(push,pop,shift,unshift,splice, sort, reverse)进行封装。

所以,当使用push方法的时候,其实调用的是arrayMethods.push,执行的是mutator函数,而mutator是可控的,我们可以在里面做一些其他的事情,诸如发送变化通知等。

挂载拦截器

有了拦截器之后,想要它生效,就需要使用它去覆盖Array.prototype。但是我们不能直接覆盖,因为这样会污染全局的Array,所以我们可以这样操作:

const hasProto = '__proto__' in {}
​
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
​
export class Observer {
  constructor (value) {
    this.value = value
    if (Array.isArray(value)) {
        //挂载关键代码
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
    } else {
      this.walk(value)
    }
  }
}
​
function protoAugment (target, src: Object, keys: any) {
  target.__proto__ = src
}
​
function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}
​

上面代码中首先判断了浏览器是否支持__proto__,如果支持,则调用protoAugment函数覆盖value原型功能;如果不支持,则调用copyAugment函数把拦截器中重写的7个方法循环加入到value上。这样,拦截器就可以生效了。

拦截器生效以后,当数组数据再发生变化时,我们就可以在拦截器中通知变化了。

Array依赖在哪收集

我们创建了拦截器,让我们具备了当数组内容发生变化时得到通知的能力。但是变化了通知谁呢?当然是用到了Array型数据的那些依赖。那么这些依赖,我们该如何收集呢?

在这之前,我们先简单回顾一下Object的依赖的在哪收集的。

Object的依赖是在Object.defineProperty中的getter里收集的,每个key都会有一个对应的Dep列表来存储依赖。

那么,数组在哪里收集依赖呢?其实,数组也是在getter中收集依赖的。为什么这么说呢?我们来看看下面这个例子。

data(){
  return {
    list:[1,2,3]
  }
}

想想看,list这个数据始终都存在于一个object数据对象中,而且我们也说了,谁用到了数据谁就是依赖,那么要用到list这个数据,是不是得先从object数据对象中获取一下list数据,而从object数据对象中获取list数据自然就会触发list的getter,所以我们就可以在getter中收集依赖。

总结一句话就是:Array型数据还是在getter中收集依赖。

Array依赖列表放在哪

知道在哪收集依赖了,那么将收集来的的依赖列表放在哪呢?Vue中,把Array的依赖列表放在Observer中。

export class Observer {
  constructor (value) {
    this.value = value
    this.dep = new Dep()    // 实例化一个依赖管理器,用来收集数组依赖
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
    } else {
      this.walk(value)
    }
  }
}

从上面的介绍中,我们知道,Array在getter中收集依赖,在拦截器中通知依赖更新。所以这个依赖保存的位置就很关键,它必须在getter和拦截器中都可以访问到,而Observer实例正好在getter中、拦截器中都能访问到。

Array依赖如何收集

把Dep实例保存在Observer的属性上之后,我们可以在getter中像下面这样访问并收集依赖。

function defineReactive (obj,key,val) {
  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get(){
      //收集依赖
      if (childOb) {
        childOb.dep.depend()
      }
      return val;
    },
    set(newVal){
     
    }
  })
}
​
​
/**
 * 尝试为value创建一个0bserver实例,如果创建成功,直接返回新创建的Observer实例。
 * 如果 Value 已经存在一个Observer实例,则直接返回它
 */
export function observe (value, asRootData){
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
}

在上面的代码中,我们通过 observe函数,创建一个Observer实例,如果value已经是响应式数据,则不需要再次创建Observer实例,直接返回已经创建的Observer实例即可,避免了重复侦测value变化的问题。

Observer实例childOb创建后,我们就可以访问其dep属性 ,调用该属性上的depend()方法,收集依赖了。

如何通知Array依赖更新

到现在为止,依赖已经收集好了,并且也已经存放好了,那么我们该如何通知依赖呢?

因为我们是在拦截器中获知数组变化的,所以我们应该在拦截器里通知依赖,而要想通知依赖,首先要能访问到依赖。

export class Observer实例。 {
  constructor (value) {
    this.value = value
    this.dep = new Dep() 
    def(value,'__ob__',this) //通过def工具函数,在value中新增一个__ob__属性,这个属性的值就是当前的Observer实例。
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
    } else {
      this.walk(value)
    }
  }
}

上述代码中,我们通过def工具函数,在value中新增一个__ob__属性,这个属性的值就是当前的Observer实例。然后我们就可以在拦截器中,通过__ob__属性拿到Observer实例,然后就可以拿到 __ob__ 的dep了,从而调用dep身上的notify()方法通知依赖更新。具体代码如下:

methodsToPatch.forEach(function (method) {
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__ //拿到Observer实例
    ob.dep.notify()//拿到Observer实例的dep属性,调用notify()方法通知依赖更新
    return result
  })
})

Array的深度监测

我们上面说的侦测数组的变化,指的是数组自身的变化,比如是否新增一个元素,是否删除一个元素等。

实际上,数组的子数据的变化也要侦测。比如数组中Object身上某个属性的值发生了变化也要发送通知。再比如,使用了push往数组中新增了元素,这个新增元素的变化也要侦测。

侦测数组中元素的变化

如何侦测数组中子数据的变化,我们来看看下面这段代码:

export class Observer {
  value: any;
  dep: Dep;
​
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)   // 将数组中的所有元素都转化为可被侦测的响应式
    } else {
      this.walk(value)
    }
  }
  // 侦测数组中的每一项
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

在上面代码中,我们调用observeArray方法,循环Array中的每一项,执行observe函数,将数组中的每个元素都执行一遍new Observer,即可将这个数组的所有子数据转换成响应式的。

侦测新增元素的变化

对于数组中已有的元素我们已经可以将其全部转化成可侦测的响应式数据了,但是如果向数组里新增一个元素的话,我们也需要将新增的这个元素转化成可侦测的响应式数据。

如若达到此目的,我们需要拿到新增的这个元素,然后调用observeArray函数将其转化即可。

我们知道,可以向数组内新增元素的方法有3个,分别是:push、unshift、splice。我们只需对这3中方法分别处理,拿到新增的元素,再将其转化即可。

​
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args   // 如果是push或unshift方法,那么传入参数就是新增的元素
        break
      case 'splice':
        inserted = args.slice(2) // 如果是splice方法,那么传入参数列表中下标为2的就是新增的元素
        break
    }
    if (inserted) ob.observeArray(inserted) // 调用observeArray函数将新增的元素转化成响应式
    // notify change
    ob.dep.notify()
    return result
  })
})

在上述代码中,我们从this.__ob__上拿到Observer实例后,如果有新增元素,则使用ob.observeArray来侦测这些新元素的变化。

关于Array侦测的问题

从前面的介绍,我们知道,Vue2中对Array的变化侦测是通过拦截原型中操作数组的方法的方式实现的,但是,其实我们是可以不通过使用数组原型方法来改变数组的。例如:

this.list[0] = 2 // 改变数组的第一个值this.list.length = 0 // 清空数组

如果使用上述方式改变数组,Vue是侦测不到的。

为了解决这一问题,与Object一样,同样需用到了Vue.set和Vue.delete这两个API。

总结

Array追踪变化的方式和Object不一样。因为它是通过方法来改变内容的,所以我们通过创建拦截器去覆盖数组原型的方式来追踪变化。

Array收集依赖的方式和Object一样,都是在getter中收集。但是由于使用依赖的位置不同,数组要在拦截器中向依赖发送消息,所以依赖不能像Object那样保存在defineReactive中,而是把依赖保存在了Observer实例上。

除了侦测数组自身的变化外,数组中元素发生的变化也要侦测。

除了侦测已有数据外,当使用push等方法向数组中新增数据时,新增的数据也要进行变化侦测。