> 原文:http://www.infoq.com/cn/articles/es6-in-depth-subclassing
[TOC]
在之前的文章《[深入淺出ES6(十三):類 Class](http://www.infoq.com/cn/articles/es6-in-depth-classes)》中,我們一起深入探討了ES6的新特性——類,在這篇文章中我寫到“可以使用類來創建一些簡易的對象構造函數”,于是我們共同實現了這樣一段代碼:
~~~
class Circle {
constructor(radius) {
this.radius = radius;
Circle.circlesMade++;
};
static draw(circle, canvas) {
// Canvas繪制代碼
};
static get circlesMade() {
return !this._count ? 0 : this._count;
};
static set circlesMade(val) {
this._count = val;
};
area() {
return Math.pow(this.radius, 2) * Math.PI;
};
get radius() {
return this._radius;
};
set radius(radius) {
if (!Number.isInteger(radius))
throw new Error("圓的半徑必須是整數。");
this._radius = radius;
};
}
~~~
你們或許還記得,我在文章末尾預留了一個尚未揭曉的懸念,其實在類的整個體系中還有一個威力無窮的特性等待我們深入挖掘。ES6的類系統借鑒了傳統的C++和Java中的類系統,它們都支持類繼承,子類可以繼承父類,并在其基礎上擴展更多自己的功能。那就讓我們繼續今天的文章,進一步發掘這個新特性無窮的潛力吧。
在我們開始討論子類的概念之前,還是再一起鞏固一下屬性繼承和動態原型鏈的相關知識。
## JavaScript繼承
我們在創建對象的時候可以為其添加各種屬性,但在這個過程中,新創建的對象同時也繼承了原型對象的屬性。作為JavaScript開發者,你應該很熟悉`Object.create`這個API,我們可以用它來創建一些新對象:
~~~
var proto = {
value: 4,
method() { return 14; }
}
var obj = Object.create(proto);
obj.value; // 4
obj.method(); // 14
~~~
進一步說,如果我們在創建`obj`時給它添加`proto`已有的屬性,則新創建對象會覆蓋原型對象中的同名屬性。
~~~
obj.value = 5;
obj.value; // 5
proto.value; // 4
~~~
## 子類化的基本概念
現在我們已經了解,通過類創建的對象,它們的原型鏈之間是如何連接的。回想一下,當我們創建一個類的時候,類的定義中有一個`constructor`方法,這個方法控制著所有靜態方法,實際上我們正是依據這個方法創建了一個新的函數;然后我們又創建一個對象并將它賦給這個函數的`prototype`屬性。為了使新創建的類繼承所有的靜態屬性,我們需要讓這個新的函數對象繼承超類的函數對象;同樣,為了使新創建的類繼承所有實例方法,我們需要讓新函數的`prototype`對象繼承超類的`prototype`對象。
看官您若要是感覺這長篇大論看得頭疼,不妨來看一段簡單的示例,看我們如何通過舊語法的力量將這一切連結起來,然后再進一步美化這段代碼的結構。
繼續我們之前的示例,假設我們有一個類`Shape`,并且想要基于這個類生成一個子類:
~~~
class Shape {
get color() {
return this._color;
}
set color(c) {
this._color = parseColorAsRGB(c);
this.markChanged(); // 稍后重繪Canvas
}
}
~~~
在嘗試編寫這些代碼時,我們仍會面臨以前遇到過的`static`屬性的問題:我們不能在定義函數的同時改變它的原型,到目前為止我們所知的語法不能實現這種功能。只不過你可以通過`Object.setPrototypeOf`方法繞過這一限制,但隨即帶來的問題是,這個方法性能很差,而JS引擎又很難對其進行優化,所以我們期待一種更完美的方法,能夠在創建函數的同時處理好原型。
~~~
class Circle {
// 與上文中代碼相同
}
// 連結實例屬性
Object.setPrototypeOf(Circle.prototype, Shape.prototype);
// 連結靜態屬性
Object.setPrototypeOf(Circle, Shape);
~~~
這 段代碼丑爆了!我們為JavaScript添加類語法的初衷是:能夠用一段完整的代碼封裝目標對象的所有邏輯,而不是在類聲明結束后再進行一些額外的“連 結”操作。Java、Ruby和其它面向對象的語言中有一種特定的語法可以聲明“一個類是另一個的子類”,JavaScript中當然也要有這樣的方法! 我們可以用關鍵詞`extends`聲明子類關系,看好了,代碼可以這樣去寫:
~~~
class Circle extends Shape {
// 與上文中代碼相同
}
~~~
`extends`后面可以接駁任意合法且擁有`prototype`屬性的構造函數。它可以是:
* 另一個類
* 源自現有繼承框架(譯者注:作者指的是原型繼承,即使在JavaScript中類繼承的本質也是原型繼承)的近類函數
* 一個普通的函數
* 一個包含一個函數或類的變量
* 一個對象上的屬性訪問
* 一個函數調用
如果不希望創建出來的實例繼承自`Object.prototype`,你甚至可以在`extends`后使用`null`來進行聲明。
## Super屬性
現在我們學會怎樣創建子類了,子類可以繼承父類的屬性,有時我們會在子類中重新定義同名方法,這樣會覆蓋掉我們繼承的方法。但在某些情況下,如果你重新定義了一個方法,但有時你又想繞開這個方法去使用父類中的同名方法,應該怎樣做呢?
假設我們想基于`Circle`類生成一個子類,這個子類可以通過一些因子來控制圓的縮放,為了實現這一目標,我們寫下的這個類看起來有些不太自然:
~~~
class ScalableCircle extends Circle {
get radius() {
return this.scalingFactor * super.radius;
}
set radius() {
throw new Error("可伸縮圓的半徑radius是常量。" +
"請設置伸縮因子scalingFactor的值。");
}
// 處理scalingFactor的代碼
}
~~~
請注意`radius`的getter使用的是`super.radius`。這里的`super`是一個全新的關鍵字,它可以幫我們繞開我們在子類中定義的屬性,直接從子類的原型開始查找屬性,從而繞過我們覆蓋到父類上的同名方法。
通過方法定義語法定義的函數,其原始對象方法的定義在初始化后就已完成,從而我們可以訪問它的super屬性(也可以訪問`super[expr]`),由于該訪問依賴的是原始對象,所以即使我們將方法存到本地變量,再訪問時也不會改變`super`的行為。
~~~
var obj = {
toString() {
return "MyObject: " + super.toString();
}
}
obj.toString(); // MyObject: [object Object]
var a = obj.toString;
a(); // MyObject: [object Object]
~~~
## 子類化內建方法
你想做的另一件事可能是擴展JavaScript的內建方法。現在你擁有極為強大的JS內建數據結構,它是子類化設計的基礎之一,可被用來創建新的類型。假設你想編寫一個帶版本號的數組,你可以改變數組內容然后提交,或者將數組回滾到之前的狀態。我們可以通過子類化`Array`來快速實現這一功能。
~~~
class VersionedArray extends Array {
constructor() {
super();
this.history = [[]];
}
commit() {
// 將變更保存到history。
this.history.push(this.slice());
}
revert() {
this.splice(0, this.length, this.history[this.history.length - 1]);
}
}
~~~
`VersionedArray`的實例保留了一些重要的屬性,包括`map`、`filter`還有`sort`,它們是組成數組的核心方法;當然,`Array.isArray()`也會把`VersionedArray`視為數組;當你向`VersionedArray`中添加元素時,它的`length`屬性也會自動增長;說遠一些,之前能夠返回一個新數組的函數(例如`Array.prototype.slice()`)現在會返回一個`VersionedArray`!
## 派生類構造函數
你可能已經注意到在上個示例中`constructor`方法中我們調用了`super()`方法。調用過程都做了哪些事情呢?
在傳統的類模型中,構造函數的作用是為類的實例初始化所有內部狀態。每個子類中的構造函數都負責初始化各自的狀態。我們希望將這些調用傳遞下去,以使所有子類都與父類共享相同的初始化代碼。
我們可以通過`super`關鍵詞調用父類中的構造函數,此時它本身也好像是函數一般。請注意,這個語法只在使用`extends`擴展的子類的`constructor`方法中有效,通過關鍵詞`super`可以重寫我們的`Shape`類。
~~~
class Shape {
constructor(color) {
this._color = color;
}
}
class Circle extends Shape {
constructor(color, radius) {
super(color);
this.radius = radius;
}
// 與上文中代碼相同
}
~~~
在JavaScript中,我們傾向于在構造函數中操作`this`對象,裝載屬性并且初始化內部狀態。通常情況下,當我們通過`new`調用構造函數時`this`對象即被創建,就好像用`Object.create()`通過構造函數的`prototype`屬性構造對象時所做的那樣。然而,有些內建方法的內部對象布局不太一樣。例如數組在內存中的布局方式就與普通對象不同。我們想要子類化內建方法,所以我們讓最基礎的構造函數分配`this`對象。如果它是一個內建方法,我們會得到我們想要的對象布局;如果它是一個普通構造函數,我們會得到期待中的默認的`this`對象。
你可能對`this`在子類構造函數中的綁定方式感到不解。當我們執行基類的構造函數前,`this`對象沒有被分配,從而我們無法得到一個確定的`this`值。因此,在子類的構造函數中,調用`super`構造函數之前訪問`this`會觸發一個引用錯誤(`ReferenceError`)。
正如我們在上一篇文章中看到的,派生類構造函數與`constructor`方法的省略規則相同,看起來好像你這樣寫就像這樣:
~~~
constructor(...args) {
super(...args);
}
~~~
構造函數有時不與`this`對象交互,它們通過其它方式創建對象并初始化后直接返回,在這種情況下,就不需要再使用`super`了。此時即使不調用`super`構造函數,任何構造函數也都能直接返回一個對象。
## new.target
還有一個奇怪的副作用,如果讓最基礎的類分配`this`對象,它有時并不知道應該分配哪一種對象。假設你正在寫一個對象框架庫,你想要一個基類`Collection`,有一些子類是數組,有一些子類是map。此時,當你準備執行`Collection`的構造函數時,你將無法區分應該生成哪個類型的對象。
既然我們已經能夠子類化內建方法,當我們執行內建構造函數時,在內部我們其實需要知道原始類的`prototype`屬性指向何方。如果沒有這個屬性,我們無法創建有適當的實例方法的對象。為了解決這個問題,我們加入了一種語法來向JavaScript代碼暴露這些信息。我們新添加的元屬性`new.target`與構造函數有關,因為直接通過`new`來調用,所以它是一個被調函數,在這個函數中調用`super`會傳遞`new.target`的值。
我知道這很難理解,所以我將通過一段代碼詳細講解:
~~~
class foo {
constructor() {
return new.target;
}
}
class bar extends foo {
// 為了清晰起見我們顯式地調用super。
// 事實上不必去獲取這些結果。
constructor() {
super();
}
}
// 直接調用foo,所以new.target是foo
new foo(); // foo
// 1) 直接調用bar,所以new.target就是bar
// 2) bar通過super()調用foo,所以new.target仍然是bar
new bar(); // bar
~~~
現在,我們已經解決了上述的`Collection`問題,`Collection`構造函數可以通過檢查`new.target`來確定子類的類型以及使用哪個內建方法這些不確定的因素。
`new.target`在任何函數中都是合法的,如果函數不是通過`new`調用,`new.target`將被賦值為`undefined`。
## 魚和熊掌可以得兼
希望你能堅持讀完整篇文章,充實大腦中的知識,我將感激不盡。現在讓我們花一點時間來探討一下,上面這些方法能很好地解決問題么?許多人已經針對“在語言中加入繼承的特性是好是壞”這個問題各抒己見,你可能認為對于創建對象來說,繼承永遠比不上構造([Composition](https://en.wikipedia.org/wiki/Composition_over_inheritance)) 的方法,換句話說,在與那么多古老的原型模型進行比較后你會發現,代碼變得更加簡潔需要付出的代價是設計靈活性的缺失。不可否認,在面向對象的世界里,如 果你想創建一些對象,并且它們以擴展的方式共享代碼,最好的選擇是混入類(Mixins),原因很簡單:它能為你提供一種將多個對象的不同方法混入到同一 個對象中的方法,這個方法很簡單,因此你無須理解兩個不相關的代碼片段是如何在相同的繼承結構中相互適應的。
很 多人在這個話題上堅決捍衛自己的信念,但是我認為有些事情不值得一昧地堅持。首先,向語言中添加類特性并不意味著要強制使用這些特性,這一態度非常重要; 第二,同樣重要的是,向語言中添加類特性也不意味著這就是解決繼承問題的最好方式!事實上,有一些問題更適合通過原型繼承的方式來建模。在考慮過所有情況 之后,類它只是你可以使用的一個額外的工具而已,它既不是唯一的工具也不一定是最好的工具。
如 果你想繼續使用混入類,你可能希望你能有這樣一種類,它繼承自幾個不同的主體,所以你可以繼承每一個混入(Mixin)然后獲取它們的精華。不幸的是,如 果改變現有的繼承模型可能會使整個系統非常混亂,所以JavaScript沒有實現類的多繼承。那也就是說,在一個基于類的框架內部有一種混合解決方案可 以支持混入類特性。請看下面的這段代碼,這是一個基于我們熟知的`extend`混入習語打造的函數:
~~~
function mix(...mixins) {
class Mix {}
// 以編程方式給Mix類添加
// mixins的所有方法和訪問器
for (let mixin of mixins) {
copyProperties(Mix, mixin);
copyProperties(Mix.prototype, mixin.prototype);
}
return Mix;
}
function copyProperties(target, source) {
for (let key of Reflect.ownKeys(source)) {
if (key !== "constructor" && key !== "prototype" && key !== "name") {
let desc = Object.getOwnPropertyDescriptor(source, key);
Object.defineProperty(target, key, desc);
}
}
}
~~~
現在,我們無須在各種各樣的混入之間創建顯示的繼承關系,我們只須使用`mix`函數就可以創建一個組合而成的超類。設想一下,如果你想編寫一個協作編輯工具,在這個工具中的所有編輯動作都被記錄下來,然后將內容序列化。你可以使用`mix`函數寫一個`DistributedEdit`類:
~~~
class DistributedEdit extends mix(Loggable, Serializable) {
// 事件方法
}
~~~
這真是兩全其美啊。如果你想將構造本身有超類的混入類,也可以用這個模型來解決:我們將超類傳遞給`mix`函數后它就會返回擴展后的類。
## 目前的可用性
好的,我們已經討論了許多有關子類化內建方法還有所有的這些新玩意兒,但是你現在可以用這些新技術了么?
事實上,你只能使用其中一部分。在主流瀏覽器廠商中,Chrome已經移植了我們今天所討論的大多數特性,在嚴格模式下,你應該可以實現我們討論的幾乎所有的功能,當然,這不包括子類化`Array`。其它內建類型也會正常運轉,但是數組會有一些不一樣;我正在實現Firefox中的相關功能,希望能在不就之后達到與Chrome一樣的目標(除了數組之外的每一個功能)。你可以訪問[Bug 1141863](https://bugzilla.mozilla.org/show_bug.cgi?id=1141863)獲取更多信息,但可能要在數周后才會登陸Nightly版本的Firefox(譯者注:截至翻譯本文,該功能[已經實現](https://bugzilla.mozilla.org/show_activity.cgi?id=1141863),將發布于Firefox 44)。
此外,Edge已經支持了`super`功能,但是沒支持子類化內建方法;Safari不支持所有的這些功能。
轉譯器相對來說處于劣勢,它們可以創建類,支持`super`特性,但是子類化內建方法必須得到引擎的支持,它們需要通過引擎才能從內建方法中得到基類的實例(想想`Array.prototype.splice`)。
唷!真是一篇長文章啊。下一次[Jason Orendorff](http://www.infoq.com/cn/author/Jason-Orendorff)將回來與大家共同深入淺出ES6的模塊系統。