Vue源码解析之数组变异

力有不逮的对象

众所周知,在 Vue 中,直接修改对象属性的值没法触发响应式。当你直接修改了对象属性的值,你会发现,只有数据改了,可是页面内容并无改变。数组

这是什么缘由?浏览器

缘由在于: Vue 的响应式系统是基于Object.defineProperty这个方法的,该方法能够监听对象中某个元素的获取或修改,通过了该方法处理的数据,咱们称其为响应式数据。可是,该方法有一个很大的缺点,新增属性或者删除属性不会触发监听,举个栗子:缓存

var vm = new Vue({
    data () {
        return {
            obj: {
                a: 1
            }
        }
    }
})
// `vm.obj.a` 如今是响应式的

vm.obj.b = 2
// `vm.obj.b` 不是响应式的

缘由在于,在 Vue 初始化的时候, Vue 内部会对 data 方法的返回值进行深度响应式处理,使其变为响应式数据,因此, vm.obj.a 是响应式的。可是,以后设置的 vm.obj.b 并无通过 Vue 初始化时响应式的洗礼,因此,理所应当的不是响应式。app

那么,vm.obj.b能够变成响应式吗?固然能够,经过 vm.$set 方法就能够完美地实现要求,在此再也不赘述相关原理了,以后应该会写一篇文章讲述 vm.$set 背后的原理。函数

更凄惨的数组

上面说了这么多,尚未提到本篇文章的主角——数组,如今该主角出场了。学习

比起对象,数组的境遇更加凄惨一些,看看官方文档:this

因为 JavaScript 的限制, Vue 不能检测如下变更的数组:prototype

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

有可能官方文档不是很清晰,那咱们继续举个栗子:eslint

var vm = new Vue({
    data () {
        return {
            items: ['a', 'b', 'c']
        }
    }
})
vm.items[1] = 'x' // 不是响应性的
vm.items.length = 2 // 不是响应性的

也就是说,数组连自身元素的修改也没法监听,缘由在于, Vuedata 方法返回的对象中的元素进行响应式处理时,若是元素是数组时,仅仅对数组自己进行响应式化,而不对数组内部元素进行响应式化。code

这也就致使如官方文档所写的后果,没法直接修改数组内部元素来触发响应式。

那么,有没有破解方法呢?

固然有,官方规定了 7 个数组方法,经过这 7 个数组方法,能够很开心地触发数组的响应式,这 7 个数组方法分别是:

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

能够发现,这 7 个数组方法貌似就是原生的那些数组方法,为何这 7 个数组方法能够触发应式,触发视图更新呢?

你是否是内心想着:数组方法了不得呀,数组方法就能够随心所欲啊?

骚瑞啊,这 7 个数组方法是真的能够随心所欲的。

由于,它们是变异后的数组方法。

数组变异思路

什么是变异数组方法?

变异数组方法即保持数组方法原有功能不变的前提下对其进行功能拓展,在 Vue 中这个所谓的功能拓展就是添加响应式功能。

将普通的数组变为变异数组的方法分为两步:

  1. 功能拓展
  2. 数组劫持

功能拓展

先来个思考题:

有这样一个需求,要求在不改变原有函数功能以及调用方式的状况下,使得每次调用该函数都能在控制台中打印出'HelloWorld'

其实思路很简单,分为三步:

  1. 使用新的变量缓存原函数
  2. 从新定义原函数
  3. 在新定义的函数中调用原函数

看看具体的代码实现:

function A () {
    console.log('调用了函数A')
}

const nativeA = A
A = function () {
    console.log('HelloWorld')
    nativeA()
}

能够看到,经过这种方式,咱们就保证了在不改变 A 函数行为的前提下对其进行了功能拓展。

接下来,咱们使用这种方法对数组本来方法进行功能拓展:

// 变异方法名称
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

const arrayProto = Array.prototype
// 继承原有数组的方法
const arrayMethods = Object.create(arrayProto)

mutationMethods.forEach(method => {
    // 缓存原生数组方法
    const original = arrayProto[method]
    arrayMethods[method] = function (...args) {
        const result = original.apply(this, args)
        
        console.log('执行响应式功能')
        
        return result
    }
})

从代码中能够看出来,咱们调用 arrayMethods 这个对象中的方法有两种状况:

  1. 调用功能拓展方法:直接调用 arrayMethods 中的方法
  2. 调用原生方法:这种状况下,经过原型链查找定义在数组原型中的原生方法

经过上述方法,咱们实现了对数组原生方法进行功能的拓展,可是,有一个巨大的问题摆在面前:咱们该如何让数组实例调用功能拓展后数组方法呢?

解决这一问题的方法就是:数组劫持。

数组劫持

数组劫持,顾名思义就是将本来数组实例要继承的方法替换成咱们功能拓展后的方法。

想想,咱们在前面实现了一个功能拓展后的数组 arrayMethods ,这个自定义的数组继承自数组对象,咱们只须要将其和普通数组实例链接起来,让普通数组继承于它便可。

而想实现上述操做,就是经过原型链。

实现方法以下代码所示:

let arr = []
// 经过隐式原型继承arrayMethods
arr.__proto__ = arrayMethods

// 执行变异后方法
arr.push(1)

经过功能拓展和数组劫持,咱们终于实现了变异数组,接下来让咱们看看 Vue 源码是如何实现变异数组的。

源码解析

咱们来到 src/core/observer/index.js 中在 Observer 类中的 constructor 函数:

constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    // 检测是不是数组
    if (Array.isArray(value)) {
        // 能力检测
        const augment = hasProto
        ? protoAugment
        : copyAugment
        // 经过能力检测的结果选择不一样方式进行数组劫持
        augment(value, arrayMethods, arrayKeys)
        // 对数组的响应式处理
        this.observeArray(value)
    } else {
        this.walk(value)
    }
}

Observer 这个类是 Vue 响应式系统的核心组成部分,在初始化阶段最主要的功能是将目标对象进行响应式化。在这里,咱们主要关注其对数组的处理。

其对数组的处理主要是如下代码

// 能力检测
const augment = hasProto
? protoAugment
: copyAugment
// 经过能力检测的结果选择不一样方式进行数组劫持
augment(value, arrayMethods, arrayKeys)
// 对数组的响应式处理,很本文关系不大,略过
this.observeArray(value)

首先定义了 augment 常量,这个常量的值由 hasProto 决定。

咱们来看看 hasProto

export const hasProto = '__proto__' in {}

能够发现, hasProto 其实就是一个布尔值常量,用来表示浏览器是否支持直接使用 __proto__ (隐式原型) 。

因此,第一段代码很好理解:根据根据能力检测结果选择不一样的数组劫持方法,若是浏览器支持隐式原型,则调用 protoAugment 函数做为数组劫持的方法,反之则使用 copyAugment

不一样的数组劫持方法

如今咱们来看看 protoAugment 以及 copyAugment

function protoAugment (target, src: Object, keys: any) {
  /* eslint-disable no-proto */
  target.__proto__ = src
  /* eslint-enable no-proto */
}

能够看到, protoAugment 函数极其简洁,和在数组变异思路中所说的方法一致:将数组实例直接经过隐式原型与变异数组链接起来,经过这种方式继承变异数组中的方法。

接下来咱们再看看 copyAugment

function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    // Object.defineProperty的封装
    def(target, key, src[key])
  }
}

因为在这种状况下,浏览器不支持直接使用隐式原型,因此数组劫持方法要麻烦不少。咱们知道该函数接收的第一个参数是数组实例,第二个参数是变异数组,那么第三个参数是什么?

// 获取变异数组中全部自身属性的属性名
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)

arrayKeys 在该文件的开头就定义了,即变异数组中的全部自身属性的属性名,是一个数组。

回头再看 copyAugment 函数就很清晰了,将全部变异数组中的方法,直接定义在数组实例自己,至关于变相的实现了数组的劫持。

实现了数组劫持后,咱们再来看看 Vue 中是怎样实现数组的功能拓展的。

功能拓展

数组功能拓展的代码位于 src/core/observer/array.js ,代码以下:

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

// 缓存数组原型
const arrayProto = Array.prototype
// 实现 arrayMethods.__proto__ === Array.prototype
export const arrayMethods = Object.create(arrayProto)

// 须要进行功能拓展的方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
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
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    // 返回原生数组方法的执行结果
    return result
  })
})

能够发现,源码在实现的方式上,和我在数组变异思路中采用的方法一致,只不过在其中添加了响应式的处理。

总结

Vue 的变异数组从本质上是来讲是一种装饰器模式,经过学习它的原理,咱们在实际工做中能够轻松处理这类保持原有功能不变的前提下对其进行功能拓展的需求。

相关文章
相关标签/搜索