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程序員對基於類的面向對象的模擬,但感覺文中講的不是很清楚。