類繼承是壹個類擴展另壹個類的壹種方式。
因此,我們可以在現有功能之上創建新功能。
假設我們有 class 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
和 class Animal
的圖形化表示:
……然後我們想創建另壹個 class Rabbit
:
因爲 rabbit 是 animal,所以 class Rabbit
應該是基于 class Animal
的,可以訪問 animal 的方法,以便 rabbit 可以做“壹般”動物可以做的事兒。
擴展另壹個類的語法是: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!
class Rabbit
的對象可以訪問例如 rabbit.hide()
等 Rabbit
的方法,還可以訪問例如 rabbit.run()
等 Animal
的方法。
在內部,關鍵字 extends
使用了很好的舊的原型機制進行工作。它將 Rabbit.prototype.[[Prototype]]
設置爲 Animal.prototype
。所以,如果在 Rabbit.prototype
中找不到壹個方法,JavaScript 就會從 Animal.prototype
中獲取該方法。
例如,要查找 rabbit.run
方法,JavaScript 引擎會進行如下檢查(如圖所示從下到上):
查找對象 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() { // ……現在這個將會被用作 rabbit.stop() // 而不是來自于 class Animal 的 stop() } }
然而通常,我們不希望完全替換父類的方法,而是希望在父類方法的基礎上進行調整或擴展其功能。我們在我們的方法中做壹些事兒,但是在它之前或之後或在過程中會調用父類方法。
Class 爲此提供了 "super"
關鍵字。
執行 super.method(...)
來調用壹個父類方法。
執行 super(...)
來調用壹個父類 constructor(只能在我們的 constructor 中)。
例如,讓我們的 rabbit 在停下來的時候自動 hide:
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(); // 調用父類的 stop this.hide(); // 然後 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
在執行過程中調用父類的 super.stop()
方法,所以 Rabbit
也具有了 stop
方法。
箭頭函數沒有 super
正如我們在 深入理解箭頭函數 壹章中所提到的,箭頭函數沒有 super
。
如果被訪問,它會從外部函數獲取。例如:
class Rabbit extends Animal { stop() { setTimeout(() => super.stop(), 1000); // 1 秒後調用父類的 stop } }
箭頭函數中的 super
與 stop()
中的是壹樣的,所以它能按預期工作。如果我們在這裏指定壹個“普通”函數,那麽將會抛出錯誤:
// 意料之外的 super setTimeout(function() { super.stop() }, 1000);
對于重寫 constructor 來說,則有點棘手。
到目前爲止,Rabbit
還沒有自己的 constructor
。
根據 規範,如果壹個類擴展了另壹個類並且沒有 constructor
,那麽將生成下面這樣的“空” constructor
:
class Rabbit extends Animal { // 爲沒有自己的 constructor 的擴展類生成的 constructor(...args) { super(...args); } }
正如我們所看到的,它調用了父類的 constructor
,並傳遞了所有的參數。如果我們沒有寫自己的 constructor,就會出現這種情況。
現在,我們給 Rabbit
添加壹個自定義的 constructor。除了 name
之外,它還會指定 earLength
。
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; } // ... } // 不工作! let rabbit = new Rabbit("White Rabbit", 10); // Error: this is not defined.
哎呦!我們得到了壹個報錯。現在我們沒法新建 rabbit。是什麽地方出錯了?
簡短的解釋是:
繼承類的 constructor 必須調用 super(...)
,並且 (!) 壹定要在使用 this
之前調用。
……但這是爲什麽呢?這裏發生了什麽?確實,這個要求看起來很奇怪。
當然,本文會給出壹個解釋。讓我們深入細節,這樣妳就可以真正地理解發生了什麽。
在 JavaScript 中,繼承類(所謂的“派生構造器”,英文爲 “derived constructor”)的構造函數與其他函數之間是有區別的。派生構造器具有特殊的內部屬性 [[ConstructorKind]]:"derived"
。這是壹個特殊的內部標簽。
該標簽會影響它的 new
行爲:
當通過 new
執行壹個常規函數時,它將創建壹個空對象,並將這個空對象賦值給 this
。
但是當繼承的 constructor 執行時,它不會執行此操作。它期望父類的 constructor 來完成這項工作。
因此,派生的 constructor 必須調用 super
才能執行其父類(base)的 constructor,否則 this
指向的那個對象將不會被創建。並且我們會收到壹個報錯。
爲了讓 Rabbit
的 constructor 可以工作,它需要在使用 this
之前調用 super()
,就像下面這樣:
class Animal { constructor(name) { this.speed = 0; this.name = name; } // ... } class Rabbit extends Animal { constructor(name, earLength) { super(name); this.earLength = earLength; } // ... } // 現在可以了 let rabbit = new Rabbit("White Rabbit", 10); alert(rabbit.name); // White Rabbit alert(rabbit.earLength); // 10
高階要點
這個要點假設妳對類已經有了壹定的經驗,或許是在其他編程語言中。
這裏提供了壹個更好的視角來窺探這門語言,且解釋了它的行爲爲什麽可能會是 bugs 的來源(但不是非常頻繁)。
如果妳發現這難以理解,什麽都別管,繼續往下閱讀,之後有機會再回來看。
我們不僅可以重寫方法,還可以重寫類字段。
不過,當我們在父類構造器中訪問壹個被重寫的字段時,有壹個詭異的行爲,這與絕大多數其他編程語言都很不壹樣。
請思考此示例:
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() { // 而不是 this.name = 'animal' alert('animal'); } constructor() { this.showName(); // 而不是 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
背後的內部機制。
讓我們更深入地研究 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() { // 這就是 super.eat() 可以大概工作的方式 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
。
// 在 longEar.eat() 中我們有 this = longEar this.__proto__.eat.call(this) // (**) // 變成了 longEar.__proto__.eat.call(this) // 也就是 rabbit.eat.call(this);
之後在 rabbit.eat
的 (*)
行中,我們希望將函數調用在原型鏈上向更高層傳遞,但是 this=longEar
,所以 this.__proto__.eat
又是 rabbit.eat
!
// 在 rabbit.eat() 中我們依然有 this = longEar this.__proto__.eat.call(this) // (*) // 變成了 longEar.__proto__.eat.call(this) // 或(再壹次) rabbit.eat.call(this);
……所以 rabbit.eat
在不停地循環調用自己,因此它無法進壹步地提升。
這個問題沒法僅僅通過使用 this
來解決。
[[HomeObject]]
爲了提供解決方法,JavaScript 爲函數添加了壹個特殊的內部屬性:[[HomeObject]]
。
當壹個函數被定義爲類或者對象方法時,它的 [[HomeObject]]
屬性就成爲了該對象。
然後 super
使用它來解析(resolve)父原型及其方法。
讓我們看看它是怎麽工作的,首先,對于普通對象:
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(); } }; // 正確執行 longEar.eat(); // Long Ear eats.
它基于 [[HomeObject]]
運行機制按照預期執行。壹個方法,例如 longEar.eat
,知道其 [[HomeObject]]
並且從其原型中獲取父方法。並沒有使用 this
。
正如我們之前所知道的,函數通常都是“自由”的,並沒有綁定到 JavaScript 中的對象。正因如此,它們可以在對象之間複制,並用另外壹個 this
調用它。
[[HomeObject]]
的存在違反了這個原則,因爲方法記住了它們的對象。[[HomeObject]]
不能被更改,所以這個綁定是永久的。
在 JavaScript 語言中 [[HomeObject]]
僅被用于 super
。所以,如果壹個方法不使用 super
,那麽我們仍然可以視它爲自由的並且可在對象之間複制。但是用了 super
再這樣做可能就會出錯。
下面是複制後錯誤的 super
結果的示例:
let animal = { sayHi() { alert(`I'm an animal`); } }; // rabbit 繼承自 animal let rabbit = { __proto__: animal, sayHi() { super.sayHi(); } }; let plant = { sayHi() { alert("I'm a plant"); } }; // tree 繼承自 plant let tree = { __proto__: plant, sayHi: rabbit.sayHi // (*) }; tree.sayHi(); // I'm an animal (?!?)
調用 tree.sayHi()
顯示 “I’m an animal”。這絕對是錯誤的。
原因很簡單:
在 (*)
行,tree.sayHi
方法是從 rabbit
複制而來。也許我們只是想避免重複代碼?
它的 [[HomeObject]]
是 rabbit
,因爲它是在 rabbit
中創建的。沒有辦法修改 [[HomeObject]]
。
tree.sayHi()
內具有 super.sayHi()
。它從 rabbit
中上溯,然後從 animal
中獲取方法。
這是發生的情況示意圖:
[[HomeObject]]
是爲類和普通對象中的方法定義的。但是對于對象而言,方法必須確切指定爲 method()
,而不是 "method: function()"
。
這個差別對我們來說可能不重要,但是對 JavaScript 來說卻非常重要。
在下面的例子中,使用非方法(non-method)語法進行了比較。未設置 [[HomeObject]]
屬性,並且繼承無效:
let animal = { eat: function() { // 這裏是故意這樣寫的,而不是 eat() {... // ... } }; let rabbit = { __proto__: animal, eat: function() { super.eat(); } }; rabbit.eat(); // 錯誤調用 super(因爲這裏沒有 [[HomeObject]])
想要擴展壹個類:class Child extends Parent
:
這意味著 Child.prototype.__proto__
將是 Parent.prototype
,所以方法會被繼承。
重寫壹個 constructor:
在使用 this
之前,我們必須在 Child
的 constructor 中將父 constructor 調用爲 super()
。
重寫壹個方法:
我們可以在壹個 Child
方法中使用 super.method()
來調用 Parent
方法。
內部:
方法在內部的 [[HomeObject]]
屬性中記住了它們的類/對象。這就是 super
如何解析父方法的。
因此,將壹個帶有 super
的方法從壹個對象複制到另壹個對象是不安全的。
補充:
箭頭函數沒有自己的 this
或 super
,所以它們能融入到就近的上下文中,像透明似的。
重要程度: 5
這裏有壹份 Rabbit
擴展 Animal
的代碼。
不幸的是,Rabbit
對象無法被創建。是哪裏出錯了呢?請解決它。
class Animal { constructor(name) { this.name = name; } } class Rabbit extends Animal { constructor(name) { this.name = name; this.created = Date.now(); } } let rabbit = new Rabbit("White Rabbit"); // Error: this is not defined alert(rabbit.name);
這是因爲子類的 constructor 必須調用 super()
。
這裏是修正後的代碼:
class Animal { constructor(name) { this.name = name; } } class Rabbit extends Animal { constructor(name) { super(name); this.created = Date.now(); } } let rabbit = new Rabbit("White Rabbit"); // 現在好了 alert(rabbit.name); // White Rabbit
重要程度: 5
我們獲得了壹個 Clock
類。到目前爲止,它每秒都會打印壹次時間。
class Clock { constructor({ template }) { this.template = template; } render() { let date = new Date(); let hours = date.getHours(); if (hours < 10) hours = '0' + hours; let mins = date.getMinutes(); if (mins < 10) mins = '0' + mins; let secs = date.getSeconds(); if (secs < 10) secs = '0' + secs; let output = this.template .replace('h', hours) .replace('m', mins) .replace('s', secs); console.log(output); } stop() { clearInterval(this.timer); } start() { this.render(); this.timer = setInterval(() => this.render(), 1000); } }
創建壹個繼承自 Clock
的新的類 ExtendedClock
,並添加參數 precision
— 每次 “ticks” 之間間隔的毫秒數,默認是 1000
(1 秒)。
妳的代碼應該在 extended-clock.js
文件裏。
不要修改原有的 clock.js
。請擴展它。
打開壹個任務沙箱。
class ExtendedClock extends Clock { constructor(options) { super(options); let { precision = 1000 } = options; this.precision = precision; } start() { this.render(); this.timer = setInterval(() => this.render(), this.precision); } };
使用沙箱打開解決方案。