Vue的MVVM模式响应式原理——数组的特殊处理之偷梁不换柱

400 阅读9分钟

前言

在上篇文章 Vue的MVVM模式响应式原理之observe、Observer和defineReactive中提到在Observer类实例化的过程中,需要对传入的value做一个类型判断,区分是否为数组,然后调用不同的方法实现所有元素的[响应式]

一、为什么数组需要特殊处理

上文提到,Object.defineProperty()方法无法对数组长度变化做出响应。

let obj = {}
let value = [111,222,333]
Object.defineProperty(obj, 'array', {
    enumerable: true,
    configurable: true,
    get() {
      console.log('数组被访问')
      return value
    },
    set(newVal) {
      console.log('数组变化了')
      value = newVal
    }
  })

obj.array //'数组被访问' **触发get**
obj.array[2] //'数组被访问' **触发get**
obj.array[2]= 88 // '数组被访问' **触发get** 可是我被修改了,为什么没有触发set ?
obj.array.push(55,66) //'数组被访问' **触发get** 可是我被修改了,为什么没有触发set ?
obj.array.length = 0 //'数组被访问'  **触发get** 可是我被修改了,为什么没有触发set ?

(1)准确的说Object.defineProperty()可以对数组索引访问产生[响应式], 但是对通过索引修改值产生不完整的[响应性]

(2)对长度的变化也无法产生完整的[响应式]

  • 重点看这三行。
  1. obj.array[2]= 88 // '数组被访问' 触发get 可是我被修改了,为什么没有触发set
  2. obj.array.push(55,66) //'数组被访问' 触发get 可是我被修改了,为什么没有触发set
  3. obj.array.length = 0 //'数组被访问' 触发get 可是我被修改了,为什么没有触发set ? 引发的问题是,不知道用户添加或删除了什么,无法及时通知notify(触发视图更新的入口方法后面讲)。

都可以触发get,所以肯定有办法,但是尤雨溪大人没有硬来很巧妙的就解决了,佩服并向您学习!

(3)需要对数组进行特殊的处理。

这里引出7个会影响数组自身索引值变化长度变化的数组方法。

const arrayChanges = [
  'push',     // 在尾部插入 1 个或多个参数,并返回新 length
  'pop',      // 删除最后一个元素,并返回这个元素
  'shift',    // 删除第一个元素,并返回这个元素
  'unshift',  // 在头部插入 1 个或多个参数,并返回新 length
  'splice',   // 在任意的位置给数组添加或删除任意个元素。
  'sort',     // 接收一个函数 
  'reverse'   // 反转数组
]

接下来思考一个问题

思考如何实现一个数组的push方法,并且打印’向他学习‘

看下面的这两段代码

const array = []
array.push(99) // [99]

如何即可以push又可以打印呢?

思路1

let list = []

pushPro = Array.prototype.push // 缓存原始方法

list.push = function (ele){
	console.log('向他学习')    // 某种方式增强
        // 还可以一顿操作....
  	return pushPro.call(this,ele)    // 还原原始功能
}

list.push(99) // '它很聪明'
console.log(list) // [99]

这个简单的例子说明,通过在数组身上定义一个同名方法,并且在同名方法中增强它,最终依然调用原始方法并return。有点像寄生式继承
这样就达到了拦截用户调用原始数组方法,从而用了增强过的方法。

思路2

因为我们知道在JS中读取属性会先从自身找,然后顺着__proto__一直找到头是undefined。
因此思路2就是修改数组的原型对象,替换成一个 7个方法被增强的原型对象

list.__proto__ = 增强的原型对象
或者
Object.setPrototype(list,增强的原型对象)

其实两种思路都是都是从JS读取属性的特性上下手的

源码中两种方式都使用了,因为尤雨溪大人的用户受众很广要考虑兼容性问题。

二、只替换传入的数组的原型对象,而不是全局的。

在哪里得知传入的值value是数组,就在哪里下手。因此这个操作在Observer实例化中执行。

class Observer {
  constructor(value) {
    def(value, '__ob__', this, false)

    if (Array.isArray(value)) {
      Object.setPrototypeOf(value, arrayMethods) // 改写传入数组的原型
      this.observeArray(value) // 遍历数组为每个数组调用observe
    } else {
      this.walk(value)
    }
  }
}

这样就把传入的数组的原型改写了,也就可以感知到数组长度的变化了。接下来就是看看这个被改写的原型arrayMethods具体是什么样实现可以感知数组长度等的变化。

三、数组变异方法的改写

(1)就像如何实现一个数组的push方法,并且打印’向他学习‘中一样。并不复杂。

const arrayPrototype = Array.prototype
const arrayMethods = Object.create(arrayPrototype)
const arrayChanges = [
  'push',     // 在尾部插入 1 个或多个参数,并返回新 length
  'pop',      // 删除最后一个元素,并返回这个元素
  'shift',    // 删除第一个元素,并返回这个元素
  'unshift',  // 在头部插入 1 个或多个参数,并返回新 length
  'splice',   // 在任意的位置给数组添加或删除任意个元素。
  'sort',     // 接收一个函数
  'reverse'   // 反转数组
]
  • (1)首先用一个变量arrayPrototype缓存原始原型对象,它携带着所有方式方法
  • (2)以 arrayPrototype 为原型对象,创建一个新的对象arrayMethods它就是拦截器
  • (3)创建一个数组arrayChanges并写入7个方法名字符串,用来对7个方法进行遍历
  • (4)遍历这个数组会对拦截器调用7次def函数。
arrayChanges.forEach(methodName => {
  const original = arrayPrototype[methodName]  // 缓存对应的方法(7个中的一个)
  // 在arrayMethods上定义同名方法
  def(arrayMethods, methodName, function () {
    //⭐执行增强操作......
    /**
     * @original 最后依然用数组原始方法执行用户操作 ⭐还原了原始功能
     * @this  :调用方法的数组
     * @arguments :开发者传入的参数
     * @return :某些数组方法是具有返回值的
     * */
    return original.apply(this, arguments)
  }, false)
})

这样拦截器身上那7个数组方法就被改写了,但是功能依旧还在,接下来我们来看看是如何实现[响应式]

这里的def函数是对Object.defineProperty()方法的简单封装,主要是用于定义访问器属性的。可以回到上篇文章中看下。def

(2)到这里我们不能忘记改写数组原型的目的

之前提到,由于Object.defineProperty()无法对数组长度变化做出完整的[响应性]。Vue不知道增加或者少了什么元素。

有了数组变异方法的改写,就可以知道用户添加和删除了什么。

  1. ⭐其中最重要的是能够感知长度变化了,因而可以在这里发布通知notify了。
  2. 用户删除了什么不重要,只要感知到长度变化了就可以发布通知notify。(update会重新读值,之后的章节讲)
  3. ⭐其次是添加了什么,因而可以为添加的元素也成为[响应式]的。

(3) 思考为什么是那7个方法?根据上面3点结论,可以得出什么?

  • 答案1:只有那7个方法,会修改数组自身索引值变化长度变化
  • 答案2:只要最终关注会增加数组长度的方法。push unshift splice,也就是只在乎用户添加了什么,因为我们要为添加的元素也添加[响应式]特性。

(4)让我们着重看一下执行增强/变异操作的内容

arrayChanges.forEach(methodName => {
  const original = arrayPrototype[methodName]  
  def(arrayMethods, methodName, function () {
    ⭐执行增强操作......
    /**
     * @methodName  我只在乎 push unshift splice 的增加操作
     * @inserted 用户插入的值
     */
    let inserted 
    switch(methodName){
        case 'push':
        case 'unshift':
          inserted = [...arguments];
          break
        case 'splice':
          inserted = [...arguments].slice(2)
    }
    ⭐ observeArray(inserted) 让添加的元素成为`[响应式]`的 
    ⭐ notify() // 通知更新,凡执行到这里数组内部顺序或者长度肯定发生变化了
    return original.apply(this, arguments)
  }, false)
})

源码就是这样呈现的。取得用户添加的元素,并让它[响应化]。但是这里有个地方由于我一直没有说,就是notify是谁的方法?,observeArray到底执行什么

  • 先看observeArray,它是Observer类的方法。
    --上文--中我说这个方法会为数组的每个元素调用特殊的defineReactive方法,其实就是为每个元素调用observe这个核心入口,这样不管数组里是什么,都会被安排得明明白白。
Observer.prototype.observeArray = function (array) {
  for (let i = 0, len = array.length; i < len; i++) {
    observe(array[i])
  }
}

再搬出这张图就明白了

  • 再看notify,它是Dep类的方法。但也是Observer实例的一个属性 dep
    由于本文还没有涉及到响应式原理-追踪变化的内容也就是Observer和 Watcher和Dep之间的关系。
    就先明白Dep类的实例有notify方法会通知触发视图更新的回调。
    它实例化的地方只有2处,Observer类和defineReactive函数调用中
class Observer {
  constructor(value) {
    def(value, '__ob__', this, false)
    ⭐this.dep = new Dep() 
    if () {
     ...
    } else {
     ...
    }
  }
  walk(value){ ... }
  observeArray(value) { ... }
}
  • (5)既然知道了Observer的实例中有dep属性,那么还记得每个被响应化后的数据都有个标识 __ob__吧,看上面代码 this.dep之前的操作! 因此完整的代码如下
const arrayPrototype = Array.prototype
const arrayMethods = Object.create(arrayPrototype)
[
  'push',     // 在尾部插入 1 个或多个参数,并返回新 length
  'pop',      // 删除最后一个元素,并返回这个元素
  'shift',    // 删除第一个元素,并返回这个元素
  'unshift',  // 在头部插入 1 个或多个参数,并返回新 length
  'splice',   // 在任意的位置给数组添加或删除任意个元素。
  'sort',     // 接收一个函数
  'reverse'   // 反转数组
].forEach(methodName => {
  const original = arrayPrototype[methodName]  
  def(arrayMethods, methodName, function () {
    执行增强操作......
    ⭐let ob = this.__ob__ // 取出当前数据的 __ob__属性
    let inserted 
    switch(methodName){
        case 'push':
        case 'unshift':
          inserted = [...arguments]; // 这两个方法只能增加
          break
        case 'splice':
          inserted = [...arguments].slice(2) // 只有3个参数才说明要增加
    }
    ⭐ if(inserted) ob.observeArray(inserted) ; //判空,JS真有趣,`空数组[]是true`呢😄
    ⭐ ob.dep.notify() // 通知更新,凡执行到这里数组内部顺序或者长度肯定发生变化了
    return original.apply(this, arguments)
  }, false)
})

export { arrayMethods } ⭐ 导出供数组享用
===================================================================================
class Observer {
  constructor(value) {
    def(value, '__ob__', this, false)
    this.dep = new Dep()

    if (Array.isArray(value)) {  
      Object.setPrototypeOf(value,⭐arrayMethods)
      this.observeArray(value)
    } else {
     this.walk(value)
    }
  }
  walk(value){ ... }
  observeArray(value) { ... }
}

至此一张说明[数组的特殊处理之偷梁不换柱]的流程图

在Observer类实例化的过程中,判断value是否是数组,如果是数组就改写它的原型俗称偷梁不换柱。然后为这个数组的每个元素调用observe(),这个链式调用的循环又圆上了!

上篇文章是Vue的MVVM模式响应式原理之observe、Observer和defineReactive

下篇文章打算写Vue的MVVM模式响应式原理——如何追踪变化之Dep、Watcher、Observer

一步步的揭盖Vue-MVVM模式的面纱!

笔者写作实践不多,如果可以的话,点个赞,或者点个倒赞评论给我你的意见吧!

本文详细的源码和注释在我的GitHub仓库中。mvvm-webpack-demo