重學前端學習筆記(五)-JavaScript原型

JavaScript原型

中文中有個成語叫做“照貓畫虎”,這裏的貓看起來就是虎的原型。
最爲成功的流派是使用“類”的方式來描述對象,這誕生了諸如 C++、Java等流行的編程語言。這個流派叫做基於類的編程語言。

還有一種就是基於原型的編程語言,它們利用原型來描述對象。我們的JavaScript就是其中代表。

基於類/基於原型

基於類”的編程提倡使用一個關注分類和類之間關係開發模型。在這類語言中,總是先有類,再從類去實例化一個對象。類與類之間又可能會形成繼承、組合等關係。類又往往與語言的類型系統整合,形成一定編譯時的能力。

與此相對,“基於原型”的編程看起來更爲提倡程序員去關注一系列對象實例的行爲,而後纔去關心如何將這些對象,劃分到最近的使用方式相似的原型對象,而不是將它們分成類。
基於原型的面向對象系統通過“複製”的方式來創建新對象。一些語言的實現中,還允許複製一個空對象。這實際上就是創建一個全新的對象。

基於原型和基於類都能夠滿足基本的複用和抽象需求,但是適用的場景不太相同。

原型系統的“複製操作”有兩種實現思路:

  • 一個是並不真的去複製一個原型對象,而是使得新對象持有一個原型的引用;
  • 另一個是切實地複製對象,從此兩個對象再無關聯。

歷史上的基於原型語言因此產生了兩個流派,顯然,JavaScript顯然選擇了前一種方式。

JavaScript的原型

如果我們拋開JavaScript用於模擬Java類的複雜語法設施(如new、Function Object、函數的prototype屬性等),原型系統可以說相當簡單,我可以用兩條概括:

  • 如果所有對象都有私有字段[[prototype]],就是對象的原型;
  • 讀一個屬性,如果對象本身沒有,則會繼續訪問對象的原型,直到原型爲空或者找到爲止。

這個模型在ES的各個歷史版本中並沒有很大改變,但從ES6 以來,JavaScript提供了一系列內置函數,以便更爲直接地訪問操縱原型。
三個方法分別爲:

  • Object.create 根據指定的原型創建新對象,原型可以是null
  • Object.getPrototypeOf 獲得一個對象的原型;
  • Object.setPrototypeOf 設置一個對象的原型。

利用這三個方法,我們可以完全拋開類的思維,利用原型來實現抽象和複用。我用下面的代碼展示了用原型來抽象貓和虎的例子。

var cat = {
    say(){
        console.log("meow~");
    },
    jump(){
        console.log("jump");
    }
}

var tiger = Object.create(cat,  {
    say:{
        writable:true,
        configurable:true,
        enumerable:true,
        value:function(){
            console.log("roar!");
        }
    }
})


var anotherCat = Object.create(cat);

anotherCat.say();

var anotherTiger = Object.create(tiger);

anotherTiger.say();

這段代碼創建了一個“貓”對象,又根據貓做了一些修改創建了虎,之後我們完全可以用Object.create來創建另外的貓和虎對象,我們可以通過“原始貓對象”和“原始虎對象”來控制所有貓和虎的行爲。

但是,在更早的版本中,程序員只能通過Java風格的類接口來操縱原型運行時,可以說非常彆扭。

早期版本中的類與原型

在早期版本的JavaScript中,“類”的定義是一個私有屬性 [[class]],語言標準爲內置類型諸如Number、String、Date等指定了[[class]]屬性,以表示它們的類。語言使用者唯一可以訪問[[class]]屬性的方式是Object.prototype.toString
以下代碼展示了所有具有內置class屬性的對象:

	var o = new Object;
    var n = new Number;
    var s = new String;
    var b = new Boolean;
    var d = new Date;
    var arg = function(){ return arguments }();
    var r = new RegExp;
    var f = new Function;
    var arr = new Array;
    var e = new Error;
    console.log([o, n, s, b, d, arg, r, f, arr, e].map(v => Object.prototype.toString.call(v)));

因此,在ES3和之前的版本,JS中類的概念是相當弱的,它僅僅是運行時的一個字符串屬性。

在ES5開始,[[class]] 私有屬性被 Symbol.toStringTag 代替,Object.prototype.toString 的意義從命名上不再跟 class 相關。我們甚至可以自定義 Object.prototype.toString 的行爲,以下代碼展示了使用Symbol.toStringTag來自定義 Object.prototype.toString 的行爲:

	var o = { [Symbol.toStringTag]: "MyObject" }
    console.log(o + "");

這裏創建了一個新對象,並且給它唯一的一個屬性 Symbol.toStringTag,我們用字符串加法觸發了Object.prototype.toString的調用,發現這個屬性最終對Object.prototype.toString 的結果產生了影響。
考慮到JavaScript語法中跟Java相似的部分,我們對類的討論不能用“new運算是針對構造器對象,而不是類”來試圖迴避。

所以,我們仍然要把new理解成JavaScript面向對象的一部分,下面我就來講一下new操作具體做了哪些事情。
new 運算接受一個構造器和一組調用參數,實際上做了幾件事:

  • 以構造器的 prototype 屬性(注意與私有字段[[prototype]]的區分)爲原型,創建新對象;
  • 將 this 和調用參數傳給構造器,執行;
  • 如果構造器返回的是對象,則返回,否則返回第一步創建的對象。

new 這樣的行爲,試圖讓函數對象在語法上跟類變得相似,但是,它客觀上提供了兩種方式,一是在構造器中添加屬性,二是在構造器的 prototype 屬性上添加屬性。

下面代碼展示了用構造器模擬類的兩種方法:

function c1(){
    this.p1 = 1;
    this.p2 = function(){
        console.log(this.p1);
    }
} 
var o1 = new c1;
o1.p2();



function c2(){
}
c2.prototype.p1 = 1;
c2.prototype.p2 = function(){
    console.log(this.p1);
}

var o2 = new c2;
o2.p2();

第一種方法是直接在構造器中修改this,給this添加屬性。

第二種方法是修改構造器的prototype屬性指向的對象,它是從這個構造器構造出來的所有對象的原型

沒有Object.create、Object.setPrototypeOf 的早期版本中,new 運算是唯一一個可以指定[[prototype]]的方法(當時的mozilla提供了私有屬性__proto__,但是多數環境並不支持),所以,當時已經有人試圖用它來代替後來的 Object.create,我們甚至可以用它來實現一個Object.create的不完整的polyfill,見以下代碼:

Object.create = function(prototype){
    var cls = function(){}
    cls.prototype = prototype;
    return new cls;
}

這段代碼創建了一個空函數作爲類,並把傳入的原型掛在了它的prototype,最後創建了一個它的實例,根據new的行爲,這將產生一個以傳入的第一個參數爲原型的對象。

這個函數無法做到與原生的Object.create一致,一個是不支持第二個參數,另一個是不支持null作爲原型,所以放到今天意義已經不大了。

ES6 中的類

ES6中加入了新特性class,new跟function搭配的怪異行爲終於可以退休了(雖然運行時沒有改變),在任何場景,我都推薦使用ES6的語法來定義類,而令function迴歸原本的函數語義。
ES6中引入了class關鍵字,並且在標準中刪除了所有[[class]]相關的私有屬性描述,類的概念正式從屬性升級成語言的基礎設施,從此,基於類的編程方式成爲了JavaScript的官方編程範式。
類的基本寫法:

class Rectangle {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
  // Getter
  get area() {
    return this.calcArea();
  }
  // Method
  calcArea() {
    return this.height * this.width;
  }
}

在現有的類語法中,getter/setter和method是兼容性最好的。

我們通過get/set關鍵字來創建getter,通過括號和大括號來創建方法,數據型成員最好寫在構造器裏面。

類的寫法實際上也是由原型運行時來承載的,邏輯上JavaScript認爲每個類是有共同原型的一組對象,類中定義的方法和屬性則會被寫在原型對象之上。

此外,最重要的是,類提供了繼承能力。我們來看一下下面的代碼。

class Animal { 
  constructor(name) {
    this.name = name;
  }
  
  speak() {
    console.log(this.name + ' makes a noise.');
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name); // call the super class constructor and pass in the name parameter
  }

  speak() {
    console.log(this.name + ' barks.');
  }
}

let d = new Dog('Mitzie');
d.speak(); // Mitzie barks.

以上代碼創造了Animal類,並且通過extends關鍵字讓Dog繼承了它,展示了最終調用子類的speak方法獲取了父類的name。

比起早期的原型模擬方式,使用extends關鍵字自動設置了constructor,並且會自動調用父類的構造函數,這是一種更少坑的設計。

所以當我們使用類的思想來設計代碼時,應該儘量使用class來聲明類,而不是用舊語法,拿函數來模擬對象。

一些激進的觀點認爲,class關鍵字和箭頭運算符可以完全替代舊的function關鍵字,它更明確地區分了定義函數和定義類兩種意圖,我認爲這是有一定道理的。

總結

在新的ES版本中,我們不再需要模擬類了:我們有了光明正大的新語法。而原型體系同時作爲一種編程範式和運行時機制存在。

我們可以自由選擇原型或者類作爲代碼的抽象風格,但是無論我們選擇哪種,理解運行時的原型系統都是很有必要的一件事。

*

在講解ES6中的類時,文中指出“類中定義的方法和屬性則會被寫在原型對象之上”,事實上一般數據屬性寫在對象上,而訪問器屬性和方法纔是寫在原型對象之上的。

class和extends實質上是作爲語法糖,統一了JS程序員對基於類的面向對象的模擬,但感覺文中講的不是很清楚。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章