[Vue源码]Vue是怎么对数组的变更方法进行增强的

1,205 阅读3分钟

前言

Vue 将被侦听的数组的变更方法进行了包裹,所以它们也将会触发视图更新。这些被包裹过的方法包括:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

已知Vue通过data中的数据进行递归遍历,然后用Object.defineProperty对其设置存取描述符从而达到响应式。然而其实在上述步骤时,是针对数据类型是对象的变量时采取的方式。

在针对数据类型为数组的数据,Vue会采用另一种处理方式,在给数组中的元素设置响应式的同时,给该数组的变更方法进行响应式增强。下面来分析一下,Vue是如何对数组进行处理的:

Observer类

首先要知道,Vue针对每个data都用Observer类进行处理,我们先从Vue的构造函数出发,分析流程是如何走到Obeserver这一步的。

src\core\instance\index.js

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}
// 注册vm的_init()方法
initMixin(Vue)
// 注册vm的$data/$props/$set/$delete/$watch
stateMixin(Vue)
// 事件相关 $on/$once/$off/$emit
eventsMixin(Vue)
// 初始化声明周期相关的混入方法
lifecycleMixin(Vue)
// $nextTick/_render
renderMixin(Vue)

export default Vue

构造函数可知,初始化时会调用实例的_init方法,而该方法在initMixin函数中设置的,接下来看initMixin函数。

src\core\instance\init.js

export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
	// ... 省略
    // vm状态的初始化
    initState(vm)
	// ... 省略
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

这个函数代码过多我直接省略不涉及到响应式处理的,initState方法用于处理vm.$options的属性,就是props,data,methods那些,接下来看一下initState方法的内部逻辑。

src\core\instance\state.js

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  // 处理props
  if (opts.props) initProps(vm, opts.props)
  // 处理methods
  if (opts.methods) initMethods(vm, opts.methods)
  // 处理data
  if (opts.data) {
    // 把组件中data的成员注入到实例中,且转换成响应式
    initData(vm)
  } else {
    // 初始化vm._data且把其转换为响应式,由此看出observe函数为响应式处理函数
    observe(vm._data = {}, true /* asRootData */)
  }
  // 处理computed
  if (opts.computed) initComputed(vm, opts.computed)
  // 处理watch
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

从上可知,处理data时,如果vm.$options.data不为空,则改用initData方法处理。否则把vm._data置为空对象且调用observe方法,该方法用于把传入的形参置为响应式。其实在initData函数的逻辑里,到最后也是调用observe方法。可见observe方法就是响应式处理函数。接下来再看observe方法的内部逻辑。

src\core\observer\index.js

export function observe (value: any, asRootData: ?boolean): Observer | void {
  // 如果传入的value不是一个对象或者是VNode的实例,则直接返回
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  // ob用于存放observer变量
  let ob: Observer | void
  // 如果value中有__ob__属性(observer对象)且该属性为Observer的实例
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if ( // 判断是否可以进行响应式处理
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

可见,经过条件判断后初始化Observer实例并把value传入到构造函数中。

到目前为止,从初始化Vue实例到初始化Observer实例的整个过程可以总结为下:

如何做到数组增强

终于到文章的核心了,现在从Observer类的内部逻辑进行分析:

src\core\observer\index.js

import { arrayMethods } from './array'

const arrayKeys = Object.getOwnPropertyNames(arrayMethods)

export class Observer {
  // 要置为响应式的数据
  value: any;
  // 依赖对象
  dep: Dep;
  // 实例计数器
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    // 通过Object.defineProperty把实例挂载到value.__ob__中
    def(value, '__ob__', this)
    if (Array.isArray(value)) { // value为数组的情况下的处理
      // 当浏览器支持访问__proto__属性时
      if (hasProto) {
        // 通过原型继承改变在数组的原型属性,让其__proto__指向arrayMethods
        protoAugment(value, arrayMethods)
      } else {
        // 利用Object.defineProperty覆盖数组中的方法
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else { // value为对象的情况下的处理
      // 遍历对象中的属性,将其添加存取描述符(get/set)
      this.walk(value)
    }
  }

  // 把obj以及obj中的属性设置为响应式,非文章重点不展示细节
  walk (obj: Object) {}

  // 把数组中的元素通过observe方法置为响应式
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

处理数组用到的两个方法protoAugmentcopyAugment都用到外部引入的arrayMethods。这里先不看protoAugmentcopyAugment的内部逻辑,先分析arrayMethods涉及到的文件的源码:

src\core\observer\array.js

const arrayProto = Array.prototype
// 使用数组的原型创建一个新的对象
export const arrayMethods = Object.create(arrayProto)
// 会修改数组的方法,arrayMethods中的原型会对这些方法进行增强,从而达到响应式的效果
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(function (method) {
  // cache original method
  // 根据方法名获取Array.prototype中的数组原方法且暂存下来
  const original = arrayProto[method]
  // 调用Object.defineProperty重新定义修改数组的方法
  def(arrayMethods, method, function mutator (...args) {
    // 原始方法
    const result = original.apply(this, args)
    // 获取数组的observer
    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)
    // notify change
    // 调用了修改数组的方法后,通知dep触发依赖
    ob.dep.notify()
    return result
  })
})

其中,def方法代码如下:

/**
 * Define a property.
 */
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

从上可知,通过遍历methodsToPatch中的字符串元素,把这些字符串作为属性名通过def方法定义到arrayMethods中,传入数据描述符value统一为mutator方法。mutator方法主要做了三件事:

  1. 先执行数组原方法
  2. 若是新增元素,则把新增的元素置为响应式
  3. 通知dep触发页面更新

回到Observer类中继续分析,如果hasProto为真,即'__proto__' in {}为真,则使用protoAugment方法使value__proto__指向arrayMethodsprotoAugment代码如下:

function protoAugment (target, src: Object) {
  target.__proto__ = src
}

如果hasProto为假,则使用copyAugmentarrayMethods中的方法都定义到value上。copyAugment代码如下:

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])
  }
}

看到这里,应该基本清楚数组增强是一个怎样的过程,把上面的流程总结为一张图,如下:

后记

本文持续未完,等我看到Vue3的源码后,会把Vue3中的响应式处理与Vue2的作对比,把这篇文章继续拓展下去。