處理 TypeScript
請支持這本書:購買捐款
(廣告,請不要封鎖。)

16 TypeScript 中的類別定義



在本章中,我們將探討 TypeScript 中的類別定義如何運作

16.1 秘笈:純粹 JavaScript 中的類別

本節是純粹 JavaScript 中類別定義的秘笈。

16.1.1 類別的基本成員

class OtherClass {}

class MyClass1 extends OtherClass {

  publicInstanceField = 1;

  constructor() {
    super();
  }

  publicPrototypeMethod() {
    return 2;
  }
}

const inst1 = new MyClass1();
assert.equal(inst1.publicInstanceField, 1);
assert.equal(inst1.publicPrototypeMethod(), 2);

  下一節是關於修飾詞

最後,有一個表格顯示修飾詞如何組合。

16.1.2 修飾詞:static

class MyClass2 {

  static staticPublicField = 1;

  static staticPublicMethod() {
    return 2;
  }
}

assert.equal(MyClass2.staticPublicField, 1);
assert.equal(MyClass2.staticPublicMethod(), 2);

16.1.3 類似修改器的名稱前綴:#(私人)

class MyClass3 {
  #privateField = 1;

  #privateMethod() {
    return 2;
  }

  static accessPrivateMembers() {
    // Private members can only be accessed from inside class definitions
    const inst3 = new MyClass3();
    assert.equal(inst3.#privateField, 1);
    assert.equal(inst3.#privateMethod(), 2);
  }
}
MyClass3.accessPrivateMembers();

JavaScript 警告

TypeScript 自 3.8 版起支援私人欄位,但目前不支援私人方法。

16.1.4 存取器的修改器:get(取得器)和 set(設定器)

大致來說,存取器是透過存取屬性來呼叫的方法。有兩種存取器:取得器和設定器。

class MyClass5 {
  #name = 'Rumpelstiltskin';
  
  /** Prototype getter */
  get name() {
    return this.#name;
  }

  /** Prototype setter */
  set name(value) {
    this.#name = value;
  }
}
const inst5 = new MyClass5();
assert.equal(inst5.name, 'Rumpelstiltskin'); // getter
inst5.name = 'Queen'; // setter
assert.equal(inst5.name, 'Queen'); // getter

16.1.5 方法的修改器:*(產生器)

class MyClass6 {
  * publicPrototypeGeneratorMethod() {
    yield 'hello';
    yield 'world';
  }
}

const inst6 = new MyClass6();
assert.deepEqual(
  [...inst6.publicPrototypeGeneratorMethod()],
  ['hello', 'world']);

16.1.6 方法的修改器:async

class MyClass7 {
  async publicPrototypeAsyncMethod() {
    const result = await Promise.resolve('abc');
    return result + result;
  }
}

const inst7 = new MyClass7();
inst7.publicPrototypeAsyncMethod()
  .then(result => assert.equal(result, 'abcabc'));

16.1.7 計算類別成員名稱

const publicInstanceFieldKey = Symbol('publicInstanceFieldKey');
const publicPrototypeMethodKey = Symbol('publicPrototypeMethodKey');

class MyClass8 {

  [publicInstanceFieldKey] = 1;

  [publicPrototypeMethodKey]() {
    return 2;
  }
}

const inst8 = new MyClass8();
assert.equal(inst8[publicInstanceFieldKey], 1);
assert.equal(inst8[publicPrototypeMethodKey](), 2);

註解

16.1.8 修改器的組合

欄位(沒有層級表示建構存在於實例層級)

層級 可見性
(實例)
(實例) #
靜態
靜態 #

方法(沒有層級表示建構存在於原型層級)

層級 存取器 非同步 產生器 可見性
(原型)
(原型) 取得
(原型) 設定
(原型) 非同步
(原型) *
(原型) 非同步 *
(與原型相關聯) #
(與原型相關聯) 取得 #
(與原型相關聯) 設定 #
(與原型相關聯) 非同步 #
(與原型相關聯) * #
(與原型相關聯) 非同步 * #
靜態
靜態 取得
靜態 設定
靜態 非同步
靜態 *
靜態 非同步 *
靜態 #
靜態 取得 #
靜態 設定 #
靜態 非同步 #
靜態 * #
靜態 非同步 * #

方法的限制

16.1.9 內部運作

重要的是要記住,在類別中,有兩個原型物件鏈

考慮以下純 JavaScript 範例

class ClassA {
  static staticMthdA() {}
  constructor(instPropA) {
    this.instPropA = instPropA;
  }
  prototypeMthdA() {}
}
class ClassB extends ClassA {
  static staticMthdB() {}
  constructor(instPropA, instPropB) {
    super(instPropA);
    this.instPropB = instPropB;
  }
  prototypeMthdB() {}
}
const instB = new ClassB(0, 1);

圖 1 顯示由 ClassAClassB 建立的原型鏈。

Figure 1: The classes ClassA and ClassB create two prototype chains: One for classes (left-hand side) and one for instances (right-hand side).

16.1.10 更多關於純 JavaScript 中類別定義的資訊

16.2 TypeScript 中的非公開資料槽

預設情況下,TypeScript 中的所有資料槽都是公開屬性。有兩種方法可以讓資料保持私密

我們將在下面探討這兩種方法。

請注意,TypeScript 目前不支援私有方法。

16.2.1 私有屬性

私有屬性是僅限於 TypeScript 的(靜態)功能。任何屬性都可以透過加上關鍵字 private(A 行)作為開頭來設為私有

class PersonPrivateProperty {
  private name: string; // (A)
  constructor(name: string) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}

如果我們在錯誤的範圍內存取該屬性,我們現在會收到編譯時期錯誤(A 行)

const john = new PersonPrivateProperty('John');

assert.equal(
  john.sayHello(), 'Hello John!');

// @ts-expect-error: Property 'name' is private and only accessible
// within class 'PersonPrivateProperty'. (2341)
john.name; // (A)

但是,private 沒有在執行時期改變任何東西。在執行時期,屬性 .name 和公開屬性沒有區別

assert.deepEqual(
  Object.keys(john),
  ['name']);

當我們查看類別編譯成的 JavaScript 程式碼時,我們也可以看到私有屬性在執行時期並未受到保護

class PersonPrivateProperty {
  constructor(name) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}

16.2.2 私有欄位

私有欄位是 TypeScript 自 3.8 版以來支援的一項新的 JavaScript 功能

class PersonPrivateField {
  #name: string;
  constructor(name: string) {
    this.#name = name;
  }
  sayHello() {
    return `Hello ${this.#name}!`;
  }
}

這個版本的 Person 大多和私有屬性版本以相同的方式使用

const john = new PersonPrivateField('John');

assert.equal(
  john.sayHello(), 'Hello John!');

不過,這次資料是完全封裝的。在類別外部使用私有欄位語法甚至會造成 JavaScript 語法錯誤。這就是為什麼我們必須在 A 行使用 eval(),才能執行這段程式碼

assert.throws(
  () => eval('john.#name'), // (A)
  {
    name: 'SyntaxError',
    message: "Private field '#name' must be declared in "
      + "an enclosing class",
  });

assert.deepEqual(
  Object.keys(john),
  []);

編譯結果現在複雜得多(略作簡化)

var __classPrivateFieldSet = function (receiver, privateMap, value) {
  if (!privateMap.has(receiver)) {
    throw new TypeError(
      'attempted to set private field on non-instance');
  }
  privateMap.set(receiver, value);
  return value;
};

// Omitted: __classPrivateFieldGet

var _name = new WeakMap();
class Person {
  constructor(name) {
    // Add an entry for this instance to _name
    _name.set(this, void 0);

    // Now we can use the helper function:
    __classPrivateFieldSet(this, _name, name);
  }
  // ···
}

這段程式碼使用了一種常見的技術來讓執行個體資料保持私密

有關此主題的更多資訊:請參閱 “JavaScript for impatient programmers”

16.2.3 私有屬性與私有欄位

16.2.4 受保護的屬性

無法在子類別中存取私有欄位和私有屬性(A 行)

class PrivatePerson {
  private name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}
class PrivateEmployee extends PrivatePerson {
  private company: string;
  constructor(name: string, company: string) {
    super(name);
    this.company = company;
  }
  sayHello() {
    // @ts-expect-error: Property 'name' is private and only
    // accessible within class 'PrivatePerson'. (2341)
    return `Hello ${this.name} from ${this.company}!`; // (A)
  }  
}

我們可以在 A 行將 private 改為 protected 來修正前一個範例(為了保持一致性,我們也在 B 行進行變更)

class ProtectedPerson {
  protected name: string; // (A)
  constructor(name: string) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}
class ProtectedEmployee extends ProtectedPerson {
  protected company: string; // (B)
  constructor(name: string, company: string) {
    super(name);
    this.company = company;
  }
  sayHello() {
    return `Hello ${this.name} from ${this.company}!`; // OK
  }  
}

16.3 私有建構函式

建構函式也可以是私有的。當我們有靜態工廠方法,而且希望用戶端總是使用這些方法,而不要直接使用建構函式時,這會很有用。靜態方法可以存取私有類別成員,這就是為什麼工廠方法仍然可以使用建構函式的緣故。

在以下程式碼中,有一個靜態工廠方法 DataContainer.create()。它透過非同步載入的資料設定執行個體。將非同步程式碼保留在工廠方法中,可以讓實際類別完全同步

class DataContainer {
  #data: string;
  static async create() {
    const data = await Promise.resolve('downloaded'); // (A)
    return new this(data);
  }
  private constructor(data: string) {
    this.#data = data;
  }
  getData() {
    return 'DATA: '+this.#data;
  }
}
DataContainer.create()
  .then(dc => assert.equal(
    dc.getData(), 'DATA: downloaded'));

在實際程式碼中,我們會在 A 行使用 fetch() 或類似的基於 Promise 的 API 來非同步載入資料。

私有建構函式可防止 DataContainer 被子類別化。如果我們要允許子類別,我們必須將其設為 protected

16.4 初始化實例屬性

16.4.1 嚴格屬性初始化

如果編譯器設定 --strictPropertyInitialization 已開啟(如果我們使用 --strict,則為這種情況),則 TypeScript 會檢查是否所有宣告的實例屬性都已正確初始化

然而,有時我們會以 TypeScript 無法辨識的方式初始化屬性。然後,我們可以使用驚嘆號(明確指定)來關閉 TypeScript 的警告(A 行和 B 行)

class Point {
  x!: number; // (A)
  y!: number; // (B)
  constructor() {
    this.initProperties();
  }
  initProperties() {
    this.x = 0;
    this.y = 0;
  }
}
16.4.1.1 範例:透過物件設定實例屬性

在以下範例中,我們也需要明確指定。在此,我們透過建構函式參數 props 設定實例屬性

class CompilerError implements CompilerErrorProps { // (A)
  line!: number;
  description!: string;
  constructor(props: CompilerErrorProps) {
    Object.assign(this, props); // (B)
  }
}

// Helper interface for the parameter properties
interface CompilerErrorProps {
  line: number,
  description: string,
}

// Using the class:
const err = new CompilerError({
  line: 123,
  description: 'Unexpected token',
});

備註

16.4.2 將建構函式參數設為 publicprivateprotected

如果我們對建構函式參數使用關鍵字 public,則 TypeScript 會為我們執行兩件事

因此,以下兩個類別是等效的

class Point1 {
  constructor(public x: number, public y: number) {
  }
}

class Point2 {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

如果我們使用 privateprotected 取代 public,則對應的實例屬性為私有或受保護(非公開)。

16.5 抽象類別

在 TypeScript 中,兩個建構可以是抽象的

以下程式碼示範抽象類別和方法。

一方面,有抽象超類別 Printable 及其輔助類別 StringBuilder

class StringBuilder {
  string = '';
  add(str: string) {
    this.string += str;
  }
}
abstract class Printable {
  toString() {
    const out = new StringBuilder();
    this.print(out);
    return out.string;
  }
  abstract print(out: StringBuilder): void;
}

另一方面,有具體子類別 EntriesEntry

class Entries extends Printable {
  entries: Entry[];
  constructor(entries: Entry[]) {
    super();
    this.entries = entries;
  }
  print(out: StringBuilder): void {
    for (const entry of this.entries) {
      entry.print(out);
    }
  }
}
class Entry extends Printable {
  key: string;
  value: string;
  constructor(key: string, value: string) {
    super();
    this.key = key;
    this.value = value;
  }
  print(out: StringBuilder): void {
    out.add(this.key);
    out.add(': ');
    out.add(this.value);
    out.add('\n');
  }
}

最後,這是我們使用 EntriesEntry 的方式

const entries = new Entries([
  new Entry('accept-ranges', 'bytes'),
  new Entry('content-length', '6518'),
]);
assert.equal(
  entries.toString(),
  'accept-ranges: bytes\ncontent-length: 6518\n');

關於抽象類別的注意事項