根据源码解析Vue2中对于数组的变化侦测

时间:2024-09-30 07:42:39

为什么object和array数据会有两种不同的侦测方式?

因为对于object数据我们使用的是JS提供的对象原型上的方法Object.defineProperty,而这个方法是对象原型上的,Array无法使用这个方法,所以我们需要对Array数据设计一套另外的变化侦测机制

虽然设计新的计算值,但是其根本思路还是不变的:在获取数据时收集依赖,在数据变化时通知更新。

1.在哪里收集依赖

Array的依赖收集方式和object的一样,都是在getter中收集,问题来了,不是说Array无法使用defineProperty吗?怎么还在getter中收集呢。

其实在平常开发中,在组件的data里面写array,array仍然存放与一个object数据对象中,那么要用array数据,是不是得先从object数据对象中获取array数据,而从object数据对象中获取数据就会触发getter,所以我们还是可以在getter中收集依赖。

2.使Array型数据可观测

Object的变化时通过setter来追踪的,只要某个数据发生变化,就一定会触发这个数据上的setter,但是Array型数据没有setter,怎么办?

试想一下,如果数组发生了变化,那必然是操作了数组,而JS中提供操作数组的方法不多,所以我们可以把这些方法全部重写一遍,在不改变原来功能的前提下,新增一些功能。

vue内部就是这么干的

3.方法拦截器

VUE创建了一个数组方法拦截器,它拦截在数组实例与Array.prototype之间,在拦截器内重写了操作数组的一些方法,当数组实例使用操作数组方法时,实际上使用的是拦截器重写的方法,而不是原生。

经过整理,数组原型中可以改变数组自身的方法有7个:

push,pop,shift,unshift,splice,sort,reverse。那么拦截器代码如下:

// 源码位置:/src/core/observer/array.ts
​
const arrayProto = Array.prototype
// 创建一个对象作为拦截器
export const arrayMethods = Object.create(arrayProto)
​
// 改变数组自身内容的7个方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
​
/**
 * Intercept mutating methods and emit events
 */
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
      }
  })
})

首先创建了继承Array的空对象arrayMethods,接着在arrayMethods上使用Object.defineProperty方法将那些可以改变数组自身的七个方法遍历逐个进行封装。比如我们使用push方法时,其实用的是arrayMethods.push,而这个其时就是封装的新函数mutator,函数内部执行了原生方法,那么接下来我们就可以在mutator里面执行我们自己的操作了。

4.挂载拦截器

拦截器写好后,把他挂载在数组实例与Array.prototype之间,这样就可以生效了。

挂载不难,只需要把数据的proto属性设置为拦截器arrayMethods即可;

// 源码位置:/src/core/observer/index.js
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)
    }
  }
}
// 能力检测:判断__proto__是否可用,因为有的浏览器不支持该属性
export const hasProto = '__proto__' in {}
​
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
​
/**
 * Augment an target Object or Array by intercepting
 * the prototype chain using __proto__
 */
function protoAugment (target, src: Object, keys: any) {
  target.__proto__ = src
}
​
/**
 * Augment an target Object or Array by defining
 * hidden properties.
 */
/* istanbul ignore next */
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])
  }
}

上述Vue的代码首先判断浏览器是否自持proto,如果支持则调用protoAugment函数直接挂载proto,如果不支持,则调用copyAugment把重写的七个方法循环加入

这样,数组数据再发生变化时,我们就可以通过拦截器获取变化然后通知依赖了。

5.依赖收集

应该把依赖收集到Observe类中

// 源码位置:/src/core/observer/index.js
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)
    }
  }
}

数组的依赖也在getter中收集,那么到底应该如何收集?这里有一个注意点,就是依赖管理器定义在Observe类中,而我们需要再getter中收集依赖,也就是说我们必须在getter中可以访问observe类的依赖管理器,才能把依赖存进去,源码是这样做的:

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){
            if(val === newVal){
                return
            }
            val = newVal;
            dep.notify()   // 在setter中通知依赖更新
        }
    })
}
​
/**
 * Attempt to create an observer instance for a value,
 * returns the new observer if successfully observed,
 * or the existing observer if the value already has one.
 * 尝试为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函数为被获取的数据arr尝试创建一个Observe实例,在observe函数内部,先判断当前传入的数据上是否有ob属性,如果没有则表示该数据还不是响应式,那么调用new observe(value)将其转化为响应式的,并返回observe实例。

而在defineReactive函数中,首先获取数据对应的observe实例,然后在getter中调用observe实例上依赖管理器,从而收集依赖

接下来是如何通知依赖

我们应该在拦截器里通知依赖,要想通知依赖,首先要能访问依赖,要访问依赖也不难,因为我们只要能访问到被转化成响应式的数据value即可,因为上面的ob就是其对应的Observe实例,有了这个我们就能访问到依赖管理器,然后只需要调用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__
    // notify change
    ob.dep.notify()
    return result
  })
})

由于我们的拦截器是挂载到数组数据的原型上的,所以拦截器的this就是数据value。

6.深度侦测

深度侦测是不但要侦测数据本身的变化,还要侦测子数据的变化。

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)
    }
  }
​
  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}
​
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
}

对于Array数据,调用了observeArray方法,该方法内部会遍历数组中的每一个元素,然后通过调用observe函数将每一个元素都转化成可侦测的响应式数据。

7.新增元素侦测

对于数组新增的元素,我们需要将他转化为响应式,只需要拿到这个元素,然后调用observe函数将其转化。

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) // 调用observe函数将新增的元素转化成响应式
    // notify change
    ob.dep.notify()
    return result
  })
})

在上面拦截器定义代码中,如果是pushunshift方法,那么传入参数就是新增的元素;如果是splice方法,那么传入参数列表中下标为2的就是新增的元素,拿到新增的元素后,就可以调用observe函数将新增的元素转化成响应式的了。

8.不足之处

综上,我们对于数组的变化侦测是通过拦截器实现的,也就是只要是通过数组原型的方法对数组的操作都可以侦测到,但是数组操作并不是只有方法操作,还可以直接使用下标操作数组,这样的操作方式是无法检测到的。

同样,vue也注意到了这个问题,所以添加了两个全局API:$set,$delete,同上。

9.总结

首先我们分析了对于Array型数据也在getter中进行依赖收集;其次我们发现,当数组数据被访问时我们轻而易举可以知道,但是被修改时我们却很难知道,为了解决这一问题,我们创建了数组方法拦截器,从而成功的将数组数据变的可观测。接着我们对数组的依赖收集及数据变化如何通知依赖进行了深入分析;最后我们发现Vue不但对数组自身进行了变化侦测,还对数组中的每一个元素以及新增的元素都进行了变化侦测,我们也分析了其实现原理。