JavaScript 給急躁的程式設計師(ES2021 版)
請支持這本書:購買捐款
(廣告,請不要封鎖。)

29 原型鏈和類別



在這本書中,JavaScript 的物件導向程式(OOP)風格分為四個步驟介紹。本章涵蓋步驟 2-4,上一章涵蓋步驟 1。步驟如下(圖 9

  1. 單一物件(前一章):物件是 JavaScript 基本的 OOP 建構區塊,它們在孤立狀態下如何運作?
  2. 原型鏈(本章):每個物件都有一個由零個或多個原型物件組成的鏈。原型是 JavaScript 的核心繼承機制。
  3. 類別(本章):JavaScript 的類別是物件的工廠。類別與其實例之間的關係是基於原型繼承。
  4. 子類化(本章):子類別與其超類別之間的關係也是基於原型繼承。
Figure 9: This book introduces object-oriented programming in JavaScript in four steps.

29.1 原型鏈

原型是 JavaScript 唯一的繼承機制:每個物件都有原型,原型可能是 null 或物件。在後者的情況下,物件會繼承原型中的所有屬性。

在物件文字中,你可以透過特殊屬性 __proto__ 來設定原型

const proto = {
  protoProp: 'a',
};
const obj = {
  __proto__: proto,
  objProp: 'b',
};

// obj inherits .protoProp:
assert.equal(obj.protoProp, 'a');
assert.equal('protoProp' in obj, true);

由於原型物件本身也可以有原型,因此我們會得到一個物件鏈,也就是所謂的原型鏈。這表示繼承會讓我們覺得自己是在處理單一物件,但實際上我們處理的是物件鏈。

圖 10 顯示 obj 的原型鏈看起來像什麼。

Figure 10: obj starts a chain of objects that continues with proto and other objects.

未繼承的屬性稱為自有屬性obj 有自有屬性 .objProp

29.1.1 JavaScript 的運算:所有屬性 vs. 自有屬性

有些運算會考慮所有屬性(自有和繼承的),例如取得屬性

> const obj = { foo: 1 };
> typeof obj.foo // own
'number'
> typeof obj.toString // inherited
'function'

其他運算只會考慮自有屬性,例如 Object.keys()

> Object.keys(obj)
[ 'foo' ]

繼續閱讀,了解另一個只會考慮自有屬性的運算:設定屬性。

29.1.2 陷阱:只會變異原型鏈的第一個成員

原型鏈的一個可能違反直覺的面向是,透過物件設定任何屬性(甚至是繼承的屬性)只會變更該物件本身,而不會變更任何原型。

考慮下列物件 obj

const proto = {
  protoProp: 'a',
};
const obj = {
  __proto__: proto,
  objProp: 'b',
};

在下一段程式碼中,我們設定繼承的屬性 obj.protoProp(A 行)。它會透過建立一個自有屬性來「變更」它:讀取 obj.protoProp 時,會先找到自有屬性,而其值會覆寫繼承屬性的值。

// In the beginning, obj has one own property
assert.deepEqual(Object.keys(obj), ['objProp']);

obj.protoProp = 'x'; // (A)

// We created a new own property:
assert.deepEqual(Object.keys(obj), ['objProp', 'protoProp']);

// The inherited property itself is unchanged:
assert.equal(proto.protoProp, 'a');

// The own property overrides the inherited property:
assert.equal(obj.protoProp, 'x');

obj 的原型鏈如圖 11 所示。

Figure 11: The own property .protoProp of obj overrides the property inherited from proto.

29.1.3 使用原型的提示(進階)

29.1.3.1 最佳實務:避免使用 __proto__,除非在物件文字中

我建議避免使用偽屬性 __proto__:正如我們將在稍後看到的,並非所有物件都有它。

不過,物件文字中的 __proto__ 不同。在那裡,它是一個內建功能,而且總是可用的。

取得和設定原型的建議方法是

以下是這些功能的使用方式

const proto1 = {};
const proto2 = {};

const obj = Object.create(proto1);
assert.equal(Object.getPrototypeOf(obj), proto1);

Object.setPrototypeOf(obj, proto2);
assert.equal(Object.getPrototypeOf(obj), proto2);
29.1.3.2 檢查:一個物件是否為另一個物件的原型?

到目前為止,「po 的原型」總是表示「po直接原型」。但它也可以更廣泛地使用,表示 po 的原型鏈中。這種較鬆散的關係可以透過

p.isPrototypeOf(o)

例如

const a = {};
const b = {__proto__: a};
const c = {__proto__: b};

assert.equal(a.isPrototypeOf(b), true);
assert.equal(a.isPrototypeOf(c), true);

assert.equal(a.isPrototypeOf(a), false);
assert.equal(c.isPrototypeOf(a), false);

29.1.4 透過原型共用資料

考慮以下程式碼

const jane = {
  name: 'Jane',
  describe() {
    return 'Person named '+this.name;
  },
};
const tarzan = {
  name: 'Tarzan',
  describe() {
    return 'Person named '+this.name;
  },
};

assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan');

我們有兩個非常相似的物件。兩個物件都有兩個屬性,名稱分別為 .name.describe。此外,方法 .describe() 是相同的。我們如何避免重複這個方法?

我們可以將它移到一個物件 PersonProto,並讓那個物件成為 janetarzan 的原型

const PersonProto = {
  describe() {
    return 'Person named ' + this.name;
  },
};
const jane = {
  __proto__: PersonProto,
  name: 'Jane',
};
const tarzan = {
  __proto__: PersonProto,
  name: 'Tarzan',
};

原型的名稱反映出 janetarzan 都是人。

Figure 12: Objects jane and tarzan share method .describe(), via their common prototype PersonProto.

圖 12 說明了這三個物件是如何連接的:底部的物件現在包含特定於 janetarzan 的屬性。頂部的物件包含它們之間共用的屬性。

當你呼叫方法 jane.describe() 時,this 指向那個方法呼叫的接收者 jane(在圖表的左下角)。這就是為什麼這個方法仍然有效。tarzan.describe() 的運作方式類似。

assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan');

29.2 類別

我們現在準備好來處理類別,它基本上是設定原型鏈的簡潔語法。在底層,JavaScript 的類別是不尋常的。但這是你在使用它們時很少會看到的東西。對於使用過其他物件導向程式語言的人來說,它們通常應該會感到熟悉。

29.2.1 一個人的類別

我們之前使用過 janetarzan,它們是代表人的單一物件。讓我們使用類別宣告來實作一個人的物件工廠

class Person {
  constructor(name) {
    this.name = name;
  }
  describe() {
    return 'Person named '+this.name;
  }
}

janetarzan 現在可以透過 new Person() 來建立

const jane = new Person('Jane');
assert.equal(jane.name, 'Jane');
assert.equal(jane.describe(), 'Person named Jane');

const tarzan = new Person('Tarzan');
assert.equal(tarzan.name, 'Tarzan');
assert.equal(tarzan.describe(), 'Person named Tarzan');

類別 Person 有兩個方法

29.2.1.1 類別表達式

有兩種類別定義(定義類別的方法)

類別表達式可以是匿名的或命名的

// Anonymous class expression
const Person = class { ··· };

// Named class expression
const Person = class MyClass { ··· };

命名類別表達式的名稱作用類似於 命名函式表達式的名稱

這是類別的初探。我們很快會探索更多功能,但首先我們需要了解類別的內部結構。

29.2.2 類別的內部結構

類別的內部結構有很多東西。讓我們看看 jane 的圖示(圖 13)。

Figure 13: The class Person has the property .prototype that points to an object that is the prototype of all instances of Person. jane is one such instance.

類別 Person 的主要目的是設定右邊的原型鏈(jane,接著是 Person.prototype)。值得注意的是,類別 Person 內部的兩個建構(.constructor.describe())都為 Person.prototype 建立了屬性,而不是 Person

這種有點奇怪的方法是因為向後相容性:在類別之前,建構函式一般函式,透過 new 營運子呼叫)通常用作物件的工廠。類別大多是建構函式更好的語法,因此與舊程式碼保持相容。這說明了為什麼類別是函式

> typeof Person
'function'

在這本書中,我互換使用術語建構函式(函式)類別

很容易將 .__proto__.prototype 混淆。希望圖 13 能清楚說明它們的差異

29.2.2.1 Person.prototype.constructor(進階)

圖 13 中有一個細節我們還沒看過:Person.prototype.constructor 指回 Person

> Person.prototype.constructor === Person
true

這種設定也是為了向後相容性而存在。但它還有兩個額外的好處。

首先,一個類別的每個實例都會繼承屬性 .constructor。因此,給定一個實例,你可以使用它來建立「類似的」物件

const jane = new Person('Jane');

const cheeta = new jane.constructor('Cheeta');
// cheeta is also an instance of Person
// (the instanceof operator is explained later)
assert.equal(cheeta instanceof Person, true);

其次,你可以取得建立給定實例的類別名稱

const tarzan = new Person('Tarzan');

assert.equal(tarzan.constructor.name, 'Person');

29.2.3 類別定義:原型屬性

以下類別宣告主體中的所有建構都會建立 Foo.prototype 的屬性。

class Foo {
  constructor(prop) {
    this.prop = prop;
  }
  protoMethod() {
    return 'protoMethod';
  }
  get protoGetter() {
    return 'protoGetter';
  }
}

讓我們依序檢視它們

以下互動使用類別 Foo

> const foo = new Foo(123);
> foo.prop
123

> foo.protoMethod()
'protoMethod'
> foo.protoGetter
'protoGetter'

29.2.4 類別定義:靜態屬性

以下類別宣告主體中的所有建構都會建立所謂的靜態屬性,也就是 Bar 本身的屬性。

class Bar {
  static staticMethod() {
    return 'staticMethod';
  }
  static get staticGetter() {
    return 'staticGetter';
  }
}

靜態方法和靜態 getter 的使用方式如下

> Bar.staticMethod()
'staticMethod'
> Bar.staticGetter
'staticGetter'

29.2.5 instanceof 運算子

instanceof 運算子會告訴你一個值是不是給定類別的實例

> new Person('Jane') instanceof Person
true
> ({}) instanceof Person
false
> ({}) instanceof Object
true
> [] instanceof Array
true

我們會在探討完子類別後,在稍後更詳細地探討 instanceof 運算子。

29.2.6 我推薦類別的原因

我推薦使用類別的原因如下

這並不表示類別是完美的

  練習:撰寫類別

exercises/proto-chains-classes/point_class_test.mjs

29.3 類別的私有資料

本節說明如何將物件的某些資料對外隱藏。我們在類別的脈絡中討論這些技術,但它們也適用於直接建立的物件,例如透過物件文字。

29.3.1 私有資料:命名慣例

第一種技術是透過在屬性的名稱前面加上底線來讓屬性變為私有。這並不會以任何方式保護屬性;它只是對外傳達:「你不必知道這個屬性。」

在以下程式碼中,屬性 ._counter._action 是私有的。

class Countdown {
  constructor(counter, action) {
    this._counter = counter;
    this._action = action;
  }
  dec() {
    this._counter--;
    if (this._counter === 0) {
      this._action();
    }
  }
}

// The two properties aren’t really private:
assert.deepEqual(
  Object.keys(new Countdown()),
  ['_counter', '_action']);

使用這種技術,你不會獲得任何保護,而且私有名稱可能會衝突。好處是它很容易使用。

29.3.2 私有資料:弱映射

另一種技術是使用弱映射。確切的運作方式已在 弱映射章節 中說明。以下是預覽

const _counter = new WeakMap();
const _action = new WeakMap();

class Countdown {
  constructor(counter, action) {
    _counter.set(this, counter);
    _action.set(this, action);
  }
  dec() {
    let counter = _counter.get(this);
    counter--;
    _counter.set(this, counter);
    if (counter === 0) {
      _action.get(this)();
    }
  }
}

// The two pseudo-properties are truly private:
assert.deepEqual(
  Object.keys(new Countdown()),
  []);

這種技術能為你提供相當程度的防護,以避免外部存取,而且不會發生名稱衝突。但它也比較複雜。

29.3.3 更多私有資料技術

本書說明了類別中私有資料最重要的技術。它可能也即將內建支援。請參閱 ECMAScript 提案 “類別公用執行個體欄位和私有執行個體欄位” 以取得詳細資訊。

探索 ES6 中說明了其他一些技術。

29.4 子類化

類別也可以子類化(「延伸」)現有的類別。以下的 Employee 類別就是 Person 的子類別,作為範例

class Person {
  constructor(name) {
    this.name = name;
  }
  describe() {
    return `Person named ${this.name}`;
  }
  static logNames(persons) {
    for (const person of persons) {
      console.log(person.name);
    }
  }
}

class Employee extends Person {
  constructor(name, title) {
    super(name);
    this.title = title;
  }
  describe() {
    return super.describe() +
      ` (${this.title})`;
  }
}

const jane = new Employee('Jane', 'CTO');
assert.equal(
  jane.describe(),
  'Person named Jane (CTO)');

兩個註解

  練習:子類別化

exercises/proto-chains-classes/color_point_class_test.mjs

29.4.1 底層的子類別(進階)

Figure 14: These are the objects that make up class Person and its subclass, Employee. The left column is about classes. The right column is about the Employee instance jane and its prototype chain.

前一節的類別 PersonEmployee 由幾個物件組成(圖 14)。了解這些物件如何關聯的一個關鍵見解是,有兩個原型鏈

29.4.1.1 實例原型鏈(右欄)

實例原型鏈從 jane 開始,並繼續使用 Employee.prototypePerson.prototype。原則上,原型鏈在這裡結束,但我們得到另一個物件:Object.prototype。此原型幾乎為所有物件提供服務,這就是為什麼它也包含在這裡

> Object.getPrototypeOf(Person.prototype) === Object.prototype
true
29.4.1.2 類別原型鏈(左欄)

在類別原型鏈中,Employee 排在第一位,Person 排在第二位。之後,鏈繼續使用 Function.prototype,它只存在於此,因為 Person 是函式,而函式需要 Function.prototype 的服務。

> Object.getPrototypeOf(Person) === Function.prototype
true

29.4.2 更詳細的 instanceof(進階)

我們尚未了解 instanceof 的實際運作方式。給定表達式

x instanceof C

instanceof 如何判斷 x 是否是 C(或 C 的子類別)的實例?它透過檢查 C.prototype 是否在 x 的原型鏈中來執行此操作。也就是說,以下表達式是等效的

C.prototype.isPrototypeOf(x)

如果我們回到圖 14,我們可以確認原型鏈確實引導我們得出以下正確答案

> jane instanceof Employee
true
> jane instanceof Person
true
> jane instanceof Object
true

29.4.3 內建物件的原型鏈(進階)

接下來,我們將利用對子類別化的了解來理解幾個內建物件的原型鏈。以下工具函式 p() 可協助我們進行探索。

const p = Object.getPrototypeOf.bind(Object);

我們萃取了 Object 的方法 .getPrototypeOf(),並將其指定給 p

29.4.3.1 {} 的原型鏈

讓我們從檢查一般物件開始

> p({}) === Object.prototype
true
> p(p({})) === null
true
Figure 15: The prototype chain of an object created via an object literal starts with that object, continues with Object.prototype, and ends with null.

圖 15 顯示此原型鏈的圖表。我們可以看到 {} 確實是 Object 的實例,因為 Object.prototype 在其原型鏈中。

29.4.3.2 [] 的原型鏈

陣列的原型鏈是什麼樣子?

> p([]) === Array.prototype
true
> p(p([])) === Object.prototype
true
> p(p(p([]))) === null
true
Figure 16: The prototype chain of an Array has these members: the Array instance, Array.prototype, Object.prototype, null.

此原型鏈(在圖 16 中視覺化)告訴我們,陣列物件是 Array 的實例,而 ArrayObject 的子類別。

29.4.3.3 function () {} 的原型鏈

最後,一般函式的原型鏈告訴我們,所有函式都是物件

> p(function () {}) === Function.prototype
true
> p(p(function () {})) === Object.prototype
true
29.4.3.4 不是 Object 實例的物件

只有當 Object.prototype 在其原型鏈中時,物件才是 Object 的實例。透過各種文字建立的大多數物件都是 Object 的實例

> ({}) instanceof Object
true
> (() => {}) instanceof Object
true
> /abc/ug instanceof Object
true

沒有原型的物件不是 Object 的實例

> ({ __proto__: null }) instanceof Object
false

Object.prototype 結束大多數原型鏈。它的原型是 null,這表示它也不是 Object 的實例

> Object.prototype instanceof Object
false
29.4.3.5 偽屬性 .__proto__ 究竟如何運作?

偽屬性 .__proto__ 是由類別 Object 透過 getter 和 setter 實作。它可以像這樣實作

class Object {
  get __proto__() {
    return Object.getPrototypeOf(this);
  }
  set __proto__(other) {
    Object.setPrototypeOf(this, other);
  }
  // ···
}

這表示您可以透過建立沒有 Object.prototype 在其原型鏈中的物件來關閉 .__proto__(請參閱前一節)

> '__proto__' in {}
true
> '__proto__' in { __proto__: null }
false

29.4.4 派送與直接方法呼叫(進階)

讓我們檢查方法呼叫如何與類別一起運作。我們重新檢視早先的 jane

class Person {
  constructor(name) {
    this.name = name;
  }
  describe() {
    return 'Person named '+this.name;
  }
}
const jane = new Person('Jane');

圖 17jane 的原型鏈圖表。

Figure 17: The prototype chain of jane starts with jane and continues with Person.prototype.

一般方法呼叫會被派送,方法呼叫 jane.describe() 會發生在兩個步驟中

這種動態尋找方法並呼叫它的方式稱為動態派送

您可以直接進行相同的方法呼叫,而不需要調度

Person.prototype.describe.call(jane)

這次,我們透過 Person.prototype.describe 直接指向方法,而不是在原型鏈中搜尋它。我們也透過 .call() 以不同的方式指定 this

請注意,this 永遠指向原型鏈的開頭。這讓 .describe() 可以存取 .name

29.4.4.1 借用方法

當您使用 Object.prototype 的方法時,直接方法呼叫會變得很有用。例如,Object.prototype.hasOwnProperty(k) 會檢查 this 是否有非繼承的屬性,其金鑰為 k

> const obj = { foo: 123 };
> obj.hasOwnProperty('foo')
true
> obj.hasOwnProperty('bar')
false

然而,在物件的原型鏈中,可能會有另一個金鑰為 'hasOwnProperty' 的屬性,會覆寫 Object.prototype 中的方法。那麼,調度的方法呼叫就不會起作用

> const obj = { hasOwnProperty: true };
> obj.hasOwnProperty('bar')
TypeError: obj.hasOwnProperty is not a function

解決方法是使用直接方法呼叫

> Object.prototype.hasOwnProperty.call(obj, 'bar')
false
> Object.prototype.hasOwnProperty.call(obj, 'hasOwnProperty')
true

這種直接方法呼叫通常會縮寫如下

> ({}).hasOwnProperty.call(obj, 'bar')
false
> ({}).hasOwnProperty.call(obj, 'hasOwnProperty')
true

這個模式看起來可能效率不彰,但大多數引擎會最佳化這個模式,所以效能不應該是問題。

29.4.5 混入類別(進階)

JavaScript 的類別系統只支援單一繼承。也就是說,每個類別最多只能有一個父類別。要解決這個限制的方法之一是透過稱為混入類別(簡稱:混入)的技術。

概念如下:假設我們想要類別 C 繼承自兩個父類別 S1S2。這將會是多重繼承,而 JavaScript 不支援這種繼承。

我們的解決方法是將 S1S2 變成混入,也就是子類別的工廠

const S1 = (Sup) => class extends Sup { /*···*/ };
const S2 = (Sup) => class extends Sup { /*···*/ };

這兩個函式中的每個函式都會傳回一個類別,用來延伸給定的父類別 Sup。我們建立類別 C 的方式如下

class C extends S2(S1(Object)) {
  /*···*/
}

我們現在有一個類別 C,它延伸自類別 S2,而 S2 延伸自類別 S1,而 S1 延伸自 Object(大多數類別都隱含地延伸自 Object)。

29.4.5.1 範例:品牌管理的混入

我們實作一個混入 Branded,它有設定和取得物件品牌的輔助方法

const Branded = (Sup) => class extends Sup {
  setBrand(brand) {
    this._brand = brand;
    return this;
  }
  getBrand() {
    return this._brand;
  }
};

我們使用這個混入來實作類別 Car 的品牌管理

class Car extends Branded(Object) {
  constructor(model) {
    super();
    this._model = model;
  }
  toString() {
    return `${this.getBrand()} ${this._model}`;
  }
}

以下程式碼確認混入有效:CarBranded 的方法 .setBrand()

const modelT = new Car('Model T').setBrand('Ford');
assert.equal(modelT.toString(), 'Ford Model T');
29.4.5.2 混入的好處

混入讓我們擺脫單一繼承的限制

29.5 常見問題:物件

29.5.1 為什麼物件會保留屬性的插入順序?

原則上,物件是不排序的。排序屬性的主要原因是,列出項目、鍵或值的操作是確定性的。例如,這有助於測試。

  測驗

請參閱測驗應用程式