Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
MrErHu opened this issue Jun 14, 2018 · 0 comments
Open

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

MrErHu opened this issue Jun 14, 2018 · 0 comments
Labels

Comments

@MrErHu
Copy link
Owner

MrErHu commented Jun 14, 2018

前言

  首先欢迎大家关注我的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继而可以创造对应类型的实例。

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant