Vue 数组操作及源码分析

3,009 阅读4分钟

Vue 在操作数组时的问题

Vue 中我们在操作数组时会遇到一些问题,明明数据已经被修改了,但是为什么页面没有刷新呢?对于这个问题官方文档中已经给出答案,检测变化的注意事项

由于 JavaScript 的限制,Vue 不能检测数组和对象的变化。尽管如此我们还是有一些办法来回避这些限制并保证它们的响应性。

Vue 不能检测以下数组的变动:

  1. 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如:vm.items.length = newLength

问题1.png

问题2.png

7 个方法的包装

Vue 将 Array 的 7 个方法进行了包装,使用这些方法操作数组可以出发页面的刷新。

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

7 个方法的使用

  • push() 方法用于向数组末尾添加元素,可添加一个或多个,返回值为数组 length

  • pop() 方法用于删除数组末尾元素,返回值为删除的元素

  • shift() 方法用于删除数组第一个元素,返回值为删除的元素

  • unshift() 方法用于像数组首部添加元素,可添加一个或多个,返回值为数组 length

  • splice() 方法用于删除、添加、替换元素,

    • arrayObject.splice(index,howmany,item1,.....,itemX) 第一个参数 index 为其实下标,第二个参数 howmany 为删除个数,第三个及之后参数为像数组添加的元素

    • 如果只传第一个元素,则删除下标 index 后的所有元素

    • 如果传两个参数,则删除起始下标 index 开始之后的 howmany 个元素

    • 如果传三个参数,则删除起始下标 index 开始之后的 howmany 个元素,添加 items 元素

  • sort() 方法用于对数组排序,不传参数则按照字符编码排序,也可传入一个有两个形参的回调函数,然后根据返回值排序,(此处将回调函数的参数命名为 a 和 b)

    • 若 a 小于 b,在排序后的数组中 a 应该出现在 b 之前,则返回一个小于 0 的值。
    • 若 a 等于 b,则返回 0。
    • 若 a 大于 b,则返回一个大于 0 的值。
  • reverse() 方法用于颠倒数组中元素的顺序

7 个数组方法的实现原理

这 7 个方法能够出发页面的刷新并不是因为他原本就能够触发,而是在 Vue 中对这 7 个方法做了特殊的逻辑处理,其实现方式就是对这 7 个方法进行了包装

.\src\core\observer\array.js

import { def } from '../util/index'

// 存储 Array 的 prototype 对象
const arrayProto = Array.prototype
// 创建原型指向 arrayProto 对象的 arrayMethods 对象
export const arrayMethods = Object.create(arrayProto)

// 定义 7 个方法名称的数组
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

// 遍历 7 个方法名称的数组
methodsToPatch.forEach(function (method) {
  // 存储 Array 原本的原型方法,放在这里取是因为只需要取一次,利用闭包以空间换时间
  const original = arrayProto[method]
  // def 就是对 defineProperty 的简单包装,将我们包装后的方法添加到 arrayMethods 对象中
  def(arrayMethods, method, function mutator (...args) {
    // 先调用 Array 原本的原型方法,进行方法原有的逻辑处理,然后再处理添加的逻辑
    const result = original.apply(this, args)
    // __ob__ 就是 Dep 对象
    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)
    // 通知 watcher 继而更新视图
    ob.dep.notify()
    // 返回原方法的返回值
    return result
  })
})

可以看出,这里暴露了原型为 Array.prototypearrayMethods 对象,在这个对象中我们对 Array 中这 7 个方法进行了包装。

那我们包装完了,它又是如何修改我们的在 data 中的数组对象的呢,原因就是他在对数组对象进行响应式化时对其方法进行了修改,修改分为两种情况,

  1. 在我们的环境中对象拥有 __proto__ 属性时便直接将数组的原型指向了我们的 arrayMethods 对象,即在原型链中添加了一层,也就是我们的arrayMethods,当数组对象调用这 7 个方法时,在对象中找不到,便在原型对象中找,找到便直接调用了
  2. 如果没有 __proto__ 对象,则直接将这 7 个方法定义到我们的数组对象中去

.\src\core\observer\index.js

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number;

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      if (hasProto) {
        // 如果有 __ob__ 对象则更改原型链
        protoAugment(value, arrayMethods)
      } else {
        // 否则直接在数组对象中定义方法
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  walk (obj: Object) {
    ...
  }
  
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

// 修改原型链
function protoAugment (target, src: Object) {
  target.__proto__ = src
}

// 将 7 个方法定义到数组对象中
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])
  }
}

图中可以看出 books 原型链中添加了我们的 arrayMethods

方法.png

使用 Vue.set 操作数组

set.png

从源码中可以看出,使用 set 方法操作数组其实就是调用了 splice 方法(我们修改之后的 splice 方法)

.\src\core\observer\index.js

export function set (target: Array<any> | Object, key: any, val: any): any {
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
}

Vue3 中的数组操作

Vue3 的响应式原理是使用了 proxy ,不同于 Vue2 中使用的 defineProperty ,不存在上述问题,可以通过下标修改数组,也可以直接修改数组 length