【Vue2.x 源码学习】第十篇 - 数组数据变化的观测情况

829 阅读9分钟

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

一,前言

上篇,主要介绍了对象数据变化的观测情况,涉及以下几个点:

  • 实现了对象老属性值变更为对象、数组时的深层观测处理;
  • 结合实现原理,说明了对象新增属性不能被观测到的原因,以及如何实现对象新增属性的数据观测;

本篇,数组数据变化的观测情况(包含数组中新增对象、数组、普通值的情况)


二,在数组中,新增对象、数组、普通值的观测问题

1,问题分析

截止至当前版本,针对数组类型进行了以下处理:

  • 重写了数组链上的方法,实现了对引起原数组变化的7个原型方法的劫持;
  • 对数组中每一项调用observe进行递归处理,实现数组类型的深层观测;

测试:向数组arr中新增对象、数组、普通值时,是否会触发数据更新?

let vm = new Vue({
  el: '#app',
  data() {
    return { arr: [{ name: "Brave" }, 100] }
  }
});

vm.arr.push({a:100});
vm.arr[2].a = 200;

注意:由于observe仅处理对象类型,所以数组中的普通值不会被观测;

展开说明:data根数据通过前期处理一定是一个对象,数据递归观测的入口是observe(data)方法,当内部存在数组类型的数据时,会调用observeArray进行处理,observeArray对继续对数组中的每一项调用observe方法,而observe方法只处理对象或数组,所以,如果数组中存在普通值,是不会被观测的;

但尚未实现数据劫持后的具体操作逻辑:

// src/Observer/array.js

let oldArrayPrototype = Array.prototype;
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)
    // 劫持到数组变化后,尚未实现处理逻辑
  }
});

目前,虽然前面通过重写数组的7个原型方法,已经实现了对数组类型的数据劫持(当向数组中添加数据时会触发数据劫持),但具体的处理逻辑尚未实现,目前只会打印出日志;

在官方版本 Vue2 中,向数组中新增对象、修改新增对象的属性,都可以触发数据更新;

2,思路分析

push方法为例,分析原型方法push的实现逻辑:

  • 首先,需要调用push原生方法逻辑,实现原数组的更新操作;
  • 然后,如果push参数中存在对象类型,需要继续进行劫持,实现对数组中新增对象的数据观测;

注意:pushpopshiftunshiftreversesortsplice这7个方法的入参数量是不一致的,例如push方法就支持传入多个参数vm.arr.push({a:100},{b:200},{c:300}),所以入参为...args;

3,代码实现

当调用arr.push方法时,传入的参数为对象类型时,需要再次进行观测

// src/observe/array.js

methods.forEach(method => {
  arrayMethods[method] = function (...args) {
    console.log('数组的方法进行重写操作 method = ' + method)
    
    // ****** 调用`push`原生方法逻辑,实现原数组的更新操作 ******
    // AOP:before 原生方法扩展... 
    oldArrayPrototype[method].call(this, ...args) // 绑定到当前调用上下文
    // AOP::after 原生方法扩展...

    // ****** 处理数组的新增数据 ******
    // 只有splice、push、unshift能新增数据,需要实现数据响应式;
    let inserted = null; // 收集新增的数据
    switch (method) {
      // splice 方法做增加,一定会有第三个参数:arr.splice(0,0,100) 
      case 'splice':  // splice:修改、删除、添加
        // 获取新增数据:从第三个参数开始都是新增数据
        inserted = args.slice(2); 
        break;
      case 'push':    // push:向前增加
        // 不写 break 会穿透到 unshift 处理
      case 'unshift': // unshift:向后增加
        // push、unshift 方法的参数全部为新增数据
        inserted = args 
        break;
    }
   
    // 遍历 inserted 数组中的新增数据,对象类型需要继续进行观测
  }
});
  • 通过oldArrayPrototype[method].call(this, ...args)执行push原生方法逻辑并绑定当前上下文,实现原数组的更新操作;
  • 收集通过splice、push、unshift方法新增的数据,放入inserted数组;
  • 遍历inserted数组,当数据为对象类型时,需要继续进行观测;

4,问题 1:如何实现新增对象的深层观测

问题分析

Observer类中的原型方法observeArray实现了数组的深层劫持,但此方法并未对外导出;

所以,在当前模块中遍历inserted数组时,就无法调用到Observer类中的observeArray方法实现数据观测;

解决方案

思路:让当前数组或对象与Observer实例产生一个关联关系;

Observer初始化时,为当前数组或对象value添加自定义属性__ob__,使valueObserver实例之间产生关联:value.__ob__ = this

  • value:为当前数组或对象,添加自定义属性__ob__ = this;(在 observe 方法中,只有值为对象类型时即数组或对象,才会执行new Observer创建实例,因此value必为数组或对象)
  • this:为当前Observer实例,通过实例可以调用到observeArray方法;

这样,就可以在src/observe/array.js模块遍历inserted数组时,调用到observeArray方法,从而实现数组的深层劫持;

// src/observe/index.js
class Observer {
  
  constructor(value) {
    // value:为数组或对象添加自定义属性__ob__ = this,
    // this:为当前 Observer 类的实例,实例上就有 observeArray 方法;
    value.__ob__ = this;

    if (isArray(value)) {
      value.__proto__ = arrayMethods;
      this.observeArray(value);
    } else {
      this.walk(value);
    }
  }
}

数组添加__ob__属性后,当调用push方法时就能够通过数组上的__ob__属性获取到当前Observer实例,进而通过Observer实例调用到observeArray方法,实现数组的深层观测;

// src/observe/array.js

methods.forEach(method => {
  arrayMethods[method] = function (...args) {
    oldArrayPrototype[method].call(this, ...args)
    let inserted = null;
    let ob = this.__ob__;	// 通过 __ob__ 属性获取到 ob
    switch (method) {
      case 'splice': 
        inserted = args.slice(2);
      case 'push':  
      case 'unshift':
        inserted = args 
        break;
    }
    
    // observeArray:内部遍历inserted数组,调用observe方法,是对象就new Observer,继续深层观测
    if(inserted)ob.observeArray(inserted);// inserted 有值就是数组
  }
});

所以,向数组中push对象或数组时,会对新增的对象或数组继续执行observeArray方法,从而使新增的对象或数组成为响应式数据;

5,问题 2:死循环问题

测试死循环问题

当前版本代码运行时会出现死循环:

image.png

思考:在Observer类中,只添加了value.__ob__ = this这一句代码,为什么就导致了死循环?

// src/observe/index.js

class Observer {

  constructor(value) {
    value.__ob__ = this; // 只添加了这一句就死循环了

    if (isArray(value)) {
      value.__proto__ = arrayMethods;
      this.observeArray(value);
    } else {
      this.walk(value);
    }
  }

  walk(data) {
    Object.keys(data).forEach(key => {
      defineReactive(data, key, data[key]);
    });
  }
}

分析死循环的产生原因

  • Observer初始化时会执行构造方法,此时value可能是对象也可能是数组,会先为value添加__ob__属性,值为当前Observer实例;
  • 继续向下,当value为对象类型时会执行walk方法:遍历对象中的所有属性并依次执行defineReactive方法;
  • walk方法遍历属性时,对象中新添加的__ob__属性也会被遍历出来,__ob__属性的值为当前Observer实例,也是一个对象,所以,在defineReactive方法中会继续通过observe进行递归处理,会继续执行new Observer继续添加__ob__属性,值为新的Observer实例....,这样就造成了死循环;

得出结论:在walk中,只要__ob__属性被遍历出来,后续通过Object.defineProperty处理后,就造成死循环;

死循环问题的解决方案

将属性设置为不可枚举

为对象或属性添加__ob__属性时,可以通过Object.defineProperty__ob__属性配置为不可被枚举;

这样,在walk方法遍历对象属性时,__ob__属性不会被遍历出来,也就解决了死循环问题;

// src/observe/index.js
class Observer {

  constructor(value) {
    // value.__ob__ = this;	// 可被遍历枚举,会造成死循环
    // 定义__ob__ 属性为不可被枚举,防止对象在进入walk都继续defineProperty,造成死循环
    Object.defineProperty(value, '__ob__', {
      value:this,
      enumerable:false  // 不可被枚举
    });
    
    if (isArray(value)) {
      value.__proto__ = arrayMethods;
      this.observeArray(value);
    } else {
      this.walk(value); 
    }
  }
}

重新执行测试,死循环问题解决:

image.png

冻结属性方案:__ob__属性被冻结后,仅仅只是不能被修改了,依然能够被遍历出来,无法解决死循环问题;

对象被重复观测的问题

Observer构造方法中,会为对象添加__ob__属性,之后:

  • 数组会执行observeArray方法:对每一项执行observe方法
  • 对象会执行walk方法:对每一个属性执行defineReactive方法,在defineReactive方法中,如果是对象还会继续递归执行observe方法;

当执行observe方法时,如果值为对象,将会继续创建Observer实例进行递归观测;

// todo 添加一个对象被重复观测的例子

结论:当对象被添加__ob__属性标识后,代表着当前对象已经被创建过Observer实例了,即当前对象已经被深层观测过了,在之后的处理中应避免重复观测;

// src/observe/index.js
export function observe(value) {

  if (!isObject(value)) {
    return;
  }

  if(value.__ob__){
    console.log("当前数据已经被观测过了,value = "+ value)
    return;
  }
  
  return new Observer(value);
}

所以,Observer使用类实现而不用函数还有其他好处 首先,可以通过Observer实例上是否有__ob__数据判断对象是否已经被观测过了,从而避免对象被重复进行观测; 其次,如果通过函数实现,__ob__就要添加到链上:value.__proto__.__ob__,这样一来所有的对象就都有__ob__属性了...这不是乱套了么...

6,功能测试

let vm = new Vue({
  el: '#app',
  data() {
    return { arr: [{ name: "Brave"}, {} ] }
  }
});

// 测试:数组的深层观测问题
//    不会更新,当前仅重写了数组链上的方法,数组中的数组没有被递归处理
// vm.arr[0].push("123")

// 测试:修改新对象的属性值
//    不会更新,当前新对象没有被被观测
// vm.message = { a: 100 }
// vm.message.a = 200;

// 测试:修改数组中已存在的属性值
//    会更新,数组中的对象会被递归观测
// vm.arr[0].name = "BraveWang";

// 测试:修改数组的索引
//    不会更新,Vue2 没有对数组索引进行劫持
// vm.arr[1] = 200;

// 实现数组中新对象的深层观测-push逻辑的重写
// vm.arr.push({a:100});
// vm.arr[2].a = 200;

// 测试:数组新增对象的深层观测
vm.arr.push({ a: 100 }, { b: 200 }, { c: 300 });
console.log(vm.arr)

执行结果: image.png

向数组中新增对象,数组中的老对象和新对象都会添加__ob__属性,并且对象中的属性都会被劫持;


三,结尾

本篇,主要介绍了数组数据变化的观测情况:

  • 实现了数组数据变化被劫持后,重写原型方法的具体逻辑;
  • 数组各种数据变化时的观测情况分析;

至此,数据劫持就全部完成了

下一篇,数据渲染的流程


维护日志

  • 20210111:重新梳理文章内容,优化部分内容语义,添加描述内容中的代码显示高亮;
  • 20210112:补充大量文字说明,重新调整目录结构,添加对象重复观测处理;