2022 年 5 月 12 日

類別繼承

類別繼承是一種讓一個類別延伸另一個類別的方式。

因此,我們可以在現有功能的基礎上建立新的功能。

「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

讓我們建立繼承自Animalclass 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方法,引擎會檢查(在圖片中由下往上)

  1. rabbit物件(沒有run)。
  2. 它的原型,也就是Rabbit.prototype(有hide,但沒有run)。
  3. 它的原型,也就是(因為extendsAnimal.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
  }
}

箭頭函式中的 superstop() 中的相同,因此可以按預期運作。如果我們在此處指定「常規」函式,則會出現錯誤

// 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,而不會在無窮迴圈中向上移動。

以下是發生情況的圖片

  1. 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);
  2. 然後在 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);
  3. …因此 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.sayHirabbit 複製。也許我們只是想避免重複程式碼?
  • 它的 [[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]])

摘要

  1. 要延伸類別:class Child extends Parent
    • 這表示 Child.prototype.__proto__ 將會是 Parent.prototype,因此方法會被繼承。
  2. 覆寫建構函式時
    • 我們必須在使用 this 之前,在 Child 建構函式中呼叫父建構函式為 super()
  3. 覆寫其他方法時
    • 我們可以在 Child 方法中使用 super.method() 來呼叫 Parent 方法。
  4. 內部
    • 方法會在內部 [[HomeObject]] 屬性中記住它們的類別/物件。這就是 super 解析父方法的方式。
    • 因此,使用 super 從一個物件複製方法到另一個物件是不安全的。

此外

  • 箭頭函式沒有自己的 thissuper,因此它們會透明地融入周圍的內容。

作業

重要性: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);

這是因為子建構函式必須呼叫 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"); // ok now
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);
  }
}

建立一個新的類別 ExtendedClock,它會繼承 Clock 並新增參數 precision,也就是「滴答」之間的毫秒數。預設應為 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);
  }
};

在沙盒中開啟解答。

教學課程地圖

留言

留言前請先閱讀…
  • 如果你有改善建議,請提交 GitHub 問題或提出拉取請求,而不是留言。
  • 如果你無法理解文章中的某個部分,請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行程式碼,請將它們包在 <pre> 標籤中,若要插入超過 10 行的程式碼,請使用沙盒(plnkrjsbincodepen…)