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

29 類別 [ES6]



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

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

29.1 秘笈:類別

超類別

class Person {
  #firstName; // (A)
  constructor(firstName) {
    this.#firstName = firstName; // (B)
  }
  describe() {
    return `Person named ${this.#firstName}`;
  }
  static extractNames(persons) {
    return persons.map(person => person.#firstName);
  }
}
const tarzan = new Person('Tarzan');
assert.equal(
  tarzan.describe(),
  'Person named Tarzan'
);
assert.deepEqual(
  Person.extractNames([tarzan, new Person('Cheeta')]),
  ['Tarzan', 'Cheeta']
);

子類別

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

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

備註

29.2 類別的基本原理

類別基本上是設定原型鏈的簡潔語法(在上一章中說明)。在底層,JavaScript 的類別是不尋常的。但這是我們在使用它們時很少見到的情況。對於使用過其他物件導向程式設計語言的人來說,它們通常應該感覺很熟悉。

請注意,我們不需要類別來建立物件。我們也可以透過物件文字來建立。這就是為什麼 JavaScript 中不需要單例模式,而且類別的使用次數比許多其他有類別的語言少。

29.2.1 人物類別

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

class Person {
  #firstName; // (A)
  constructor(firstName) {
    this.#firstName = firstName; // (B)
  }
  describe() {
    return `Person named ${this.#firstName}`;
  }
  static extractNames(persons) {
    return persons.map(person => person.#firstName);
  }
}

現在可以使用new Person()建立janetarzan

const jane = new Person('Jane');
const tarzan = new Person('Tarzan');

讓我們檢查Person類別主體內部有什麼。

我們也可以在建構函式中建立實例屬性(公開欄位)

class Container {
  constructor(value) {
    this.value = value;
  }
}
const abcContainer = new Container('abc');
assert.equal(
  abcContainer.value, 'abc'
);

與實例私有欄位相反,實例屬性不必在類別主體中宣告。

29.2.2 類別表達式

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

類別表達式可以是匿名的,也可以是有名稱的

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

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

命名類別表達式的名稱運作方式類似於 命名函式表達式的名稱:它只能在類別主體內存取,並且保持不變,無論類別如何指派。

29.2.3 instanceof 算子

instanceof 算子告訴我們一個值是否為給定類別的實例

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

我們將在 稍後更詳細地探討 instanceof 算子,在我們看過子類別化之後。

29.2.4 公開槽(屬性)與私有槽

在 JavaScript 語言中,物件可以有兩種「槽」。

以下是我們需要知道的關於屬性和私有槽最重要的規則

  關於屬性和私有槽的更多資訊

本章未涵蓋屬性和私有槽的所有詳細資訊(僅涵蓋要點)。如果您想要更深入地探討,可以按這裡

以下類別展示了兩種槽。每個實例都有私有欄位和屬性

class MyClass {
  #instancePrivateField = 1;
  instanceProperty = 2;
  getInstanceValues() {
    return [
      this.#instancePrivateField,
      this.instanceProperty,
    ];
  }
}
const inst = new MyClass();
assert.deepEqual(
  inst.getInstanceValues(), [1, 2]
);

正如預期,在 MyClass 外部,我們只能看到屬性

assert.deepEqual(
  Reflect.ownKeys(inst),
  ['instanceProperty']
);

接下來,我們將探討私有槽的一些細節。

29.2.5 更詳細的私有槽 [ES2022](進階)

29.2.5.1 無法在子類別中存取私有槽

私有槽只能在其類別主體內部存取。我們甚至無法從子類別存取它

class SuperClass {
  #superProp = 'superProp';
}
class SubClass extends SuperClass {
  getSuperProp() {
    return this.#superProp;
  }
}
// SyntaxError: Private field '#superProp'
// must be declared in an enclosing class

透過 extends 進行子類別化 將在本章稍後說明。如何在 §29.5.4「透過 WeakMaps 模擬受保護可見性和友元可見性」 中說明如何解決此限制。

29.2.5.2 每個私有槽都有唯一的金鑰(私有名稱

私有槽有唯一的金鑰,類似於 符號。考慮以下類別

class MyClass {
  #instancePrivateField = 1;
  instanceProperty = 2;
  getInstanceValues() {
    return [
      this.#instancePrivateField,
      this.instanceProperty,
    ];
  }
}

在內部,MyClass 的私有欄位大致處理如下

let MyClass;
{ // Scope of the body of the class
  const instancePrivateFieldKey = Symbol();
  MyClass = class {
    // Very loose approximation of how this
    // works in the language specification
    __PrivateElements__ = new Map([
      [instancePrivateFieldKey, 1],
    ]);
    instanceProperty = 2;
    getInstanceValues() {
      return [
        this.__PrivateElements__.get(instancePrivateFieldKey),
        this.instanceProperty,
      ];
    }
  }
}

instancePrivateFieldKey 的值稱為私有名稱。我們無法在 JavaScript 中直接使用私有名稱,只能透過私有欄位、私有方法和私有存取器的固定識別碼間接使用。其中,公有槽的固定識別碼(例如 getInstanceValues)被解釋為字串金鑰,私有槽的固定識別碼(例如 #instancePrivateField)則是指私有名稱(類似於變數名稱指稱值)。

29.2.5.3 相同的私有識別碼在不同的類別中指稱不同的私有名稱

由於私有槽的識別碼不用作金鑰,因此在不同的類別中使用相同的識別碼會產生不同的槽(A 行和 C 行)

class Color {
  #name; // (A)
  constructor(name) {
    this.#name = name; // (B)
  }
  static getName(obj) {
    return obj.#name;
  }
}
class Person {
  #name; // (C)
  constructor(name) {
    this.#name = name;
  }
}

assert.equal(
  Color.getName(new Color('green')), 'green'
);

// We can’t access the private slot #name of a Person in line B:
assert.throws(
  () => Color.getName(new Person('Jane')),
  {
    name: 'TypeError',
    message: 'Cannot read private member #name from'
      + ' an object whose class did not declare it',
  }
);
29.2.5.4 私有欄位的名稱絕不會衝突

即使子類別對私有欄位使用相同名稱,兩個名稱也不會衝突,因為它們指的是私有名稱(總是唯一的)。在以下範例中,SuperClass 中的 .#privateField 即使兩個插槽都直接儲存在 inst 中,也不會與 SubClass 中的 .#privateField 衝突

class SuperClass {
  #privateField = 'super';
  getSuperPrivateField() {
    return this.#privateField;
  }
}
class SubClass extends SuperClass {
  #privateField = 'sub';
  getSubPrivateField() {
    return this.#privateField;
  }
}
const inst = new SubClass();
assert.equal(
  inst.getSuperPrivateField(), 'super'
);
assert.equal(
  inst.getSubPrivateField(), 'sub'
);

透過 extends 進行子類別化會在本章節稍後說明。

29.2.5.5 使用 in 檢查物件是否有指定的私有插槽

in 營運子可用於檢查私有插槽是否存在(A 行)

class Color {
  #name;
  constructor(name) {
    this.#name = name;
  }
  static check(obj) {
    return #name in obj; // (A)
  }
}

讓我們看看應用於私有插槽的更多 in 範例。

私有方法。以下程式碼顯示私有方法會在執行個體中建立私有插槽

class C1 {
  #priv() {}
  static check(obj) {
    return #priv in obj;
  }
}
assert.equal(C1.check(new C1()), true);

靜態私有欄位。我們也可以對靜態私有欄位使用 in

class C2 {
  static #priv = 1;
  static check(obj) {
    return #priv in obj;
  }
}
assert.equal(C2.check(C2), true);
assert.equal(C2.check(new C2()), false);

靜態私有方法。我們也可以檢查靜態私有方法的插槽

class C3 {
  static #priv() {}
  static check(obj) {
    return #priv in obj;
  }
}
assert.equal(C3.check(C3), true);

在不同的類別中使用相同的私有識別碼。在以下範例中,兩個類別 ColorPerson 都有一個識別碼為 #name 的插槽。in 營運子正確區分它們

class Color {
  #name;
  constructor(name) {
    this.#name = name;
  }
  static check(obj) {
    return #name in obj;
  }
}
class Person {
  #name;
  constructor(name) {
    this.#name = name;
  }
  static check(obj) {
    return #name in obj;
  }
}

// Detecting Color’s #name
assert.equal(
  Color.check(new Color()), true
);
assert.equal(
  Color.check(new Person()), false
);

// Detecting Person’s #name
assert.equal(
  Person.check(new Person()), true
);
assert.equal(
  Person.check(new Color()), false
);

29.2.6 JavaScript 中類別的優缺點

我建議使用類別,原因如下

這並不表示類別是完美的

這是類別的第一眼。我們很快就會探討更多功能。

  練習:撰寫類別

exercises/classes/point_class_test.mjs

29.2.7 使用類別的秘訣

29.3 類別的內部

29.3.1 類別實際上是兩個連接的物件

在幕後,類別會變成兩個連接的物件。讓我們重新檢視類別 Person,看看它是如何運作的

class Person {
  #firstName;
  constructor(firstName) {
    this.#firstName = firstName;
  }
  describe() {
    return `Person named ${this.#firstName}`;
  }
  static extractNames(persons) {
    return persons.map(person => person.#firstName);
  }
}

類別建立的第一個物件儲存在 Person 中。它有四個屬性

assert.deepEqual(
  Reflect.ownKeys(Person),
  ['length', 'name', 'prototype', 'extractNames']
);

// The number of parameters of the constructor
assert.equal(
  Person.length, 1
);

// The name of the class
assert.equal(
  Person.name, 'Person'
);

剩下的兩個屬性是

以下是 Person.prototype 的內容

assert.deepEqual(
  Reflect.ownKeys(Person.prototype),
  ['constructor', 'describe']
);

有兩個屬性

29.3.2 類別設定實例的原型鏈

物件 Person.prototype 是所有實例的原型

const jane = new Person('Jane');
assert.equal(
  Object.getPrototypeOf(jane), Person.prototype
);

const tarzan = new Person('Tarzan');
assert.equal(
  Object.getPrototypeOf(tarzan), Person.prototype
);

這說明了實例如何取得它們的方法:它們從物件 Person.prototype 繼承這些方法。

圖 13 視覺化了所有東西是如何連接的。

Figure 13: The class Person has the property .prototype that points to an object that is the prototype of all instances of Person. The objects jane and tarzan are two such instances.

29.3.3 .__proto__ 與 .prototype

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

29.3.4 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
assert.equal(cheeta instanceof Person, true);

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

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

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

在這個小節中,我們將瞭解呼叫方法的兩種不同方式

了解這兩種方式,將讓我們深入瞭解方法運作的方式。

我們也需要第二種方式,稍後在本章中:它將允許我們從 Object.prototype 借用有用的方法。

29.3.5.1 派送方法呼叫

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

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

圖 14 有個圖表,顯示 jane 的原型鏈。

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

正常的呼叫方法是派送的 – 方法呼叫

jane.describe()

分為兩個步驟

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

29.3.5.2 直接方法呼叫

我們也可以直接呼叫方法,而不用派送

Person.prototype.describe.call(jane)

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

  this 永遠指向實例

不論實例的原型鏈中的方法位於何處,this 永遠指向實例(原型鏈的開頭)。這使得 .describe() 能夠在範例中存取 .#firstName

何時直接方法呼叫是有用的?當我們想要從其他地方借用一個給定物件所沒有的方法時,例如

const obj = Object.create(null);

// `obj` is not an instance of Object and doesn’t inherit
// its prototype method .toString()
assert.throws(
  () => obj.toString(),
  /^TypeError: obj.toString is not a function$/
);
assert.equal(
  Object.prototype.toString.call(obj),
  '[object Object]'
);

29.3.6 類別從一般函式演變而來(進階)

在 ECMAScript 6 之前,JavaScript 沒有類別。取而代之的是,一般函式被用作建構函式

function StringBuilderConstr(initialString) {
  this.string = initialString;
}
StringBuilderConstr.prototype.add = function (str) {
  this.string += str;
  return this;
};

const sb = new StringBuilderConstr('¡');
sb.add('Hola').add('!');
assert.equal(
  sb.string, '¡Hola!'
);

類別為此方法提供了更好的語法

class StringBuilderClass {
  constructor(initialString) {
    this.string = initialString;
  }
  add(str) {
    this.string += str;
    return this;
  }
}
const sb = new StringBuilderClass('¡');
sb.add('Hola').add('!');
assert.equal(
  sb.string, '¡Hola!'
);

使用建構函式進行子類別化特別棘手。類別也提供了超越更方便語法的好處

類別與建構函式非常相容,甚至可以延伸它們

function SuperConstructor() {}
class SubClass extends SuperConstructor {}

assert.equal(
  new SubClass() instanceof SuperConstructor, true
);

extends 和子類別化會在 本章節稍後 說明。

29.3.6.1 類別是建構函式

這讓我們獲得了一個有趣的見解。一方面,StringBuilderClass 透過 StringBuilderClass.prototype.constructor 參照其建構函式。

另一方面,類別就是建構函式(一個函式)

> StringBuilderClass.prototype.constructor === StringBuilderClass
true
> typeof StringBuilderClass
'function'

  建構函式(函式)vs. 類別

由於它們非常相似,我交替使用建構函式(函式)類別這兩個術語。

29.4 類別的原型成員

29.4.1 公開原型方法和存取器

下列類別宣告主體中的所有成員都會建立 PublicProtoClass.prototype 的屬性。

class PublicProtoClass {
  constructor(args) {
    // (Do something with `args` here.)
  }
  publicProtoMethod() {
    return 'publicProtoMethod';
  }
  get publicProtoAccessor() {
    return 'publicProtoGetter';
  }
  set publicProtoAccessor(value) {
    assert.equal(value, 'publicProtoSetter');
  }
}

assert.deepEqual(
  Reflect.ownKeys(PublicProtoClass.prototype),
  ['constructor', 'publicProtoMethod', 'publicProtoAccessor']
);

const inst = new PublicProtoClass('arg1', 'arg2');
assert.equal(
  inst.publicProtoMethod(), 'publicProtoMethod'
);
assert.equal(
  inst.publicProtoAccessor, 'publicProtoGetter'
);
inst.publicProtoAccessor = 'publicProtoSetter';
29.4.1.1 所有類型的公開原型方法和存取器(進階)
const accessorKey = Symbol('accessorKey');
const syncMethodKey = Symbol('syncMethodKey');
const syncGenMethodKey = Symbol('syncGenMethodKey');
const asyncMethodKey = Symbol('asyncMethodKey');
const asyncGenMethodKey = Symbol('asyncGenMethodKey');

class PublicProtoClass2 {
  // Identifier keys
  get accessor() {}
  set accessor(value) {}
  syncMethod() {}
  * syncGeneratorMethod() {}
  async asyncMethod() {}
  async * asyncGeneratorMethod() {}

  // Quoted keys
  get 'an accessor'() {}
  set 'an accessor'(value) {}
  'sync method'() {}
  * 'sync generator method'() {}
  async 'async method'() {}
  async * 'async generator method'() {}

  // Computed keys
  get [accessorKey]() {}
  set [accessorKey](value) {}
  [syncMethodKey]() {}
  * [syncGenMethodKey]() {}
  async [asyncMethodKey]() {}
  async * [asyncGenMethodKey]() {}
}

// Quoted and computed keys are accessed via square brackets:
const inst = new PublicProtoClass2();
inst['sync method']();
inst[syncMethodKey]();

引號鍵和計算鍵也可以用在物件字面上

更多有關存取器(透過 getter 和/或 setter 定義)、產生器、非同步方法和非同步產生器方法的資訊

29.4.2 私有方法和存取器 [ES2022]

私有方法(和存取器)是原型成員和實例成員的有趣組合。

一方面,私有方法儲存在實例中的槽位(A 行)

class MyClass {
  #privateMethod() {}
  static check() {
    const inst = new MyClass();
    assert.equal(
      #privateMethod in inst, true // (A)
    );
    assert.equal(
      #privateMethod in MyClass.prototype, false
    );
    assert.equal(
      #privateMethod in MyClass, false
    );
  }
}
MyClass.check();

為什麼它們沒有儲存在 .prototype 物件中?私有槽位不會被繼承,只有屬性會被繼承。

另一方面,私有方法在實例之間共用,就像原型公開方法一樣

class MyClass {
  #privateMethod() {}
  static check() {
    const inst1 = new MyClass();
    const inst2 = new MyClass();
    assert.equal(
      inst1.#privateMethod,
      inst2.#privateMethod
    );
  }
}

由於它們的語法類似於原型公共方法,因此在此介紹。

下列程式碼示範私有方法和存取器的運作方式

class PrivateMethodClass {
  #privateMethod() {
    return 'privateMethod';
  }
  get #privateAccessor() {
    return 'privateGetter';
  }
  set #privateAccessor(value) {
    assert.equal(value, 'privateSetter');
  }
  callPrivateMembers() {
    assert.equal(this.#privateMethod(), 'privateMethod');
    assert.equal(this.#privateAccessor, 'privateGetter');
    this.#privateAccessor = 'privateSetter';
  }
}
assert.deepEqual(
  Reflect.ownKeys(new PrivateMethodClass()), []
);
29.4.2.1 各種私有方法和存取器(進階)

使用私有欄位時,金鑰永遠是識別碼

class PrivateMethodClass2 {
  get #accessor() {}
  set #accessor(value) {}
  #syncMethod() {}
  * #syncGeneratorMethod() {}
  async #asyncMethod() {}
  async * #asyncGeneratorMethod() {}
}

更多有關存取器(透過 getter 和/或 setter 定義)、產生器、非同步方法和非同步產生器方法的資訊

29.5 類別的執行個體成員 [ES2022]

29.5.1 執行個體公共欄位

下列類別的執行個體有兩個執行個體屬性(在 A 行和 B 行建立)

class InstPublicClass {
  // Instance public field
  instancePublicField = 0; // (A)

  constructor(value) {
    // We don’t need to mention .property elsewhere!
    this.property = value; // (B)
  }
}

const inst = new InstPublicClass('constrArg');
assert.deepEqual(
  Reflect.ownKeys(inst),
  ['instancePublicField', 'property']
);
assert.equal(
  inst.instancePublicField, 0
);
assert.equal(
  inst.property, 'constrArg'
);

如果在建構函式內建立執行個體屬性(B 行),我們不需要在其他地方「宣告」它。正如我們已經看到的,執行個體私有欄位則不同。

請注意,執行個體屬性在 JavaScript 中相對常見;比方說在 Java 中,大部分執行個體狀態都是私有的,因此比 Java 常見得多。

29.5.1.1 帶有引號和運算金鑰的執行個體公共欄位(進階)
const computedFieldKey = Symbol('computedFieldKey');
class InstPublicClass2 {
  'quoted field key' = 1;
  [computedFieldKey] = 2;
}
const inst = new InstPublicClass2();
assert.equal(inst['quoted field key'], 1);
assert.equal(inst[computedFieldKey], 2);
29.5.1.2 this 在執行個體公共欄位中的值為何?(進階)

在執行個體公共欄位的初始化程式中,this 指的是新建立的執行個體

class MyClass {
  instancePublicField = this;
}
const inst = new MyClass();
assert.equal(
  inst.instancePublicField, inst
);
29.5.1.3 執行個體公共欄位何時執行?(進階)

執行個體公共欄位的執行大致遵循下列兩個規則

下列範例示範這些規則

class SuperClass {
  superProp = console.log('superProp');
  constructor() {
    console.log('super-constructor');
  }
}
class SubClass extends SuperClass {
  subProp = console.log('subProp');
  constructor() {
    console.log('BEFORE super()');
    super();
    console.log('AFTER super()');
  }
}
new SubClass();

// Output:
// 'BEFORE super()'
// 'superProp'
// 'super-constructor'
// 'subProp'
// 'AFTER super()'

extends 和子類別化會在 本章節稍後 說明。

29.5.2 執行個體私有欄位

下列類別包含兩個執行個體私有欄位(A 行和 B 行)

class InstPrivateClass {
  #privateField1 = 'private field 1'; // (A)
  #privateField2; // (B) required!
  constructor(value) {
    this.#privateField2 = value; // (C)
  }
  /**
   * Private fields are not accessible outside the class body.
   */
  checkPrivateValues() {
    assert.equal(
      this.#privateField1, 'private field 1'
    );
    assert.equal(
      this.#privateField2, 'constructor argument'
    );
  }
}

const inst = new InstPrivateClass('constructor argument');
  inst.checkPrivateValues();

// No instance properties were created
assert.deepEqual(
  Reflect.ownKeys(inst),
  []
);

請注意,我們只能在 C 行中使用 .#privateField2,如果我們在類別主體中宣告它。

29.5.3 ES2022 之前的私有執行個體資料(進階)

在本節中,我們將探討兩種保持執行個體資料私有的技術。由於它們不依賴類別,因此我們也可以將它們用於透過其他方式建立的物件,例如物件文字。

29.5.3.1 ES6 之前:透過命名慣例設定私有成員

第一種技術透過在屬性名稱之前加上底線來設定私有屬性。這並不會以任何方式保護屬性;它只是向外部發出訊號:「你不需要知道這個屬性。」

在以下程式碼中,屬性 ._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.5.3.2 ES6 及後續版本:透過 WeakMaps 取得私有實例資料

我們也可以透過 WeakMaps 管理私有實例資料

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()),
  []);

確切的運作方式說明於 WeakMaps 章節 中。

此技術為我們提供相當程度的外部存取保護,而且不會有任何名稱衝突。但它也比較複雜。

我們透過控制誰有權存取偽屬性 _superProp 來控制其可見性,例如:如果變數存在於模組內且未匯出,則模組內的每個人都可以存取,而模組外的任何人則無法存取。換句話說:在此情況下,私密性的範圍不是類別,而是模組。不過,我們可以縮小範圍

let Countdown;
{ // class scope
  const _counter = new WeakMap();
  const _action = new WeakMap();

  Countdown = class {
    // ···
  }
}

此技術實際上不支援私有方法。但可以存取 _superProp 的模組局部函式是次佳選擇

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

class Countdown {
  constructor(counter, action) {
    _counter.set(this, counter);
    _action.set(this, action);
  }
  dec() {
    privateDec(this);
  }
}

function privateDec(_this) { // (A)
  let counter = _counter.get(_this);
  counter--;
  _counter.set(_this, counter);
  if (counter === 0) {
    _action.get(_this)();
  }
}

請注意,this 會變成明確的函式參數 _this(A 行)。

29.5.4 透過 WeakMaps 模擬受保護的可見性和友元可見性(進階)

如前所述,實例私有欄位僅在其類別內可見,甚至在子類別中也看不到。因此,沒有內建方式可以取得

在先前的子節中,我們透過 WeakMaps 模擬了「模組可見性」(模組內的每個人都可以存取實例資料)。因此

以下範例示範受保護的可見性

const _superProp = new WeakMap();
class SuperClass {
  constructor() {
    _superProp.set(this, 'superProp');
  }
}
class SubClass extends SuperClass {
  getSuperProp() {
    return _superProp.get(this);
  }
}
assert.equal(
  new SubClass().getSuperProp(),
  'superProp'
);

透過 extends 進行子類別化會在本章節稍後說明。

29.6 類別的靜態成員

29.6.1 靜態公用方法和存取器

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

class StaticPublicMethodsClass {
  static staticMethod() {
    return 'staticMethod';
  }
  static get staticAccessor() {
    return 'staticGetter';
  }
  static set staticAccessor(value) {
    assert.equal(value, 'staticSetter');
  }
}
assert.equal(
  StaticPublicMethodsClass.staticMethod(), 'staticMethod'
);
assert.equal(
  StaticPublicMethodsClass.staticAccessor, 'staticGetter'
);
StaticPublicMethodsClass.staticAccessor = 'staticSetter';
29.6.1.1 各種靜態公用方法和存取器(進階)
const accessorKey = Symbol('accessorKey');
const syncMethodKey = Symbol('syncMethodKey');
const syncGenMethodKey = Symbol('syncGenMethodKey');
const asyncMethodKey = Symbol('asyncMethodKey');
const asyncGenMethodKey = Symbol('asyncGenMethodKey');

class StaticPublicMethodsClass2 {
  // Identifier keys
  static get accessor() {}
  static set accessor(value) {}
  static syncMethod() {}
  static * syncGeneratorMethod() {}
  static async asyncMethod() {}
  static async * asyncGeneratorMethod() {}

  // Quoted keys
  static get 'an accessor'() {}
  static set 'an accessor'(value) {}
  static 'sync method'() {}
  static * 'sync generator method'() {}
  static async 'async method'() {}
  static async * 'async generator method'() {}

  // Computed keys
  static get [accessorKey]() {}
  static set [accessorKey](value) {}
  static [syncMethodKey]() {}
  static * [syncGenMethodKey]() {}
  static async [asyncMethodKey]() {}
  static async * [asyncGenMethodKey]() {}
}

// Quoted and computed keys are accessed via square brackets:
StaticPublicMethodsClass2['sync method']();
StaticPublicMethodsClass2[syncMethodKey]();

引號鍵和計算鍵也可以用在物件字面上

更多有關存取器(透過 getter 和/或 setter 定義)、產生器、非同步方法和非同步產生器方法的資訊

29.6.2 靜態公用欄位 [ES2022]

以下程式碼示範靜態公用欄位。StaticPublicFieldClass 有三個

const computedFieldKey = Symbol('computedFieldKey');
class StaticPublicFieldClass {
  static identifierFieldKey = 1;
  static 'quoted field key' = 2;
  static [computedFieldKey] = 3;
}

assert.deepEqual(
  Reflect.ownKeys(StaticPublicFieldClass),
  [
    'length', // number of constructor parameters
    'name', // 'StaticPublicFieldClass'
    'prototype',
    'identifierFieldKey',
    'quoted field key',
    computedFieldKey,
  ],
);

assert.equal(StaticPublicFieldClass.identifierFieldKey, 1);
assert.equal(StaticPublicFieldClass['quoted field key'], 2);
assert.equal(StaticPublicFieldClass[computedFieldKey], 3);

29.6.3 靜態私有方法、存取器和欄位 [ES2022]

以下類別有兩個靜態私有插槽(A 行和 B 行)

class StaticPrivateClass {
  // Declare and initialize
  static #staticPrivateField = 'hello'; // (A)
  static #twice() { // (B)
    const str = StaticPrivateClass.#staticPrivateField;
    return str + ' ' + str;
  }
  static getResultOfTwice() {
    return StaticPrivateClass.#twice();
  }
}

assert.deepEqual(
  Reflect.ownKeys(StaticPrivateClass),
  [
    'length', // number of constructor parameters
    'name', // 'StaticPublicFieldClass'
    'prototype',
    'getResultOfTwice',
  ],
);

assert.equal(
  StaticPrivateClass.getResultOfTwice(),
  'hello hello'
);

這是所有種類的靜態私有插槽的完整清單

class MyClass {
  static #staticPrivateMethod() {}
  static * #staticPrivateGeneratorMethod() {}

  static async #staticPrivateAsyncMethod() {}
  static async * #staticPrivateAsyncGeneratorMethod() {}
  
  static get #staticPrivateAccessor() {}
  static set #staticPrivateAccessor(value) {}
}

29.6.4 類別中的靜態初始化區塊 [ES2022]

要透過類別設定執行個體資料,我們有兩個建構

對於靜態資料,我們有

以下程式碼示範靜態區塊(A 行)

class Translator {
  static translations = {
    yes: 'ja',
    no: 'nein',
    maybe: 'vielleicht',
  };
  static englishWords = [];
  static germanWords = [];
  static { // (A)
    for (const [english, german] of Object.entries(this.translations)) {
      this.englishWords.push(english);
      this.germanWords.push(german);
    }
  }
}

我們也可以在類別(頂層)之後執行靜態區塊內的程式碼。不過,使用靜態區塊有兩個好處

29.6.4.1 靜態初始化區塊規則

靜態初始化區塊運作規則相對簡單

以下程式碼示範這些規則

class SuperClass {
  static superField1 = console.log('superField1');
  static {
    assert.equal(this, SuperClass);
    console.log('static block 1 SuperClass');
  }
  static superField2 = console.log('superField2');
  static {
    console.log('static block 2 SuperClass');
  }
}

class SubClass extends SuperClass {
  static subField1 = console.log('subField1');
  static {
    assert.equal(this, SubClass);
    console.log('static block 1 SubClass');
  }
  static subField2 = console.log('subField2');
  static {
    console.log('static block 2 SubClass');
  }
}

// Output:
// 'superField1'
// 'static block 1 SuperClass'
// 'superField2'
// 'static block 2 SuperClass'
// 'subField1'
// 'static block 1 SubClass'
// 'subField2'
// 'static block 2 SubClass'

透過 extends 進行子類別化會在本章節稍後說明。

29.6.5 陷阱:使用 this 存取靜態私有欄位

在靜態公用成員中,我們可以使用 this 存取靜態公用插槽。遺憾的是,我們不應該使用它來存取靜態私有插槽。

29.6.5.1 this 和靜態公用欄位

考慮以下程式碼

class SuperClass {
  static publicData = 1;
  
  static getPublicViaThis() {
    return this.publicData;
  }
}
class SubClass extends SuperClass {
}

透過 extends 進行子類別化會在本章節稍後說明。

靜態公用欄位是屬性。如果我們執行方法呼叫

assert.equal(SuperClass.getPublicViaThis(), 1);

那麼 this 會指向 SuperClass,一切都如預期運作。我們也可以透過子類別呼叫 .getPublicViaThis()

assert.equal(SubClass.getPublicViaThis(), 1);

SubClass 從其原型 SuperClass 繼承 .getPublicViaThis()this 指向 SubClass,而且事情繼續進行,因為 SubClass 也繼承屬性 .publicData

順帶一提,如果我們在 getPublicViaThis() 中指派給 this.publicData,並透過 SubClass.getPublicViaThis() 呼叫它,那麼我們將建立 SubClass 的新自有屬性,它會(非破壞性地)覆寫從 SuperClass 繼承的屬性。

29.6.5.2 this 和靜態私有欄位

考慮以下程式碼

class SuperClass {
  static #privateData = 2;
  static getPrivateDataViaThis() {
    return this.#privateData;
  }
  static getPrivateDataViaClassName() {
    return SuperClass.#privateData;
  }
}
class SubClass extends SuperClass {
}

透過 SuperClass 呼叫 .getPrivateDataViaThis() 會運作,因為 this 指向 SuperClass

assert.equal(SuperClass.getPrivateDataViaThis(), 2);

然而,透過 SubClass 呼叫 .getPrivateDataViaThis() 無法運作,因為 this 現在指向 SubClass,而 SubClass 沒有靜態私有欄位 .#privateData(原型鏈中的私有插槽不會被繼承)

assert.throws(
  () => SubClass.getPrivateDataViaThis(),
  {
    name: 'TypeError',
    message: 'Cannot read private member #privateData from'
      + ' an object whose class did not declare it',
  }
);

解決方法是透過 SuperClass 直接存取 .#privateData

assert.equal(SubClass.getPrivateDataViaClassName(), 2);

使用靜態私有方法時,我們會遇到相同的問題。

29.6.6 所有成員(靜態、原型、實例)都可以存取所有私有成員

類別中的每個成員都可以存取該類別中的所有其他成員,包括公用和私有成員

class DemoClass {
  static #staticPrivateField = 1;
  #instPrivField = 2;

  static staticMethod(inst) {
    // A static method can access static private fields
    // and instance private fields
    assert.equal(DemoClass.#staticPrivateField, 1);
    assert.equal(inst.#instPrivField, 2);
  }

  protoMethod() {
    // A prototype method can access instance private fields
    // and static private fields
    assert.equal(this.#instPrivField, 2);
    assert.equal(DemoClass.#staticPrivateField, 1);
  }
}

相反地,外部沒有人可以存取私有成員

// Accessing private fields outside their classes triggers
// syntax errors (before the code is even executed).
assert.throws(
  () => eval('DemoClass.#staticPrivateField'),
  {
    name: 'SyntaxError',
    message: "Private field '#staticPrivateField' must"
      + " be declared in an enclosing class",
  }
);
// Accessing private fields outside their classes triggers
// syntax errors (before the code is even executed).
assert.throws(
  () => eval('new DemoClass().#instPrivField'),
  {
    name: 'SyntaxError',
    message: "Private field '#instPrivField' must"
      + " be declared in an enclosing class",
  }
);

29.6.7 ES2022 之前的靜態私有方法和資料

以下程式碼僅在 ES2022 中運作,因為每一行都包含雜湊符號 (#)

class StaticClass {
  static #secret = 'Rumpelstiltskin';
  static #getSecretInParens() {
    return `(${StaticClass.#secret})`;
  }
  static callStaticPrivateMethod() {
    return StaticClass.#getSecretInParens();
  }
}

由於私有插槽每個類別只存在一次,因此我們可以將 #secret#getSecretInParens 移至圍繞類別的範圍,並使用模組將它們隱藏在模組外部的世界中。

const secret = 'Rumpelstiltskin';
function getSecretInParens() {
  return `(${secret})`;
}

// Only the class is accessible outside the module
export class StaticClass {
  static callStaticPrivateMethod() {
    return getSecretInParens();
  }
}

29.6.8 靜態工廠方法

有時類別有多種實例化方式。然後我們可以實作靜態工廠方法,例如 Point.fromPolar()

class Point {
  static fromPolar(radius, angle) {
    const x = radius * Math.cos(angle);
    const y = radius * Math.sin(angle);
    return new Point(x, y);
  }
  constructor(x=0, y=0) {
    this.x = x;
    this.y = y;
  }
}

assert.deepEqual(
  Point.fromPolar(13, 0.39479111969976155),
  new Point(12, 5)
);

我喜歡靜態工廠方法的描述性:fromPolar 描述如何建立實例。JavaScript 的標準函式庫也有這樣的工廠方法,例如

我比較喜歡沒有靜態工廠方法或只有靜態工廠方法。在後者的情況下需要考慮的事項

在以下程式碼中,我們使用一個秘密令牌(A 行)來防止建構函數從當前模組外部呼叫。

// Only accessible inside the current module
const secretToken = Symbol('secretToken'); // (A)

export class Point {
  static create(x=0, y=0) {
    return new Point(secretToken, x, y);
  }
  static fromPolar(radius, angle) {
    const x = radius * Math.cos(angle);
    const y = radius * Math.sin(angle);
    return new Point(secretToken, x, y);
  }
  constructor(token, x, y) {
    if (token !== secretToken) {
      throw new TypeError('Must use static factory method');
    }
    this.x = x;
    this.y = y;
  }
}
Point.create(3, 4); // OK
assert.throws(
  () => new Point(3, 4),
  TypeError
);

29.7 子類化

類別也可以擴充現有類別。例如,以下類別 Employee 擴充 Person

class Person {
  #firstName;
  constructor(firstName) {
    this.#firstName = firstName;
  }
  describe() {
    return `Person named ${this.#firstName}`;
  }
  static extractNames(persons) {
    return persons.map(person => person.#firstName);
  }
}

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

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

與擴充相關的術語

在衍生類別的 .constructor() 內,我們必須在存取 this 之前透過 super() 呼叫父建構函數。為什麼會這樣?

讓我們考慮一個類別鏈

如果我們呼叫 new C()C 的建構函數會呼叫父建構函數 B,而 B 會呼叫父建構函數 A。實例總是建立在基底類別中,在子類別的建構函數新增其插槽之前。因此,在我們呼叫 super() 之前,實例並不存在,而且我們還無法透過 this 存取它。

請注意,靜態公用插槽會被繼承。例如,Employee 繼承靜態方法 .extractNames()

> 'extractNames' in Employee
true

  練習:子類化

exercises/classes/color_point_class_test.mjs

29.7.1 子類化的內部機制(進階)

Figure 15: 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 由多個物件組成(圖 15)。了解這些物件之間關係的一個關鍵見解是,有兩個原型鏈

29.7.1.1 實例原型鏈(右欄)

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

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

在類別原型鏈中,Employee 優先,接著是 Person。之後,鏈會繼續使用 Function.prototype,它只會存在,因為 Person 是函式,而函式需要 Function.prototype 的服務。

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

29.7.2 instanceof 和子類別化 (進階)

我們尚未了解 instanceof 的實際運作方式。instanceof 如何判斷值 x 是否為類別 C 的實例 (它可能是 C 的直接實例,或 C 子類別的直接實例)?它會檢查 C.prototype 是否在 x 的原型鏈中。也就是說,下列兩個表達式是等效的

x instanceof C
C.prototype.isPrototypeOf(x)

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

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

請注意,如果 instanceof 的自我手邊側為基本值,它總是會傳回 false

> 'abc' instanceof String
false
> 123 instanceof Number
false

29.7.3 並非所有物件都是 Object 的實例 (進階)

物件 (非基本值) 只有在 Object.prototype 在其原型鏈中的情況下,才會是 Object 的實例 (請參閱前一個小節)。幾乎所有物件都是 Object 的實例,例如

assert.equal(
  {a: 1} instanceof Object, true
);
assert.equal(
  ['a'] instanceof Object, true
);
assert.equal(
  /abc/g instanceof Object, true
);
assert.equal(
  new Map() instanceof Object, true
);

class C {}
assert.equal(
  new C() instanceof Object, true
);

在下列範例中,obj1obj2 都是物件 (A 行和 C 行),但它們不是 Object 的實例 (B 行和 D 行):Object.prototype 不在它們的原型鏈中,因為它們沒有任何原型。

const obj1 = {__proto__: null};
assert.equal(
  typeof obj1, 'object' // (A)
);
assert.equal(
  obj1 instanceof Object, false // (B)
);

const obj2 = Object.create(null);
assert.equal(
  typeof obj2, 'object' // (C)
);
assert.equal(
  obj2 instanceof Object, false // (D)
);

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

> typeof Object.prototype
'object'
> Object.getPrototypeOf(Object.prototype)
null
> Object.prototype instanceof Object
false

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

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

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

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

29.7.4.1 {} 的原型鏈

讓我們從檢查純粹物件開始

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

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

29.7.4.2 [] 的原型鏈

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

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

這個原型鏈(在圖 17 中視覺化)告訴我們,陣列物件是 ArrayObject 的實例。

29.7.4.3 function () {} 的原型鏈

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

> p(function () {}) === Function.prototype
true
> p(p(function () {})) === Object.prototype
true
29.7.4.4 內建類別的原型鏈

基底類別的原型是 Function.prototype,這表示它是一個函式(Function 的實例)

class A {}
assert.equal(
  Object.getPrototypeOf(A),
  Function.prototype
);

assert.equal(
  Object.getPrototypeOf(class {}),
  Function.prototype
);

衍生類別的原型是其超類別

class B extends A {}
assert.equal(
  Object.getPrototypeOf(B),
  A
);

assert.equal(
  Object.getPrototypeOf(class extends Object {}),
  Object
);

有趣的是,ObjectArrayFunction 都是基底類別

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

然而,正如我們所見,即使是基底類別的實例,其原型鏈中也有 Object.prototype,因為它提供了所有物件需要的服務。

  為什麼 ArrayFunction 是基底類別?

基底類別是實際建立實例的地方。ArrayFunction 都需要建立自己的實例,因為它們有所謂的「內部插槽」,無法在 Object 建立的實例中後續加入。

29.7.5 Mixin 類別(進階)

JavaScript 的類別系統僅支援單一繼承。也就是說,每個類別最多只能有一個超類別。解決這個限制的一種方法是使用一種稱為Mixin 類別(簡稱:Mixins)的技術。

概念如下:假設我們希望類別 C 從兩個超類別 S1S2 繼承。這將是多重繼承,而 JavaScript 不支援此功能。

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

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

29.7.5.1 範例:品牌管理的 mixin

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

const Named = (Sup) => class extends Sup {
  name = '(Unnamed)';
  toString() {
    const className = this.constructor.name;
    return `${className} named ${this.name}`;
  }
};

我們使用這個 mixin 來實作一個類別 City,它有一個名稱

class City extends Named(Object) {
  constructor(name) {
    super();
    this.name = name;
  }
}

以下程式碼確認這個 mixin 有效

const paris = new City('Paris');
assert.equal(
  paris.name, 'Paris'
);
assert.equal(
  paris.toString(), 'City named Paris'
);
29.7.5.2 mixin 的好處

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

29.8 Object.prototype 的方法和存取器(進階)

正如我們在 §29.7.3 “並非所有物件都是 Object 的實例” 中所見,幾乎所有的物件都是 Object 的實例。這個類別提供了幾個有用的方法和一個存取器給它的實例

在我們仔細檢視這些功能的每個功能之前,我們將瞭解一個重要的陷阱(以及如何解決它):我們無法對所有物件使用 Object.prototype 的功能。

29.8.1 安全地使用 Object.prototype 方法

在任意物件上呼叫 Object.prototype 的方法並不總是可行的。為了說明原因,我們使用 Object.prototype.hasOwnProperty 方法,如果物件有擁有屬性,其鍵值為給定值,則回傳 true

> {ownProp: true}.hasOwnProperty('ownProp')
true
> {ownProp: true}.hasOwnProperty('abc')
false

呼叫任意物件上的 .hasOwnProperty() 可能會以兩種方式失敗。一方面,如果物件不是 Object 的執行個體,此方法不可用(請參閱 §29.7.3「並非所有物件都是 Object 的執行個體」

const obj = Object.create(null);
assert.equal(obj instanceof Object, false);
assert.throws(
  () => obj.hasOwnProperty('prop'),
  {
    name: 'TypeError',
    message: 'obj.hasOwnProperty is not a function',
  }
);

另一方面,如果物件以自有屬性覆寫 .hasOwnProperty(),我們無法使用 .hasOwnProperty()(A 行)

const obj = {
  hasOwnProperty: 'yes' // (A)
};
assert.throws(
  () => obj.hasOwnProperty('prop'),
  {
    name: 'TypeError',
    message: 'obj.hasOwnProperty is not a function',
  }
);

不過,有一個安全的方法可以使用 .hasOwnProperty()

function hasOwnProp(obj, propName) {
  return Object.prototype.hasOwnProperty.call(obj, propName); // (A)
}
assert.equal(
  hasOwnProp(Object.create(null), 'prop'), false
);
assert.equal(
  hasOwnProp({hasOwnProperty: 'yes'}, 'prop'), false
);
assert.equal(
  hasOwnProp({hasOwnProperty: 'yes'}, 'hasOwnProperty'), true
);

A 行中的方法呼叫在 §29.3.5「派送方法呼叫與直接方法呼叫」 中說明。

我們也可以使用 .bind() 來實作 hasOwnProp()

const hasOwnProp = Object.prototype.hasOwnProperty.call
  .bind(Object.prototype.hasOwnProperty);

這是如何運作的?當我們呼叫 .call(),就像前一個範例中的 A 行,它會執行 hasOwnProp() 應該執行的所有動作,包括避免陷阱。不過,如果我們想要以函式呼叫它,我們不能只是萃取它,我們還必須確保它的 this 永遠有正確的值。這就是 .bind() 的作用。

  是否永遠不適合透過動態派送使用 Object.prototype 方法?

在某些情況下,我們可以偷懶,呼叫 Object.prototype 方法,就像呼叫一般方法(沒有 .call().bind()):如果我們知道接收者,而且它們是固定配置物件。

另一方面,如果我們不知道接收者和/或它們是字典物件,我們就需要採取預防措施。

29.8.2 Object.prototype.toString()

透過覆寫 .toString()(在子類別或執行個體中),我們可以設定物件轉換為字串的方式

> String({toString() { return 'Hello!' }})
'Hello!'
> String({})
'[object Object]'

要將物件轉換為字串,最好使用 String(),因為它也可以用於 undefinednull

> undefined.toString()
TypeError: Cannot read properties of undefined (reading 'toString')
> null.toString()
TypeError: Cannot read properties of null (reading 'toString')
> String(undefined)
'undefined'
> String(null)
'null'

29.8.3 Object.prototype.toLocaleString()

.toLocaleString().toString() 的版本,可以透過地區設定和通常的其他選項來設定。任何類別或執行個體都可以實作此方法。在標準函式庫中,下列類別會實作此方法

舉例來說,這是數字如何以不同的方式轉換為字串,視地區設定而定('fr' 是法文,'en' 是英文)

> 123.45.toLocaleString('fr')
'123,45'
> 123.45.toLocaleString('en')
'123.45'

29.8.4 Object.prototype.valueOf()

透過覆寫 .valueOf()(在子類別或實例中),我們可以設定物件如何轉換為非字串值(通常是數字)

> Number({valueOf() { return 123 }})
123
> Number({})
NaN

29.8.5 Object.prototype.isPrototypeOf()

proto.isPrototypeOf(obj) 如果 protoobj 的原型鏈中,則傳回 true,否則傳回 false

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.8.1「安全使用 Object.prototype 方法」

const obj = {
  // Overrides Object.prototype.isPrototypeOf
  isPrototypeOf: true,
};
// Doesn’t work in this case:
assert.throws(
  () => obj.isPrototypeOf(Object.prototype),
  {
    name: 'TypeError',
    message: 'obj.isPrototypeOf is not a function',
  }
);
// Safe way of using .isPrototypeOf():
assert.equal(
  Object.prototype.isPrototypeOf.call(obj, Object.prototype), false
);

29.8.6 Object.prototype.propertyIsEnumerable()

obj.propertyIsEnumerable(propKey) 如果 obj 有自己的可列舉屬性,其鍵為 propKey,則傳回 true,否則傳回 false

const proto = {
  enumerableProtoProp: true,
};
const obj = {
  __proto__: proto,
  enumerableObjProp: true,
  nonEnumObjProp: true,
};
Object.defineProperty(
  obj, 'nonEnumObjProp',
  {
    enumerable: false,
  }
);

assert.equal(
  obj.propertyIsEnumerable('enumerableProtoProp'),
  false // not an own property
);
assert.equal(
  obj.propertyIsEnumerable('enumerableObjProp'),
  true
);
assert.equal(
  obj.propertyIsEnumerable('nonEnumObjProp'),
  false // not enumerable
);
assert.equal(
  obj.propertyIsEnumerable('unknownProp'),
  false // not a property
);

以下是安全使用此方法的方式(詳細資訊請參閱 §29.8.1「安全使用 Object.prototype 方法」

const obj = {
  // Overrides Object.prototype.propertyIsEnumerable
  propertyIsEnumerable: true,
  enumerableProp: 'yes',
};
// Doesn’t work in this case:
assert.throws(
  () => obj.propertyIsEnumerable('enumerableProp'),
  {
    name: 'TypeError',
    message: 'obj.propertyIsEnumerable is not a function',
  }
);
// Safe way of using .propertyIsEnumerable():
assert.equal(
  Object.prototype.propertyIsEnumerable.call(obj, 'enumerableProp'),
  true
);

另一個安全的替代方案是使用 屬性描述符

assert.deepEqual(
  Object.getOwnPropertyDescriptor(obj, 'enumerableProp'),
  {
    value: 'yes',
    writable: true,
    enumerable: true,
    configurable: true,
  }
);

29.8.7 Object.prototype.__proto__(存取器)

屬性 __proto__ 有兩個版本

我建議避免使用前一個功能

相反地,物件文字中的 __proto__ 永遠都能運作,而且不建議使用。

如果您有興趣瞭解存取器 __proto__ 如何運作,請繼續閱讀。

__proto__Object.prototype 的存取器,由所有 Object 實例繼承。透過類別實作它看起來像這樣

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

由於 __proto__ 是從 Object.prototype 繼承的,我們可以透過建立一個在原型鏈中沒有 Object.prototype 的物件來移除此功能(請參閱 §29.7.3「並非所有物件都是 Object 的實例」

> '__proto__' in {}
true
> '__proto__' in Object.create(null)
false

29.8.8 Object.prototype.hasOwnProperty()

  .hasOwnProperty() 更好的替代方案:Object.hasOwn() [ES2022]

請參閱 §28.9.4「Object.hasOwn():給定的屬性是否為自己的(非繼承的)?[ES2022]」

obj.hasOwnProperty(propKey) 如果 obj 有自己的(非繼承的)屬性,其鍵為 propKey,則傳回 true,否則傳回 false

const obj = { ownProp: true };
assert.equal(
  obj.hasOwnProperty('ownProp'), true // own
);
assert.equal(
  'toString' in obj, true // inherited
);
assert.equal(
  obj.hasOwnProperty('toString'), false
);

以下是安全使用此方法的方式(詳細資訊請參閱 §29.8.1「安全使用 Object.prototype 方法」

const obj = {
  // Overrides Object.prototype.hasOwnProperty
  hasOwnProperty: true,
};
// Doesn’t work in this case:
assert.throws(
  () => obj.hasOwnProperty('anyPropKey'),
  {
    name: 'TypeError',
    message: 'obj.hasOwnProperty is not a function',
  }
);
// Safe way of using .hasOwnProperty():
assert.equal(
  Object.prototype.hasOwnProperty.call(obj, 'anyPropKey'), false
);

29.9 常見問題:類別

29.9.1 為什麼本書將它們稱為「實例私有欄位」,而不是「私有實例欄位」?

這樣做是為了強調不同的屬性(公用插槽)和私有插槽之間的差異:透過改變形容詞的順序,單字「公用」和「欄位」以及單字「私有」和「欄位」總是會一起被提及。

29.9.2 為什麼識別碼前綴為 #?為什麼不透過 private 宣告私有欄位?

私有欄位是否可以透過 private 宣告並使用一般識別碼?讓我們來檢視如果可以這樣做會發生什麼事

class MyClass {
  private value; // (A)
  compare(other) {
    return this.value === other.value;
  }
}

每當 MyClass 的主體中出現類似 other.value 的表達式時,JavaScript 必須決定

在編譯時間,JavaScript 不知道 A 行中的宣告是否適用於 other(因為它是 MyClass 的執行個體)或不適用。這會留下兩個選項來做出決定

  1. .value 總是被解譯為私有欄位。
  2. JavaScript 在執行時間決定
    • 如果 otherMyClass 的執行個體,則 .value 會被解譯為私有欄位。
    • 否則 .value 會被解譯為屬性。

這兩個選項都有缺點

這就是為什麼會引入名稱前綴 #。現在可以輕鬆做出決定:如果我們使用 #,我們想要存取私有欄位。如果我們不使用,我們想要存取屬性。

private 適用於靜態類型語言(例如 TypeScript),因為它們在編譯時間知道 other 是否是 MyClass 的執行個體,然後可以將 .value 視為私有或公用。

  測驗

請參閱 測驗應用程式