从Vue数组响应化所引发的思考

简介:

前言

  首先欢迎大家关注我的Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。

  从上一篇文章响应式数据与数据依赖基本原理开始,我就萌发了想要研究Vue源码的想法。最近看了youngwind的一篇文章如何监听一个数组的变化发现Vue早期实现监听数组的方式和我的实现稍有区别。并且在两年前作者对其中的一些代码的理解有误,在阅读完评论中@Ma63d的评论之后,感觉收益匪浅。

Vue实现数据监听的方式

  在我们的上一篇文章中,我们想尝试监听数组变化,采用的是下面的思路:



function observifyArray(array){
  //需要变异的函数名列表
  var methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
  var arrayProto = Object.create(Array.prototype);
  _.each(methods, function(method){
    arrayProto[method] = function(...args){
      // 劫持修改数据
      var ret = Array.prototype[method].apply(this, args);
      //可以在修改数据时触发其他的操作
      console.log("newValue: ", this);
      return ret;
    }
  });
  Object.setPrototypeOf(array, arrayProto);
}
 我们是通过为数组实例设置原型 prototype来实现,新的 prototype重写了原生数组原型的部分方法。因此在调用上面的几个变异方法的时候我们会得到相应的通知。但其实 setPrototypeOf方法是ECMAScript 6的方法,肯定不是Vue内部可选的实现方案。我们可以大致看看Vue的实现 思路


function observifyArray(array){
    var aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
    var arrayAugmentations = Object.create(Array.prototype);
    
    aryMethods.forEach((method)=> {
    
        // 这里是原生Array的原型方法
        let original = Array.prototype[method];
       // 将push, pop等封装好的方法定义在对象arrayAugmentations的属性上
       // 注意:是属性而非原型属性
        arrayAugmentations[method] = function () {
            console.log('我被改变啦!');
            // 调用对应的原生方法并返回结果
            return original.apply(this, arguments);
        };
    });
    array.__proto__ = arrayAugmentations;
}

  __proto__是我们大家的非常熟悉的一个属性,其指向的是实例对象对应的原型对象。在ES5中,各个实例中存在一个内部属性[[Prototype]]指向实例对象对应的原型对象,但是内部属性是没法访问的。浏览器各家厂商都支持非标准属性__proto__。其实Vue的实现思路与我们的非常相似。唯一不同的是Vue使用了的非标准属性__proto__

  其实阅读过《JavaScript高级程序设计》的同学应该还记得原型式继承。其重要思路就是借助原型可以基于已有的对象创建对象。比如说:


function object(o){
    function F(){}
    F.prototype = o;
    return new F();
}

 其实我们上面Vue的思路也是这样的,我们借助原型创建的基于arrayAugmentations的新实例,使得实例能够访问到我们自定义的变异方法。

  上面一篇文章的作者youngwind写文章的时候就提出了,为什么不去采用更为常见的组合式继承去实现,比如:



function FakeArray() {
    Array.apply(this,arguments);
}

FakeArray.prototype = [];
FakeArray.prototype.constructor = FakeArray;

FakeArray.prototype.push = function () {
    console.log('我被改变啦');
    return Array.prototype.push.apply(this,arguments);
};

let list = ['a','b','c'];

let fakeList = new FakeArray(list);

 结果发现fakeList并不是一个数组而是一个对象,作者当时这这样认为的:

构造函数默认返回的本来就是this对象,这是一个对象,而非数组。Array.apply(this,arguments);这个语句返回的才是数组

我们能不能将Array.apply(this,arguments);直接return出来呢?

如果我们return这个返回的数组,这个数组是由原生的Array构造出来的,所以它的push等方法依然是原生数组的方法,无法到达重写的目的。

首先我们知道采用new操作符调用构造函数会依次经历以下四个步骤:

  1. 创建新对象
  2. 将构造函数的作用域给对象(因此构造函数中的this指向这个新对象)
  3. 执行构造函数的代码
  4. 返回新对象(如果没有显式返回的情况下)

  在没有显式返回的时候,返回的是新对象,因此fakeList是对象而不是数组。但是为什么不能强制返回Array.apply(this,arguments)。其实下面有人说作者这句话有问题

这个数组是由原生的Array构造出来的,所以它的push等方法依然是原生数组的方法,无法到达重写的目的。

  其实上面这句话本身确实没有错误,当我们给构造函数显式返回的时候,我们得到的fakeList就是原生的数组。因此调用push方法是没法观测到的。但是我们不能返回的Array.apply(this,arguments)更深层的原因在于我们这边调用Array.apply(this,arguments)的目的是为了借用原生的Array的构造函数将Array属性赋值到当前对象上。

举一个例子:



function Father(){
 this.name = "Father";
}

Father.prototype.sayName = function(){
 console.log("name: ", this.name);
}

function Son(){
 Father.apply(this);
 this.age = 100;
}

Son.prototype = new Father();
Son.prototype.constructor = Son;
Son.prototype.sayAge = function(){
 console.log("age: ", this.age);
}


var instance = new Son();
instance.sayName(); //name:  Father
instance.sayAge(); //age:  100

  子类Son为了继承父类Father的属性和方法两次调用Father的构造函数,Father.apply(this)就是为了创建父类的属性,而Son.prototype = new Father();目的就是为了通过原型链继承父类的方法。因此上面所说的才是为什么不能将Array.apply(this,arguments)强制返回的原因,它的目的就是借用原生的Array构造函数创建对应的属性。

  但是问题来了,为什么无法借用原生的Array构造函数创建对象呢?实际上不仅仅是Array,StringNumberRegexpObject等等JavaScript的内置类都不能通过借用构造函数的方式创建带有功能的属性(例如: length)。JavaScript数组中有一个特殊的响应式属性length,一方面如果数组数值类型下标的数据发生变化的时候会在length上体现,另一方面,修改length也会影响到数组的数值数据。因为无法通过借用构造函数的方式创建响应式length属性(虽然属性可以被创建,但不具备响应式功能),因此在E55我们是没法继承数组的。比如:



function MyArray(){
    Array.apply(this, arguments);
}

MyArray.prototype = Object.create(Array.prototype, {
    constructor: {
        value: MyArray,
        writable: true,
        configurable: true,
        enumerable: true
    }
});

var colors = new MyArray();
colors[0] = "red"; 
console.log(colors.length); // 0

colors.length = 0;
console.log(colors[0]); //"red"

 好在我们迎来ES6的曙光,通过类class的extends,我们就可以实现继承原生的数组,例如:


class MyArray extends Array {
}

var colors = new MyArray();
colors[0] = "red";
console.log(colors.length); // 0

colors.length = 0;
cosole.log(colors[0]); // undefined

 为什么ES6的extends可以做到ES5所不能实现的数组继承呢?这是由于二者的继承原理不同导致的。ES5的继承方式中,先是生成派生类型的this(例如:MyArray),然后调用基类的构造函数(例如:Array.apply(this)),这也就是说this首先指向的是派生类的实例,然后指向的是基类的实例。由于原生对象(例如: Array)通过借用的方式并不能给this赋值length类似的具有功能的属性,因此我们没法实现想要的结果。

  但是ES6的extends的继承方式却是与之相反的,首先是由基类(Array)创建this的值,然后再由派生类的构造函数修改这个值,因此在上面的例子中,一开始就可以通过this创建基类的所有內建功能并接受与之相关的功能(如length),然后在此this的基础上用派生类进行扩展,因此就可以达到我们的继承原生数组的目的。

  不仅仅如此。ES6在扩展类似上面的原生对象时还提供了一个非常方便的属性: Symbol.species

Symbol.species

  Symbol.species的主要作用就是可以使得原本返回基类实例的继承方法返回派生类的实例,举个例子吧,比如Array.prototype.slice返回的就是数组的实例,但是当MyArray继承Array时,我们也希望当使用MyArray的实例调用slice时也能返回MyArray的实例。那我们该如何使用呢,其实Symbol.species是一个静态访问器属性,只要在定义派生类时定义,就可以实现我们的目的。比如:



class MyArray extends Array {
  static get [Symbol.species](){
    return this;
  }
}

var myArray = new MyArray(); // MyArray[]
myArray.slice(); // MyArray []


 我们可以发现调用数组子类的实例 myArrayslice方法时也会返回的是 MyArray类型的实例。如果你喜欢尝试的话,你会发现即使去掉了静态访问器属性 get [Symbol.species]myArray.slice()也会仍然返回 MyArray的实例,这是因为即使你不显式定义,默认的 Symbol.species属性也会返回 this。当然你也将 this改变为其他值来改变对应方法的返回的实例类型。例如我希望实例 myArrayslice方法返回的是原生数组类型 Array,就可以采用如下的定义:

class MyArray extends Array {
  static get [Symbol.species](){
    return Array;
  }
}

var myArray = new MyArray(); // []
myArray.slice(); // []

  当然了,如果在上面的例子中,如果你希望在自定义的函数中返回的实例类型与 Symbol.species 的类型保持一致的话,可以如下定义:



class MyArray extends Array {
  static get [Symbol.species](){
    return Array;
  }
  
  constructor(value){
    super();
    this.value = value;
  }
  
  clone(){
    return new this.constructor[Symbol.species](this.value)
  }
}

var myArray = new MyArray();
myArray.clone(); //[]

 通过上面的代码我们可以了解到,在实例方法中通过调用this.constructor[Symbol.species]我们就可以获取到Symbol.species继而可以创造对应类型的实例。

  上面整个的文章都是基于监听数组响应的一个点想到的。这里仅仅是起到抛砖引玉的作用,希望能对大家有所帮助。如有不正确的地方,欢迎大家指出,愿共同学习。



原文发布时间为:2018年06月14日
原文作者:请叫我王磊同学

本文来源: 掘金 如需转载请联系原作者



相关文章
|
6天前
|
缓存 JavaScript 前端开发
vue学习第四章
欢迎来到我的博客!我是瑞雨溪,一名热爱JavaScript与Vue的大一学生。本文介绍了Vue中计算属性的基本与复杂使用、setter/getter、与methods的对比及与侦听器的总结。如果你觉得有用,请关注我,将持续更新更多优质内容!🎉🎉🎉
vue学习第四章
|
6天前
|
JavaScript 前端开发
vue学习第九章(v-model)
欢迎来到我的博客,我是瑞雨溪,一名热爱JavaScript与Vue的大一学生,自学前端2年半,正向全栈进发。此篇介绍v-model在不同表单元素中的应用及修饰符的使用,希望能对你有所帮助。关注我,持续更新中!🎉🎉🎉
vue学习第九章(v-model)
|
6天前
|
JavaScript 前端开发 开发者
vue学习第十章(组件开发)
欢迎来到瑞雨溪的博客,一名热爱JavaScript与Vue的大一学生。本文深入讲解Vue组件的基本使用、全局与局部组件、父子组件通信及数据传递等内容,适合前端开发者学习参考。持续更新中,期待您的关注!🎉🎉🎉
vue学习第十章(组件开发)
|
11天前
|
JavaScript 前端开发
如何在 Vue 项目中配置 Tree Shaking?
通过以上针对 Webpack 或 Rollup 的配置方法,就可以在 Vue 项目中有效地启用 Tree Shaking,从而优化项目的打包体积,提高项目的性能和加载速度。在实际配置过程中,需要根据项目的具体情况和需求,对配置进行适当的调整和优化。
|
12天前
|
存储 缓存 JavaScript
如何在大型 Vue 应用中有效地管理计算属性和侦听器
在大型 Vue 应用中,合理管理计算属性和侦听器是优化性能和维护性的关键。本文介绍了如何通过模块化、状态管理和避免冗余计算等方法,有效提升应用的响应性和可维护性。
|
11天前
|
JavaScript 前端开发 UED
vue学习第二章
欢迎来到我的博客!我是一名自学了2年半前端的大一学生,熟悉JavaScript与Vue,目前正在向全栈方向发展。如果你从我的博客中有所收获,欢迎关注我,我将持续更新更多优质文章。你的支持是我最大的动力!🎉🎉🎉
|
11天前
|
JavaScript 前端开发 开发者
vue学习第一章
欢迎来到我的博客!我是瑞雨溪,一名热爱JavaScript和Vue的大一学生。自学前端2年半,熟悉JavaScript与Vue,正向全栈方向发展。博客内容涵盖Vue基础、列表展示及计数器案例等,希望能对你有所帮助。关注我,持续更新中!🎉🎉🎉
|
12天前
|
存储 缓存 JavaScript
在 Vue 中使用 computed 和 watch 时,性能问题探讨
本文探讨了在 Vue.js 中使用 computed 计算属性和 watch 监听器时可能遇到的性能问题,并提供了优化建议,帮助开发者提高应用性能。
|
12天前
|
存储 缓存 JavaScript
Vue 中 computed 和 watch 的差异
Vue 中的 `computed` 和 `watch` 都用于处理数据变化,但使用场景不同。`computed` 用于计算属性,依赖于其他数据自动更新;`watch` 用于监听数据变化,执行异步或复杂操作。
|
13天前
|
存储 JavaScript 开发者
Vue 组件间通信的最佳实践
本文总结了 Vue.js 中组件间通信的多种方法,包括 props、事件、Vuex 状态管理等,帮助开发者选择最适合项目需求的通信方式,提高开发效率和代码可维护性。
下一篇
无影云桌面