我們描述了ES6中新增的新類系統,用於處理建立物件建構函式的瑣碎情況。我們展示瞭如何使用它來編寫如下程式碼:
class Circle {
constructor(radius) {
this.radius = radius;
Circle.circlesMade++;
};
static draw(circle, canvas) {
// Canvas drawing code
};
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("Circle radius must be an integer.");
this._radius = radius;
};
}
不幸的是,正如一些人指出的那樣,當時沒有時間討論ES6中其他類的強大功能。與傳統的類系統(例如c++或Java)一樣,ES6允許繼承
,即一個類使用另一個類作為基類,然後通過新增自己的更多特性來擴充套件它。讓我們仔細看看這個新特性的可能性。
在開始討論子類
之前,花點時間回顧一下屬性繼承
和動態原型鏈
是很有用的。
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
上的屬性會覆蓋掉proto
上的屬性:
obj.value = 5;
obj.value; // 5
proto.value; // 4
子類基本要點
記住一點,我們現在可以看到應該如何連線由類建立的物件
的原型鏈
。回想一下,當我們建立一個類時,我們建立了一個新函式,與類定義中包含所有靜態方法的constructor
方法相對應。我們還建立了一個物件作為所建立函式的prototype
屬性,它將包含所有的例項方法(instance method)。為了建立繼承所有靜態屬性的新類,我們必須使新函式物件繼承父類的函式物件。類似地,對於例項方法,我們必須使新函式的prototype
物件繼承父類的prototype
物件。
這種描述非常複雜。讓我們嘗試一個示例,展示如何在不新增新語法的情況下將其連線起來,然後新增一個微不足道的擴充套件,使其更美觀。繼續前面的例子,假設我們有一個想要被繼承的Shape
類:
class Shape {
get color() {
return this._color;
}
set color(c) {
this._color = parseColorAsRGB(c);
this.markChanged(); // repaint the canvas later
}
}
當我們試圖編寫這樣的程式碼時,我們遇到了與上一篇關於靜態屬性的文章相同的問題:在定義函式時,沒有一種語法方法可以改變它的原型。你可以用Object.setPrototypeOf
來解決這個問題。對於引擎來說,這種方法的效能和可優化性都不如使用預期原型建立函式的方法。
class Circle {
// As above
}
// Hook up the instance properties
Object.setPrototypeOf(Circle.prototype, Shape.prototype);
// Hook up the static properties
Object.setPrototypeOf(Circle, Shape);
這太難看了。我們新增了類語法,這樣我們就可以封裝關於最終物件在一個地方的外觀的所有邏輯,而不是在之後使用Object.setPrototypeOf
的邏輯。Java、Ruby和其他面嚮物件語言都有一種方法來宣告一個類宣告是另一個類的子類,我們也應該這樣做。我們使用關鍵字extends
,所以可以這樣寫:
class Circle extends Shape {
// As above
}
你可以在extends
後面放任何你想要的表示式,只要它是一個帶prototype
屬性的有效constructor
函式。例如:
- 另一個class
- 從現有的繼承框架中來的類class的函式
- 一個普通function
- 一個代表函式或類的變數
- 一個函式呼叫:
func()
- 一個對物件屬性的訪問:
obj.name
如果你不希望例項繼承Object.prototype
,你甚至可以使用null
。
父類的屬性(super properties)
我們可以建立子類,我們可以繼承屬性,有時我們的方法甚至會重寫我們繼承的方法。但如果你想要繞過這個重寫
機制呢?假設我們想要編寫Circle
類的一個子類來處理按某個因數縮放圓。為了做到這一點,我們可以編寫類:
class ScalableCircle extends Circle {
get radius() {
return this.scalingFactor * super.radius;
}
set radius() {
throw new Error("ScalableCircle radius is constant." +
"Set scaling factor instead.");
}
// Code to handle scalingFactor
}
注意,radius
getter使用super.radius
。這個新的super
關鍵字允許我們繞過我們自己的屬性,並從我們的原型開始尋找屬性,從而繞過我們可能做過的任何重寫
。
父類屬性訪問(順便說一下,super[expr]
也可以正常使用)可以在任何用方法定義語法
定義的函式中使用。雖然這些函式可以從原始物件中提取出來,但訪問是繫結到方法最初定義的物件上的。這意味著將super方法
賦值給區域性變數中不會改變
super`的行為。
var obj = {
toString() {
return "MyObject: " + super.toString();
}
}
obj.toString(); // MyObject: [object Object]
var a = obj.toString;
a(); // MyObject: [object Object]
子類的內建命令
你可能想要做的另一件事是為JavaScript語言內建程式編寫擴充套件。內建的資料結構
為該語言新增了巨大的功能,能夠建立利用這種功能的新型別是非常有用的,並且是子類設計
的基礎部分。假設您想要編寫版本控制陣列。你應該能夠進行更改,然後提交它們,或者回滾到以前提交的更改。快速實現的一種方法是編寫Array
的子類。
class VersionedArray extends Array {
constructor() {
super();
this.history = [[]];
}
commit() {
// Save changes to history.
this.history.push(this.slice());
}
revert() {
this.splice(0, this.length, this.history[this.history.length - 1]);
}
}
VersionedArray
的例項保留了一些重要的屬性。它們是Array
的真實例項,包括map
、filter
和sort
。Array.isArray()
會像對待陣列一樣對待它們,它們甚至會獲得自動更新的陣列length
屬性。甚至,返回新陣列的函式(如Array.prototype.slice()
)將返回VersionedArray
!
派生類建構函式
你可能已經注意到上一個示例的建構函式方法中的super()
。到底發生了什麼事?
在傳統的類模型中,建構函式用於初始化類例項的任何內部狀態。每個子類負責初始化與其相關聯的狀態。我們希望將這些呼叫連結起來,以便子類與它們所擴充套件的類共享相同的初始化程式碼。
為了呼叫父類的建構函式,我們再次使用super
關鍵字,這一次它就像一個函式一樣。此語法僅在使用extends
的類的建構函式方法中有效。使用super
,我們可以重寫Shape
類。
class Shape {
constructor(color) {
this._color = color;
}
}
class Circle extends Shape {
constructor(color, radius) {
super(color);
this.radius = radius;
}
// As from above
}
在JavaScript中,我們傾向於編寫對this
物件進行操作的建構函式,設定屬性並初始化內部狀態。通常,this
物件是在使用new
呼叫建構函式時建立的,就像在建構函式的prototype
屬性上使用Object.create()
一樣。然而,一些內建物件
有不同的內部物件佈局。例如,陣列在記憶體中的佈局與普通物件不同。因為我們希望能夠繼承這些內建物件
,所以我們讓最基本的建構函式(最上級的父類)分配this
物件。如果它是內建的,我們會得到我們想要的物件佈局,如果它是普通建構函式,我們會得到this
物件的預設值。
可能最奇怪的結果是在子類建構函式中繫結this
的方式。在執行基類建構函式
並允許它分配this
物件之前,我們不會擁有this
值。因此,在子類建構函式中,在呼叫父類造函式super()
之前對this
的所有訪問都將導致ReferenceError。
正如我們在上一篇文章中看到的,你可以省略建構函式方法constructor
,派生類(子類)建構函式也可以省略,就像你寫的:
constructor(...args) {
super(...args);
}
有時,建構函式不與this
物件互動。相反,它們以其他方式建立物件,初始化它,然後直接返回它。如果是這種情況,就沒有必要使用super
。任何建構函式都可以直接返回一個物件,與是否呼叫過父類建構函式(super)無關。
new.target
讓最上級的父類分配this
物件的另一個奇怪的副作用是,有時最上級的父類不知道要分配哪種物件。假設你正在編寫一個物件框架庫,你想要一個基類Collection
,它的一些子類是Arrays
,一些是Maps
。然後,在執行Collection
建構函式時,您將無法判斷要建立哪種型別的物件!
由於我們能夠繼承父類的內建屬性,當我們執行父類內建建構函式時,我們已經在內部知道了原始類的prototype
。沒有它,我們就無法建立具有適當例項方法的物件。為了解決這種奇怪的Collection
問題,我們新增了語法,以便將該資訊公開給JavaScript程式碼。我們新增了一個新的元屬性new.target
,它對應於用new
直接呼叫的建構函式。呼叫使用new
呼叫的函式會設定new.target
為被呼叫的函式,並在該函式中呼叫super
轉發new.target
的值。
這很難理解,所以我來告訴你我的意思:
class foo {
constructor() {
return new.target;
}
}
class bar extends foo {
// This is included explicitly for clarity. It is not necessary
// to get these results.
constructor() {
super();
}
}
// foo directly invoked, so new.target is foo
new foo(); // foo
// 1) bar directly invoked, so new.target is bar
// 2) bar invokes foo via super(), so new.target is still bar
new bar(); // bar
我們已經解決了上面描述的Collection
的問題,因為Collection
建構函式可以只檢查new.target
,並使用它來派生類沿襲,並確定要使用哪個內建建構函式。
new.target
在任何函式中都是有效的,如果函式不是用new呼叫的,它將被設定為undefined
。
兩全其美
許多人都直言不諱地表示,在語言特性中編寫繼承是否是一件好事。你可能認為,與舊的原型模型相比,繼承永遠不如組合建立物件(composition)好,或者新語法的整潔不值得因此而缺乏設計靈活性。不可否認的是,在建立以可擴充套件方式共享程式碼的物件時,mixin
已經成為一種主要的習慣用法,這是有原因的:它們提供了一種簡單的方法,可以將不相關的程式碼共享到同一個物件,而無需理解這兩個不相關的部分
在這個話題上有不同意見,但我認為有一些事情值得注意。首先,作為一種語言特性新增的類
並沒有強制使用它們。第二,同樣重要的是,將類
作為一種語言特性新增並不意味著它們總是解決繼承問題的最佳方法!事實上,有些問題更適合使用原型繼承
進行建模。在一天結束的時候,課程只是教會你可以使用的另一個工具;不是唯一的工具,也不一定是最好的。
如果你想繼續使用mixin
,你可能希望你可以訪問繼承了幾個東西的類,這樣你就可以繼承每個mixin
,讓一切都很好。不幸的是,現在更改繼承模型會很不協調,因此JavaScript沒有為類實現多重繼承
。也就是說,有一種混合解決方案允許mixin在基於類的框架中。基於眾所周知的mixin extend
習慣用法,考慮以下的函式。
function mix(...mixins) {
class Mix {}
// Programmatically add all the methods and accessors
// of the mixins to class Mix.
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
函式來建立一個複合基類,而不必在各種mixin
之間建立顯式的繼承關係。想象一下,編寫一個協作編輯工具,其中記錄了編輯操作,並且需要對其內容進行序列化。你可以使用mix函式來編寫一個類DistributedEdit:
class DistributedEdit extends mix(Loggable, Serializable) {
// Event methods
}
這是兩全其美的方案。很容易看到如何擴充套件這個模型來處理自己有超類的mixin類:我們可以簡單地將父類傳遞給mix,並讓返回類擴充套件它。