類別繼承是一種讓一個類別延伸另一個類別的方式。
因此,我們可以在現有功能的基礎上建立新的功能。
「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…)