類別繼承是一種讓一個類別延伸另一個類別的方式。
因此,我們可以在現有功能的基礎上建立新的功能。
「extends」關鍵字
假設我們有類別 Animal
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} runs with speed ${this.speed}.`);
}
stop() {
this.speed = 0;
alert(`${this.name} stands still.`);
}
}
let animal = new Animal("My animal");
以下是我們如何以圖形方式表示 animal 物件和 Animal 類別
…我們想要建立另一個class Rabbit。
由於兔子是動物,Rabbit類別應該以Animal為基礎,存取動物方法,讓兔子可以執行「一般」動物可以執行的動作。
延伸另一個類別的語法為:class Child extends Parent。
讓我們建立繼承自Animal的class Rabbit
class Rabbit extends Animal {
hide() {
alert(`${this.name} hides!`);
}
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.hide(); // White Rabbit hides!
Rabbit類別的物件可以存取Rabbit方法(例如rabbit.hide()),也可以存取Animal方法(例如rabbit.run())。
在內部,extends關鍵字使用古老的原型機制運作。它將Rabbit.prototype.[[Prototype]]設定為Animal.prototype。因此,如果在Rabbit.prototype中找不到方法,JavaScript會從Animal.prototype取得。
例如,要尋找rabbit.run方法,引擎會檢查(在圖片中由下往上)
rabbit物件(沒有run)。- 它的原型,也就是
Rabbit.prototype(有hide,但沒有run)。 - 它的原型,也就是(因為
extends)Animal.prototype,最後有run方法。
正如我們在章節原生原型中所提到的,JavaScript本身使用原型繼承來處理內建物件。例如,Date.prototype.[[Prototype]]是Object.prototype。這就是日期可以存取一般物件方法的原因。
extends之後,允許任何表達式類別語法允許在extends之後指定不只一個類別,而是任何表達式。
例如,呼叫函式來產生父類別
function f(phrase) {
return class {
sayHi() { alert(phrase); }
};
}
class User extends f("Hello") {}
new User().sayHi(); // Hello
這裡的class User繼承自f("Hello")的結果。
當我們使用函式來產生類別,依據許多條件,並從它們繼承時,這在進階程式設計模式中可能很有用。
覆寫方法
現在讓我們繼續覆寫方法。預設情況下,class Rabbit中未指定的所有方法都直接「照樣」從class Animal取得。
但是,如果我們在Rabbit中指定自己的方法,例如stop(),則會使用它來取代
class Rabbit extends Animal {
stop() {
// ...now this will be used for rabbit.stop()
// instead of stop() from class Animal
}
}
通常,我們並不想完全取代父方法,而是建立在其上以調整或擴展其功能。我們在方法中執行某些操作,但在方法之前/之後或過程中呼叫父方法。
類別提供 "super" 關鍵字來執行此操作。
super.method(...)用於呼叫父方法。super(...)用於呼叫父建構式(僅在我們的建構式內)。
例如,讓我們的兔子在停止時自動隱藏
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} runs with speed ${this.speed}.`);
}
stop() {
this.speed = 0;
alert(`${this.name} stands still.`);
}
}
class Rabbit extends Animal {
hide() {
alert(`${this.name} hides!`);
}
stop() {
super.stop(); // call parent stop
this.hide(); // and then hide
}
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.stop(); // White Rabbit stands still. White Rabbit hides!
現在 Rabbit 有一個 stop 方法,該方法在過程中呼叫父方法 super.stop()。
super如章節 重新檢視箭頭函式 中所述,箭頭函式沒有 super。
如果存取,則從外部函式取得。例如
class Rabbit extends Animal {
stop() {
setTimeout(() => super.stop(), 1000); // call parent stop after 1sec
}
}
箭頭函式中的 super 與 stop() 中的相同,因此可以按預期運作。如果我們在此處指定「常規」函式,則會出現錯誤
// Unexpected super
setTimeout(function() { super.stop() }, 1000);
覆寫建構式
使用建構式時會變得有點棘手。
到目前為止,Rabbit 沒有自己的 constructor。
根據 規範,如果類別延伸另一個類別且沒有 constructor,則會產生以下「空」constructor
class Rabbit extends Animal {
// generated for extending classes without own constructors
constructor(...args) {
super(...args);
}
}
如我們所見,它基本上會呼叫父 constructor 並傳遞所有引數。如果我們沒有撰寫自己的建構式,就會發生這種情況。
現在,讓我們為 Rabbit 新增一個自訂建構式。它將指定 earLength 以及 name
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
this.speed = 0;
this.name = name;
this.earLength = earLength;
}
// ...
}
// Doesn't work!
let rabbit = new Rabbit("White Rabbit", 10); // Error: this is not defined.
糟糕!我們遇到一個錯誤。現在我們無法建立兔子。出了什麼問題?
簡短的答案是
- 繼承類別中的建構式必須呼叫
super(...),而且(!)必須在使用this之前執行此操作。
…但為什麼?這裡發生了什麼事?的確,這個要求似乎很奇怪。
當然,有一個解釋。讓我們深入瞭解細節,這樣你就能真正理解正在發生的事情。
在 JavaScript 中,繼承類別的建構式函式(所謂的「衍生建構式」)與其他函式之間存在區別。衍生建構式有一個特殊的內部屬性 [[ConstructorKind]]:"derived"。這是一個特殊的內部標籤。
該標籤會影響其與 new 的行為。
- 當使用
new執行常規函式時,它會建立一個空物件並將其指定給this。 - 但是,當衍生建構式執行時,它不會執行此操作。它預期父建構式執行此工作。
因此,派生建構函式必須呼叫 super 來執行其父類 (基礎) 建構函式,否則不會建立 this 的物件。而且我們會收到錯誤。
要讓 Rabbit 建構函式運作,它需要在使用 this 之前呼叫 super(),如下所示
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
super(name);
this.earLength = earLength;
}
// ...
}
// now fine
let rabbit = new Rabbit("White Rabbit", 10);
alert(rabbit.name); // White Rabbit
alert(rabbit.earLength); // 10
覆寫類別欄位:一個棘手的注意事項
此注意事項假設您具備類別的特定經驗,可能是其他程式語言。
它提供對語言的更深入見解,也說明可能會造成錯誤的行為 (但並非經常如此)。
如果您發現難以理解,請繼續閱讀,然後稍後再回來閱讀。
我們不僅可以覆寫方法,還可以覆寫類別欄位。
不過,當我們在父類建構函式中存取覆寫的欄位時,會出現一個棘手的行為,與大多數其他程式語言大不相同。
考慮以下範例
class Animal {
name = 'animal';
constructor() {
alert(this.name); // (*)
}
}
class Rabbit extends Animal {
name = 'rabbit';
}
new Animal(); // animal
new Rabbit(); // animal
在此,類別 Rabbit 延伸 Animal,並以其自己的值覆寫 name 欄位。
Rabbit 中沒有自己的建構函式,因此會呼叫 Animal 建構函式。
有趣的是,在 new Animal() 和 new Rabbit() 這兩種情況下,第 (*) 行中的 alert 都顯示 animal。
換句話說,父類建構函式總是使用自己的欄位值,而不是覆寫的值。
這有什麼奇怪之處?
如果還不清楚,請與方法進行比較。
以下為相同的程式碼,但我們呼叫 this.showName() 方法,而不是 this.name 欄位
class Animal {
showName() { // instead of this.name = 'animal'
alert('animal');
}
constructor() {
this.showName(); // instead of alert(this.name);
}
}
class Rabbit extends Animal {
showName() {
alert('rabbit');
}
}
new Animal(); // animal
new Rabbit(); // rabbit
請注意:現在輸出不同。
這就是我們自然會預期的。當在派生類別中呼叫父類建構函式時,它會使用覆寫的方法。
…但對於類別欄位並非如此。如前所述,父類建構函式總是使用父類欄位。
為什麼會有差異?
嗯,原因是欄位初始化順序。類別欄位已初始化
- 在基礎類別 (未延伸任何內容) 的建構函式之前,
- 緊接在派生類別的
super()之後。
在我們的案例中,Rabbit 是派生類別。其中沒有 constructor()。如前所述,這與只有一個空建構函式 super(...args) 相同。
因此,new Rabbit() 會呼叫 super(),從而執行父建構函式,並且(根據衍生類別的規則)只有在之後才會初始化其類別欄位。在執行父建構函式時,尚未有 Rabbit 類別欄位,這就是為何會使用 Animal 欄位的原因。
欄位和方法之間的這個細微差異是 JavaScript 特有的。
幸運的是,只有在父建構函式中使用了覆寫的欄位時,此行為才會顯示出來。然後可能很難理解發生了什麼事,因此我們在此說明。
如果這成為一個問題,可以使用方法或 getter/setter 而不是欄位來解決。
Super:內部、[[HomeObject]]
如果您是第一次閱讀教學課程,可以跳過此部分。
這是關於繼承和 super 背後的內部機制。
讓我們深入了解 super。我們將在過程中看到一些有趣的事情。
首先要說的是,從我們到目前為止所學到的所有知識來看,super 根本不可能執行!
是的,的確,讓我們問問自己,它在技術上應該如何執行?當物件方法執行時,它會取得目前的物件作為 this。如果我們呼叫 super.method(),則引擎需要從目前物件的原型取得 method。但如何取得?
這項任務看似簡單,但並非如此。引擎知道目前的物件 this,因此它可以將父 method 取得為 this.__proto__.method。不幸的是,這種「天真的」解決方案無法執行。
讓我們示範這個問題。不使用類別,為了簡單起見,使用一般物件。
如果您不想知道詳細資訊,可以跳過此部分,然後前往 [[HomeObject]] 小節。這不會造成任何傷害。如果您有興趣深入了解事物,請繼續閱讀。
在以下範例中,rabbit.__proto__ = animal。現在讓我們嘗試:在 rabbit.eat() 中,我們將使用 this.__proto__ 呼叫 animal.eat()
let animal = {
name: "Animal",
eat() {
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
name: "Rabbit",
eat() {
// that's how super.eat() could presumably work
this.__proto__.eat.call(this); // (*)
}
};
rabbit.eat(); // Rabbit eats.
在行 (*) 中,我們從原型(animal)取得 eat,並在目前物件的內容中呼叫它。請注意,這裡的 .call(this) 很重要,因為簡單的 this.__proto__.eat() 會在原型的內容中執行父 eat,而不是目前的物件。
在上述程式碼中,它實際上按預期執行:我們有正確的 alert。
現在讓我們在鏈中新增另一個物件。我們將看到事情如何中斷
let animal = {
name: "Animal",
eat() {
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
eat() {
// ...bounce around rabbit-style and call parent (animal) method
this.__proto__.eat.call(this); // (*)
}
};
let longEar = {
__proto__: rabbit,
eat() {
// ...do something with long ears and call parent (rabbit) method
this.__proto__.eat.call(this); // (**)
}
};
longEar.eat(); // Error: Maximum call stack size exceeded
程式碼不再執行!我們可以看到嘗試呼叫 longEar.eat() 時發生的錯誤。
這可能不太明顯,但如果我們追蹤 longEar.eat() 呼叫,我們就可以看到原因。在行 (*) 和 (**) 中,this 的值都是目前的物件(longEar)。這很重要:所有物件方法都會取得目前的物件作為 this,而不是原型或其他東西。
因此,在 (*) 和 (**) 兩行中,this.__proto__ 的值完全相同:rabbit。它們都呼叫 rabbit.eat,而不會在無窮迴圈中向上移動。
以下是發生情況的圖片
-
在
longEar.eat()內部,第(**)行呼叫rabbit.eat,並提供this=longEar。// inside longEar.eat() we have this = longEar this.__proto__.eat.call(this) // (**) // becomes longEar.__proto__.eat.call(this) // that is rabbit.eat.call(this); -
然後在
rabbit.eat的第(*)行中,我們希望將呼叫傳遞到鏈中的更高層級,但this=longEar,因此this.__proto__.eat再次為rabbit.eat!// inside rabbit.eat() we also have this = longEar this.__proto__.eat.call(this) // (*) // becomes longEar.__proto__.eat.call(this) // or (again) rabbit.eat.call(this); -
…因此
rabbit.eat在無窮迴圈中呼叫自身,因為它無法再向上移動。
僅使用 this 無法解決問題。
[[HomeObject]]
為了提供解決方案,JavaScript 為函式新增一個特殊的內部屬性:[[HomeObject]]。
當函式指定為類別或物件方法時,其 [[HomeObject]] 屬性會變成該物件。
然後 super 使用它來解析父原型及其方法。
讓我們看看它是如何運作的,首先從純粹的物件開始
let animal = {
name: "Animal",
eat() { // animal.eat.[[HomeObject]] == animal
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
name: "Rabbit",
eat() { // rabbit.eat.[[HomeObject]] == rabbit
super.eat();
}
};
let longEar = {
__proto__: rabbit,
name: "Long Ear",
eat() { // longEar.eat.[[HomeObject]] == longEar
super.eat();
}
};
// works correctly
longEar.eat(); // Long Ear eats.
由於 [[HomeObject]] 機制,它按預期運作。方法(例如 longEar.eat)知道其 [[HomeObject]],並從其原型取得父方法。無需使用 this。
方法並非「自由」
正如我們之前所知,函式通常是「自由」的,在 JavaScript 中不受物件約束。因此,它們可以在物件之間複製,並使用其他 this 呼叫。
[[HomeObject]] 的存在違反了該原則,因為方法會記住其物件。[[HomeObject]] 無法變更,因此此連結是永久性的。
語言中唯一使用 [[HomeObject]] 的地方是 super。因此,如果方法不使用 super,則我們仍可以將其視為自由的,並在物件之間複製。但使用 super 時可能會出錯。
以下是複製後錯誤的 super 結果示範
let animal = {
sayHi() {
alert(`I'm an animal`);
}
};
// rabbit inherits from animal
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
alert("I'm a plant");
}
};
// tree inherits from plant
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // I'm an animal (?!?)
呼叫 tree.sayHi() 會顯示「我是動物」。這絕對是錯的。
原因很簡單
- 在第
(*)行中,方法tree.sayHi從rabbit複製。也許我們只是想避免重複程式碼? - 它的
[[HomeObject]]是rabbit,因為它是在rabbit中建立的。無法變更[[HomeObject]]。 tree.sayHi()的程式碼內部有super.sayHi()。它從rabbit向上移動,並從animal取得方法。
以下是發生情況的圖表
方法,而非函式屬性
[[HomeObject]] 定義為類別和純粹物件中的方法。但對於物件,方法必須明確指定為 method(),而不是 "method: function()"。
對我們來說,差異可能不重要,但對 JavaScript 而言很重要。
以下範例使用非方法語法進行比較。[[HomeObject]] 屬性未設定,且繼承不起作用
let animal = {
eat: function() { // intentionally writing like this instead of eat() {...
// ...
}
};
let rabbit = {
__proto__: animal,
eat: function() {
super.eat();
}
};
rabbit.eat(); // Error calling super (because there's no [[HomeObject]])
摘要
- 要延伸類別:
class Child extends Parent- 這表示
Child.prototype.__proto__將會是Parent.prototype,因此方法會被繼承。
- 這表示
- 覆寫建構函式時
- 我們必須在使用
this之前,在Child建構函式中呼叫父建構函式為super()。
- 我們必須在使用
- 覆寫其他方法時
- 我們可以在
Child方法中使用super.method()來呼叫Parent方法。
- 我們可以在
- 內部
- 方法會在內部
[[HomeObject]]屬性中記住它們的類別/物件。這就是super解析父方法的方式。 - 因此,使用
super從一個物件複製方法到另一個物件是不安全的。
- 方法會在內部
此外
- 箭頭函式沒有自己的
this或super,因此它們會透明地融入周圍的內容。
留言
<code>標籤,若要插入多行程式碼,請將它們包在<pre>標籤中,若要插入超過 10 行的程式碼,請使用沙盒(plnkr、jsbin、codepen…)