【Vue2.x 源码学习】第五篇 - 数组的劫持

1,957 阅读7分钟

这是我参与更文挑战的第5天,活动详情查看: 更文挑战

一,前言

上篇,主要介绍了 Vue 数据初始化流程中,对象属性的深层劫持是如何实现的

核心思路就是递归,主要流程如下;

  • 1.通过 data = isFunction(data) ? data.call(vm) : data;处理后的 data 一定是对象类型
  • 2.通过 data = observe(data)处理后的 data 就实现了数据的响应式(目前只有劫持)
  • 3.observe 方法最终返回一个 Observer 类
  • 4.Observer 类初始化时,通过 walk 遍历属性
  • 5.对每一个属性进行 defineReactive(Object.defineProperty)就实现对象属性的单层数据劫持
  • 6.在 defineReactive 中,如果属性值为对象类型就继续调用 observe 对当前的对象属性进行观测(即递归步骤 3~5),这样就实现了对象属性的深层数据劫持

本篇,继续介绍 Vue 数据初始化流程中,对于数组类型的劫持;


二,提出问题:对象劫持实现了,数组类型如何处理?

当前代码已经支持,当 data 对象中的属性值依然为对象时,递归处理对象属性实现深层观测(可能存在多层嵌套):

let vm = new Vue({
  el: '#app',
  data() {
    return { message: 'Hello Vue', obj: { key: "val" }, a: { a: { a: {} } } }
});

image.png

当 data 对象中的属性值为数组时,由于数组也是对象,那么,Vue 该如何对数组进行处理呢?


三,数组类型的处理

1,当前逻辑分析

按照当前代码的处理逻辑,所有对象类型都会被递归的实现深层观测,这里的对象就包含了数组:

let vm = new Vue({
  el: '#app',
  data() {
    return { message: 'Hello Vue', obj: { key: "val" }, arr:[1,2,3]}
  }
});

image.png

通过测试结果可以看到,数组中的每一项都被添加了 get、set 方法,相当于实现了数组的深层观测;

看似是没有任何问题的,数组中的每一项都非常完美的实现了数据观测;

但是,在 Vue2.x 中,是不支持通过修改数组索引或长度来触发更新的,Why?

备注:Object.defineProperty 支持数组数据类型的劫持;

2,Vue 对性能的权衡

原本可以轻松实现修改数组索引或长度触发更新,,Vue 为什么选择不支持?

这里主要是从框架的性能和应用场景进行权衡考量,最终做出的取舍:

作为一个数组类型,不可避免的会存在大量数据,比如:

let vm = new Vue({
  el: '#app',
  data() {
    return { arr:new Array(9999) }
  }
});

按照目前处理逻辑,数组中的 9999 条数据,将全部被添加 get、set 方法

  • 为了实现对数组索引的劫持,就需要对数组中每一项进行观测,开销可能会比较大;
  • 如果数组使用Object.defineProperty可以实现修改索引触发更新,但在实际开发场景中,很少会通过arr[888] = x指定索引的方式做数据更新;

所以,权衡性能和应用场景,Vue 源码中没有采用Object.defineProperty对数组进行处理;

备注:这种实现思路,直接导致 vue2 修改数组的索引和长度不能触发视图更新;(在 Vue3 中,数组使用了Object.defineProperty,支持修改索引和长度触发更新)

3,数组劫持的实现思路

对数组进行劫持的核心目标,还是要实现数组的响应式:

  • 在 Vue 中,认为这 7 个方法能够改变原数组:push、pop、splice、shift、unshift、reverse、sort;
  • 对以上 7 个方法进行特殊处理,使他们能够劫持到数组的数据变化,就能够实现数组的响应式;

对象深层观测的实现:

  1. 数据观测入口:src/observe/index.js#observe方法;
  2. 如果数据为对象类型,就会new Observer实例;
  3. 在 Observer 初始化时,遍历对象中的属性并逐一通过Object.defineProperty递归处理;

根据之前的分析,数组不能和对象采用相同的处理方式,在 Observer 初始化时会 walk 遍历属性实现递归观测;所以在此处,将数组响应式的处理逻辑单独拆出来,即重写数组的 7 个变异方法;

// src/observe/index.js
import { arrayMethods } from "./array";

class Observer {
  constructor(value) {
    if(isArray(value)){
      // 对数组类型进行单独处理:重写 7 个变异方法
    }else{
      this.walk(value);
    }
  }
}
// src/utils

/**
 * 判断是否是数组
 * @param {*} val 
 * @returns 
 */
export function isArray(val) {
  return Array.isArray(val)
}

拉出处理分支后,在 Observer 类中,暂时不会对数组类型进行观测了;

4,数组方法的重写(拦截)思路

这一步的主要工作:数组具有很多原生方法,只重写以上 7 个方法,以实现数组的数据劫持;

注意:仅对响应式数据中的数组进行方法重写,不能影响非响应式数组;

所以,对响应式数据中数组的这 7 个方法进行拦截:优先从链上查找到并使用重写方法,其它方法依然走原生逻辑;(优先查找自身方法-重写方法,找不到继续到链上查找-原生方法);

5,数组方法重写的实现

// src/Observer/array.js

// 拿到数组的原型方法
let oldArrayPrototype = Array.prototype;
// 原型继承,将原型链向后移动 arrayMethods.__proto__ == oldArrayPrototype
export let arrayMethods = Object.create(oldArrayPrototype);

// 重写能够导致原数组变化的七个方法
let methods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'reverse',
  'sort',
  'splice'
]

// 在数组自身上进行方法重写,以实现对链上同名方法的拦截效果
methods.forEach(method => {
  arrayMethods[method] = function () {
    console.log('数组的方法进行重写操作 method = ' + method)
  }
});

new Observer时,对数组类型的数据进行链上方法的重写:

// src/observe/index.js
import { arrayMethods } from "./array";

class Observer {
  constructor(value) {
    // 分别处理 value 为数组和对象两种情况
    if(isArray(value)){
      value.__proto__ = arrayMethods; // 更改数组的原型方法
    }else{
      this.walk(value);
    }
  }
}

测试数组方法的重写:

image.png

数组的链:

  • array.proto:包含 7 个重写方法
  • array.proto.proto:原始方法

6,关于重写数组方法的理解

  • 1,首先,拿到数组的所有原生方法 oldArrayPrototype,通过Object.create原型继承放到 arrayMethods 原型链上,相当于将原生方法向后移动了一层;
  • 2,当 value 为数组类型时,修改数组的原型链为 arrayMethods;此时,原本在value.__proto__上的原生方法,被已换为 arrayMethods 原生方法被向后移动了一层,而中间让出的这一层,就是我们重新 7 个变异方法的地方;
  • 3,在 arrayMethods 进行处理,在第一层对 7 个变异方法进行重写(此处可以使用策略模式,针对不同方法进行处理和实现),利用 js 原型链查找的机制,就实现了对原生方法的拦截,即重写;

7,数组方法拦截的实现

// src/state.js#initData

function initData(vm) {
    let data = vm.$options.data;
    data = isFunction(data) ? data.call(vm) : data;
    // 在 observe 方法中,当 new Observer 执行完成后,数组的原型方法已被重写
    observe(data);  

    // 测试数组方法的拦截效果
    data.arr.push(666); 
    data.arr.pop()
}

image.png

  • arrayMethods.push:会在数组自身找到重写的push方法,不会继续到链上查找,实现拦截
  • arrayMethods.pop:数组自身没找到重写方法,继续到链上找到原生pop方法

四,结尾

本篇主要介绍了 Vue 数据初始化流程中,数组类型的数据劫持,核心有以下几点:

出于对性能的考虑,Vue 没有对数组类型的数据使用 Object.defineProperty 进行递归劫持,而是通过对能够导致原数组变化的 7 个方法进行拦截和重写实现了数据劫持;

下一篇,数据代理的实现;


维护日志

  • 20230109:重构本篇,添加并优化了若干描述和代码注释,使表述更加清晰易懂,更新文章摘要,添加对数组 7 个变异方法的拦截思路;