從Vue陣列響應化所引發的思考

請叫我王磊同學發表於2018-06-14

前言

  首先歡迎大家關注我的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繼而可以創造對應型別的例項。

  上面整個的文章都是基於監聽陣列響應的一個點想到的。這裡僅僅是起到拋磚引玉的作用,希望能對大家有所幫助。如有不正確的地方,歡迎大家指出,願共同學習。

相關文章