likes
comments
collection
share

手写 Vue2 源码之实现数组的劫持

作者站长头像
站长
· 阅读数 35

前言

Vue 源码一直是前端面试必问的题目,熟悉源码能让我们在面试过程中脱颖而出,熟悉源码也能让我们在开发过程中知道整个框架是如何运转的,遇到了问题,我们能第一时间知道是哪里出了问题。

上一篇文章,实现了 new Vue() - 手写 Vue2 源码之实现 new Vue,知道了 new Vue() 进行了哪些操作。今天我们继续手写源码: Vue2 中如何实现对数组的劫持。

Object defineProperty 缺陷

虽然 Vue2 的响应式原理核心是使用 Object defineProperty 实现,但是 Object defineProperty 并不是万能的,它存在以下几个问题:

  • 无法监听动态属性,即熟悉的新增和删除。
  • 无法监听数组长度的变化。
  • 无法监听数组索引值的修改。

第一个缺陷, Vue2 是通过 $set$delete 这两个方法实现了,后面我们也会手写他们的源码。第二个缺陷是 api 本身没办法监听数组长度的变化,最后一个缺陷不是 api 的原因,而是尤大认为性能消耗与带来的用户体验不成正比。对数组进行响应式检测会带来很大的性能消耗,因为数组项可能会大,比如10000条、100000条。

手写 Vue2 源码之实现数组的劫持

手写 Vue2 源码之实现数组的劫持

所以为了更友好的操作数组并触发响应式检测,Vue2 重写了对数组引起副作用(改变原数组)的7个方法。

重写数组方法

迭代数组

由于考虑到性能问题,我们在 Observe 构造器里就不能直接调用 walking 方法,需要判断是否为数组,然后对数组类型的数据单独进行处理:

src/observe/index.js:

class Observe {
  constructor(data) {
    if (Array.isArray(data)) {
      this.observeArray(data)
    } else {
      this.walking(data)
    }
  }
  // 迭代对象
  walking(data) {
    Object.keys(data).forEach((key) => defineReactive(data, key, data[key])) // 遍历 data 对象属性,依次执行defineReactive方法进行数据劫持
  }
  // 迭代数组
  observeArray(data) {
    data.forEach((item) => observe(item))
  }
}

由于数组里面的子元素仍然可能是对象类型,所以我们在迭代数组方法 observeArray 中将子元素每一项重新调用 observe,依次往下劫持属性。

到现在为止,数组的元素新增、删除还是没法将听到的:

手写 Vue2 源码之实现数组的劫持

手写 Vue2 源码之实现数组的劫持

手写 Vue2 源码之实现数组的劫持

下面我们开始重写数组方法。

变异数组

Vue2 规定了7个触发响应式的方法:

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

这7个方法和数组原型上的方法同名,那为什么这7个方法可以触发响应式,进而更新试图呢?

那是因为他们是变异数组的方法。

什么是变异数组

变异数组就是在保证数组原有方法不变的情况下,对数组现有方法进行功能扩展。这里我们对数组方法功能扩展就是给这些方法添加响应式。

例如:有个打印函数,他会打印出'这是原始打印',现有我们有个需求,函数打印之前我们需要打印'这是新增的打印',这个我们应该去怎么实现呢?

我们可以分为以下几步进行操作:

  1. 定义原函数。
  2. 将函数赋值给变量。
  3. 重新定义原函数,并在原函数里面打印'这是新增的打印',然后在调用原函数。

我们结合代码,实现一下:

function print() {// 原始打印函数
  console.log('这是原始打印');
}
let newPrint = print // 将原始函数存储在变量newPrint里
print = function () { // 重新定义原函数,添加打印内容,然后调用原函数
  console.log('这是新增的打印')
  newPrint()
}
print()

手写 Vue2 源码之实现数组的劫持

这样,我们实现了在保证 print 函数功能不变的前提下,给 print 函数添加了新的功能即功能扩展。

在 Vue2中,也是通过这种思路对数组的7个方法进行功能扩展。

重写数组方法

我们在 src/observe 文件夹下新建 array.js 存放我们实现变异数组的所有逻辑。

我们需要在数组原型上修改7个方法,但是直接修改会干掉原有的方法,我们需要拷贝一份数组原型出来,在复制出来的原型上面做功能扩展,即我们前面说的思路第二点'将函数赋值给变量'。

src/observe/array.js:

// 数组原型,拷贝到arrayProto
const arrayProto = Object.prototype
// 继承原有数组的方法 newArrayProto.__proto__ === arrayProto
export const newArrayProto = Object.create(arrayProto)

然后我们将7个变异数组方法放在数组 methodsToPatch 里:

// 存放7个会改变原数组变异数组方法
const methodsToPatch = [
  'push', 
  'pop', 
  'shift',
  'unshift',
  'splice', 
  'sort',
  'reverse'
]

我们需要在原型 newArrayProto 上面新增7个变异方法,当我们在 newArrayProto 上访问这些方法的时候,我们其实是调用了数组的同名方法。

src/observe/array.js:

// 遍历7个变异数组方法,添加到拷贝出来的原型newArrayProto上
methodsToPatch.forEach((method) => {
  newArrayProto[method] = function (...args) {
    // 访问newArrayProto上的方法实则是访问的数组原型方法
    let result = arrayProto[method].apply(this, args)
    console.log('调用方法:' + method)
    return result
  }
})

定义好变异数组后,我们需要让 data 的隐士原型指向重写的数组原型对象 newArrayProto 上。

src/observe/index.js:

constructor(data) {
  if (Array.isArray(data)) {
    // data 隐士原型指向重写数组原型对象
    data.__proto__ = newArrayProto
    this.observeArray(data)
  } else {
    this.walking(data)
  }
}

我们可以在 index.html 里测试下:

let vm = new Vue({
  data() {
    return {
      name: 'ts',
      age: 18,
      sports: ['basketball', 'football']
    }
  }
})
vm.sports.push('tennis')
vm.sports.shift()

手写 Vue2 源码之实现数组的劫持

通过上面的代码,我们可以知道,当我们调用 newArrayProto 对象上 7个变异方法的时候,会做两个事情:

  1. 调用扩展功能,比如这里的打印'console.log('调用方法:' + method)'。
  2. 调用数组原生方法。

至此,我们重写了数组原型,在新的数组原型上面添加了数组的变异方法,扩展了功能,对于通过方法对数组进行新增和删除元素我们还需要对这部分进行数据劫持。

对数组新增数据再次劫持

变异方法中,pushunshiftsplice 会新增数组元素,我们需要对新增的内容进行响应式处理。

src/observe/array.js:

// 遍历7个变异数组方法,添加到拷贝出来的原型newArrayProto上
methodsToPatch.forEach((method) => {
  newArrayProto[method] = function (...args) {
    // 访问newArrayProto上的方法实则是访问的数组原型方法
    let result = arrayProto[method].apply(this, args)
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    console.log(inserted, 'inserted')
    return result
  }
})

我们将新增的内容存储在变量 inserted 中,很明显,inserted 可以看出是一个数组:

手写 Vue2 源码之实现数组的劫持

手写 Vue2 源码之实现数组的劫持

这样,我们只需要对 inserted 进行观测,就能实现对新增数组元素的劫持了。

我们在 src/observe/index.js 文件里面已经在观察者 Observe 里面定义了 observeArray 方法对数组进行观测,那我们这里只需要调用 Observe 里面的 observeArray 就解决了。那么问题来了,我在 src/observe/array.js 文件里面如何调用 src/observe/index.jsObserve 类里的 observeArray 方法呢?

Vue2 源码里用了一个很巧妙的方法解决这个问题:

src/observe/array.js 里面,我们调用变异数组方法的是 arr,和 src/observe/index.js 里面 Observe 里面的 data 是相同的,我们可以通过 data 拿到 thisObserve 的实例,将 this 添加到 data 属性上,这样我们在 arr 上面也能拿到 this 了。

src/observe/index.js:

// 将this绑定到data属性__ob__上,方便对新增数组元素进行观测
data.__ob__ = this

src/observe/array.js:

// 响应式处理,获取Observe实例
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)

添加好了,我们运行发现 "超出最大调用堆栈大小":

手写 Vue2 源码之实现数组的劫持

出现这种错误最常见的原因是:在代码中的某个地方,您正在调用一个函数,该函数又调用另一个函数,依此类推,直到达到调用堆栈限制。这几乎总是因为具有未满足的基本情况的递归函数,即死递归。

出现死递归的原因是因为我们对对象添加 __ob__ 属性,然后对对象属性进行遍历,__ob__ 属性上又有 thisthis 上面又有 walkingobserveArray 这些方法,然后又要执行这些方法,继续遍历属性,形成死递归

解决这个问题的方法就是将 __ob__ 属性变为不可枚举的:

Object.defineProperty(data, '__ob__', {
  value: this,
  enumerable: false
})

手写 Vue2 源码之实现数组的劫持

这样,完整的数组劫持代码就写完了,最后贴一下 src/observe/array.jssrc/observe/index.js 代码:

src/observe/array.js:

// 数组原型,拷贝到arrayProto
const arrayProto = Array.prototype
// 继承原有数组的方法 newArrayProto.__proto__ === arrayProto
export let newArrayProto = Object.create(arrayProto)
// 存放7个会改变原数组变异数组方法
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']

// 遍历7个变异数组方法,添加到拷贝出来的原型newArrayProto上
methodsToPatch.forEach((method) => {
  newArrayProto[method] = function (...args) {
    // 访问newArrayProto上的方法实则是访问的数组原型方法
    let result = arrayProto[method].apply(this, args)
    // 响应式处理,获取Observe实例
    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)
    console.log(inserted, 'inserted')
    return result
  }
})

src/observe/index.js:

import { newArrayProto } from './array'
export function observe(data) {
  if (typeof data !== 'object' && data !== 'null') return // data不是对象就不用劫持
  // data.__ob__ instanceof Observe 为true表示data.__ob__为Observe实例,即已经被观测过了,不需要再观测了
  if (data.__ob__ instanceof Observe) {
    return data.__ob__
  }
  return new Observe(data)
}

// 观察者
class Observe {
  constructor(data) {
    // 将this绑定到data属性__ob__上,方便对新增数组元素进行观测,同时给数据加了标识,表示数据已经被观测过了
    Object.defineProperty(data, '__ob__', {
      value: this,
      enumerable: false
    })
    if (Array.isArray(data)) {
      // data 隐士原型指向重写数组原型对象
      data.__proto__ = newArrayProto
      this.observeArray(data)
    } else {
      this.walking(data)
    }
  }
  // 迭代对象
  walking(data) {
    Object.keys(data).forEach((key) => defineReactive(data, key, data[key])) // 遍历 data 对象属性,依次执行defineReactive方法进行数据劫持
  }
  // 迭代数组
  observeArray(data) {
    data.forEach((item) => observe(item))
  }
}
/**
 * defineReactive 通过Object.defineProperty api 对数据进行数据劫持
 * @param target 目标数据对象
 * @param key 属性
 * @param value 值
 */
export function defineReactive(target, key, value) {
  observe(value) // 深层次对象递归
  Object.defineProperty(target, key, {
    get() {
      console.log('访问属性' + key)
      // 访问属性时候执行
      return value
    },
    set(newValue) {
      console.log('修改属性' + key)
      // 修改属性值时候执行
      if (value === newValue) return // 新值和旧值相等就不用赋值
      observe(newValue) // 新值仍然可能是引用类型,需要继续观测
      value = newValue // 新值赋值给属性
    }
  })
}

总结

数组劫持核心就是重写数组的方法然后重新观测数组里的元素。

源码地址github.com/liy1wen/min…