TypeScript 之 Class(上)

冴羽發表於2021-12-15

TypeScript 的官方文件早已更新,但我能找到的中文文件都還停留在比較老的版本。所以對其中新增以及修訂較多的一些章節進行了翻譯整理。

本篇翻譯整理自 TypeScript Handbook 中 「Classes」 章節。

本文並不嚴格按照原文翻譯,對部分內容也做了解釋補充。

類(Classes)

TypeScript 完全支援 ES2015 引入的 class 關鍵字。

和其他 JavaScript 語言特性一樣,TypeScript 提供了型別註解和其他語法,允許你表達類與其他型別之間的關係。

類成員(Class Members)

這是一個最基本的類,一個空類:

class Point {}

這個類並沒有什麼用,所以讓我們新增一些成員。

欄位(Fields)

一個欄位宣告會建立一個公共(public)可寫入(writeable)的屬性:

class Point {
  x: number;
  y: number;
}
 
const pt = new Point();
pt.x = 0;
pt.y = 0;

注意:型別註解是可選的,如果沒有指定,會隱式的設定為 any。​

欄位可以設定初始值(initializers):

class Point {
  x = 0;
  y = 0;
}
 
const pt = new Point();
// Prints 0, 0
console.log(`${pt.x}, ${pt.y}`);

就像 constletvar ,一個類屬性的初始值會被用於推斷它的型別:

const pt = new Point();
pt.x = "0";
// Type 'string' is not assignable to type 'number'.

--strictPropertyInitialization

strictPropertyInitialization 選項控制了類欄位是否需要在建構函式裡初始化:

class BadGreeter {
  name: string;
  // Property 'name' has no initializer and is not definitely assigned in the constructor.
}
class GoodGreeter {
  name: string;
 
  constructor() {
    this.name = "hello";
  }
}

注意,欄位需要在建構函式自身進行初始化。TypeScript 並不會分析建構函式裡你呼叫的方法,進而判斷初始化的值,因為一個派生類也許會覆蓋這些方法並且初始化成員失敗:

class BadGreeter {
  name: string;
  // Property 'name' has no initializer and is not definitely assigned in the constructor.
  setName(): void {
    this.name = '123'
  }
  constructor() {
    this.setName();
  }
}

如果你執意要通過其他方式初始化一個欄位,而不是在建構函式裡(舉個例子,引入外部庫為你補充類的部分內容),你可以使用明確賦值斷言操作符(definite assignment assertion operator) !:

class OKGreeter {
  // Not initialized, but no error
  name!: string;
}

readonly

欄位可以新增一個 readonly 字首修飾符,這會阻止在建構函式之外的賦值。

class Greeter {
  readonly name: string = "world";
 
  constructor(otherName?: string) {
    if (otherName !== undefined) {
      this.name = otherName;
    }
  }
 
  err() {
    this.name = "not ok";
        // Cannot assign to 'name' because it is a read-only property.
  }
}

const g = new Greeter();
g.name = "also not ok";
// Cannot assign to 'name' because it is a read-only property.

建構函式(Constructors)

類的建構函式跟函式非常類似,你可以使用帶型別註解的引數、預設值、過載等。

class Point {
  x: number;
  y: number;
 
  // Normal signature with defaults
  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}
class Point {
  // Overloads
  constructor(x: number, y: string);
  constructor(s: string);
  constructor(xs: any, y?: any) {
    // TBD
  }
}

但類建構函式簽名與函式簽名之間也有一些區別:

  • 建構函式不能有型別引數(關於型別引數,回想下泛型裡的內容),這些屬於外層的類宣告,我們稍後就會學習到。
  • 建構函式不能有返回型別註解,因為總是返回類例項型別

Super 呼叫(Super Calls)

就像在 JavaScript 中,如果你有一個基類,你需要在使用任何 this. 成員之前,先在建構函式裡呼叫 super()

class Base {
  k = 4;
}
 
class Derived extends Base {
  constructor() {
    // Prints a wrong value in ES5; throws exception in ES6
    console.log(this.k);
        // 'super' must be called before accessing 'this' in the constructor of a derived class.
    super();
  }
}

忘記呼叫 super 是 JavaScript 中一個簡單的錯誤,但是 TypeScript 會在需要的時候提醒你。

方法(Methods)

類中的函式屬性被稱為方法。方法跟函式、建構函式一樣,使用相同的型別註解。

class Point {
  x = 10;
  y = 10;
 
  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
}

除了標準的型別註解,TypeScript 並沒有給方法新增任何新的東西。

注意在一個方法體內,它依然可以通過 this. 訪問欄位和其他的方法。方法體內一個未限定的名稱(unqualified name,沒有明確限定作用域的名稱)總是指向閉包作用域裡的內容。

let x: number = 0;
 
class C {
  x: string = "hello";
 
  m() {
    // This is trying to modify 'x' from line 1, not the class property
    x = "world";
        // Type 'string' is not assignable to type 'number'.
  }
}

Getters / Setter

類也可以有存取器(accessors):

class C {
  _length = 0;
  get length() {
    return this._length;
  }
  set length(value) {
    this._length = value;
  }
}

TypeScript 對存取器有一些特殊的推斷規則:

  • 如果 get 存在而 set 不存在,屬性會被自動設定為 readonly
  • 如果 setter 引數的型別沒有指定,它會被推斷為 getter 的返回型別
  • getters 和 setters 必須有相同的成員可見性(Member Visibility)。

從 TypeScript 4.3 起,存取器在讀取和設定的時候可以使用不同的型別。

class Thing {
  _size = 0;
 
  // 注意這裡返回的是 number 型別
  get size(): number {
    return this._size;
  }
 
  // 注意這裡允許傳入的是 string | number | boolean 型別
  set size(value: string | number | boolean) {
    let num = Number(value);
 
    // Don't allow NaN, Infinity, etc
    if (!Number.isFinite(num)) {
      this._size = 0;
      return;
    }
 
    this._size = num;
  }
}

索引簽名(Index Signatures)

類可以宣告索引簽名,它和物件型別的索引簽名是一樣的:

class MyClass {
  [s: string]: boolean | ((s: string) => boolean);
 
  check(s: string) {
    return this[s] as boolean;
  }
}

因為索引簽名型別也需要捕獲方法的型別,這使得並不容易有效的使用這些型別。通常的來說,在其他地方儲存索引資料而不是在類例項本身,會更好一些。

類繼承(Class Heritage)

JavaScript 的類可以繼承基類。

implements 語句(implements Clauses)

你可以使用 implements 語句檢查一個類是否滿足一個特定的 interface。如果一個類沒有正確的實現(implement)它,TypeScript 會報錯:

interface Pingable {
  ping(): void;
}
 
class Sonar implements Pingable {
  ping() {
    console.log("ping!");
  }
}
 
class Ball implements Pingable {
  // Class 'Ball' incorrectly implements interface 'Pingable'.
  // Property 'ping' is missing in type 'Ball' but required in type 'Pingable'.
  pong() {
    console.log("pong!");
  }
}

類也可以實現多個介面,比如 class C implements A, B {

注意事項(Cautions)

implements 語句僅僅檢查類是否按照介面型別實現,但它並不會改變類的型別或者方法的型別。一個常見的錯誤就是以為 implements 語句會改變類的型別——然而實際上它並不會:

interface Checkable {
  check(name: string): boolean;
}
 
class NameChecker implements Checkable {
  check(s) {
         // Parameter 's' implicitly has an 'any' type.
    // Notice no error here
    return s.toLowercse() === "ok";
                    // any
}

在這個例子中,我們可能會以為 s 的型別會被 checkname: string 引數影響。實際上並沒有,implements 語句並不會影響類的內部是如何檢查或者型別推斷的。

類似的,實現一個有可選屬性的介面,並不會建立這個屬性:

interface A {
  x: number;
  y?: number;
}
class C implements A {
  x = 0;
}
const c = new C();
c.y = 10;

// Property 'y' does not exist on type 'C'.

extends 語句(extends Clauses)

類可以 extend 一個基類。一個派生類有基類所有的屬性和方法,還可以定義額外的成員。

class Animal {
  move() {
    console.log("Moving along!");
  }
}
 
class Dog extends Animal {
  woof(times: number) {
    for (let i = 0; i < times; i++) {
      console.log("woof!");
    }
  }
}
 
const d = new Dog();
// Base class method
d.move();
// Derived class method
d.woof(3);

覆寫屬性(Overriding Methods)

一個派生類可以覆寫一個基類的欄位或屬性。你可以使用 super 語法訪問基類的方法。

TypeScript 強制要求派生類總是它的基類的子型別。

舉個例子,這是一個合法的覆寫方法的方式:

class Base {
  greet() {
    console.log("Hello, world!");
  }
}
 
class Derived extends Base {
  greet(name?: string) {
    if (name === undefined) {
      super.greet();
    } else {
      console.log(`Hello, ${name.toUpperCase()}`);
    }
  }
}
 
const d = new Derived();
d.greet();
d.greet("reader");

派生類需要遵循著它的基類的實現。

而且通過一個基類引用指向一個派生類例項,這是非常常見併合法的:

// Alias the derived instance through a base class reference
const b: Base = d;
// No problem
b.greet();

但是如果 Derived 不遵循 Base 的約定實現呢?

class Base {
  greet() {
    console.log("Hello, world!");
  }
}
 
class Derived extends Base {
  // Make this parameter required
  greet(name: string) {
    // Property 'greet' in type 'Derived' is not assignable to the same property in base type 'Base'.
  // Type '(name: string) => void' is not assignable to type '() => void'.
    console.log(`Hello, ${name.toUpperCase()}`);
  }
}

即便我們忽視錯誤編譯程式碼,這個例子也會執行錯誤:

const b: Base = new Derived();
// Crashes because "name" will be undefined
b.greet();

初始化順序(Initialization Order)

有些情況下,JavaScript 類初始化的順序會讓你感到很奇怪,讓我們看這個例子:

class Base {
  name = "base";
  constructor() {
    console.log("My name is " + this.name);
  }
}
 
class Derived extends Base {
  name = "derived";
}
 
// Prints "base", not "derived"
const d = new Derived();

到底發生了什麼呢?

類初始化的順序,就像在 JavaScript 中定義的那樣:

  • 基類欄位初始化
  • 基類建構函式執行
  • 派生類欄位初始化
  • 派生類建構函式執行

這意味著基類建構函式只能看到它自己的 name 的值,因為此時派生類欄位初始化還沒有執行。

繼承內建型別(Inheriting Built-in Types)

注意:如果你不打算繼承內建的型別比如 ArrayErrorMap 等或者你的編譯目標是 ES6/ES2015 或者更新的版本,你可以跳過這個章節。

在 ES2015 中,當呼叫 super(...) 的時候,如果建構函式返回了一個物件,會隱式替換 this 的值。所以捕獲 super() 可能的返回值並用 this 替換它是非常有必要的。

這就導致,像 ErrorArray 等子類,也許不會再如你期望的那樣執行。這是因為 ErrorArray 等類似內建物件的建構函式,會使用 ECMAScript 6 的 new.target 調整原型鏈。然而,在 ECMAScript 5 中,當呼叫一個建構函式的時候,並沒有方法可以確保 new.target 的值。 其他的降級編譯器預設也會有同樣的限制。

對於一個像下面這樣的子類:

class MsgError extends Error {
  constructor(m: string) {
    super(m);
  }
  sayHello() {
    return "hello " + this.message;
  }
}

你也許可以發現:

  1. 物件的方法可能是 undefined ,所以呼叫 sayHello 會導致錯誤
  2. instanceof 失效, (new MsgError()) instanceof MsgError 會返回 false

我們推薦,手動的在 super(...) 呼叫後調整原型:

class MsgError extends Error {
  constructor(m: string) {
    super(m);
 
    // Set the prototype explicitly.
    Object.setPrototypeOf(this, MsgError.prototype);
  }
 
  sayHello() {
    return "hello " + this.message;
  }
}

不過,任何 MsgError 的子類也不得不手動設定原型。如果執行時不支援 Object.setPrototypeOf,你也許可以使用 __proto__

不幸的是,這些方案並不會能在 IE 10 或者之前的版本正常執行。解決的一個方法是手動拷貝原型中的方法到例項中(就比如 MsgError.prototypethis),但是它自己的原型鏈依然沒有被修復。

成員可見性(Member Visibility)

你可以使用 TypeScript 控制某個方法或者屬性是否對類以外的程式碼可見。

public

類成員預設的可見性為 public,一個 public 的成員可以在任何地方被獲取:

class Greeter {
  public greet() {
    console.log("hi!");
  }
}
const g = new Greeter();
g.greet();

因為 public 是預設的可見性修飾符,所以你不需要寫它,除非處於格式或者可讀性的原因。

protected

protected 成員僅僅對子類可見:

class Greeter {
  public greet() {
    console.log("Hello, " + this.getName());
  }
  protected getName() {
    return "hi";
  }
}
 
class SpecialGreeter extends Greeter {
  public howdy() {
    // OK to access protected member here
    console.log("Howdy, " + this.getName());
  }
}
const g = new SpecialGreeter();
g.greet(); // OK
g.getName();

// Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses.

受保護成員的公開(Exposure of protected members)

派生類需要遵循基類的實現,但是依然可以選擇公開擁有更多能力的基類子型別,這就包括讓一個 protected 成員變成 public

class Base {
  protected m = 10;
}
class Derived extends Base {
  // No modifier, so default is 'public'
  m = 15;
}
const d = new Derived();
console.log(d.m); // OK

這裡需要注意的是,如果公開不是故意的,在這個派生類中,我們需要小心的拷貝 protected 修飾符。

交叉等級受保護成員訪問(Cross-hierarchy protected access)

不同的 OOP 語言在通過一個基類引用是否可以合法的獲取一個 protected 成員是有爭議的。

class Base {
  protected x: number = 1;
}
class Derived1 extends Base {
  protected x: number = 5;
}
class Derived2 extends Base {
  f1(other: Derived2) {
    other.x = 10;
  }
  f2(other: Base) {
    other.x = 10;
        // Property 'x' is protected and only accessible through an instance of class 'Derived2'. This is an instance of class 'Base'.
  }
}

在 Java 中,這是合法的,而 C# 和 C++ 認為這段程式碼是不合法的。

TypeScript 站在 C# 和 C++ 這邊。因為 Derived2x 應該只有從 Derived2 的子類訪問才是合法的,而 Derived1 並不是它們中的一個。此外,如果通過 Derived1 訪問 x 是不合法的,通過一個基類引用訪問也應該是不合法的。

看這篇《Why Can’t I Access A Protected Member From A Derived Class?》,解釋了更多 C# 這樣做的原因。

private

private 有點像 protected ,但是不允許訪問成員,即便是子類。

class Base {
  private x = 0;
}
const b = new Base();
// Can't access from outside the class
console.log(b.x);
// Property 'x' is private and only accessible within class 'Base'.
class Derived extends Base {
  showX() {
    // Can't access in subclasses
    console.log(this.x);
        // Property 'x' is private and only accessible within class 'Base'.
  }
}

因為 private 成員對派生類並不可見,所以一個派生類也不能增加它的可見性:

class Base {
  private x = 0;
}
class Derived extends Base {
// Class 'Derived' incorrectly extends base class 'Base'.
// Property 'x' is private in type 'Base' but not in type 'Derived'.
  x = 1;
}

交叉例項私有成員訪問(Cross-instance private access)

不同的 OOP 語言在關於一個類的不同例項是否可以獲取彼此的 private 成員上,也是不一致的。像 Java、C#、C++、Swift 和 PHP 都是允許的,Ruby 是不允許。

TypeScript 允許交叉例項私有成員的獲取:

class A {
  private x = 10;
 
  public sameAs(other: A) {
    // No error
    return other.x === this.x;
  }
}

警告(Caveats)

privateprotected 僅僅在型別檢查的時候才會強制生效。

這意味著在 JavaScript 執行時,像 in 或者簡單的屬性查詢,依然可以獲取 private 或者 protected 成員。

class MySafe {
  private secretKey = 12345;
}
// In a JavaScript file...
const s = new MySafe();
// Will print 12345
console.log(s.secretKey);

private 允許在型別檢查的時候,通過方括號語法進行訪問。這讓比如單元測試的時候,會更容易訪問 private 欄位,這也讓這些欄位是弱私有(soft private)而不是嚴格的強制私有。

class MySafe {
  private secretKey = 12345;
}
 
const s = new MySafe();
 
// Not allowed during type checking
console.log(s.secretKey);
// Property 'secretKey' is private and only accessible within class 'MySafe'.
 
// OK
console.log(s["secretKey"]);

不像 TypeScript 的 private,JavaScript 的私有欄位#)即便是編譯後依然保留私有性,並且不會提供像上面這種方括號獲取的方法,這讓它們變得強私有(hard private)。

class Dog {
  #barkAmount = 0;
  personality = "happy";
 
  constructor() {}
}
"use strict";
class Dog {
    #barkAmount = 0;
    personality = "happy";
    constructor() { }
}
 

當被編譯成 ES2021 或者之前的版本,TypeScript 會使用 WeakMaps 替代 #:

"use strict";
var _Dog_barkAmount;
class Dog {
    constructor() {
        _Dog_barkAmount.set(this, 0);
        this.personality = "happy";
    }
}
_Dog_barkAmount = new WeakMap();

如果你需要防止惡意攻擊,保護類中的值,你應該使用強私有的機制比如閉包,WeakMaps ,或者私有欄位。但是注意,這也會在執行時影響效能。

TypeScript 系列

TypeScript 系列文章由官方文件翻譯、重難點解析、實戰技巧三個部分組成,涵蓋入門、進階、實戰,旨在為你提供一個系統學習 TS 的教程,全系列預計 40 篇左右。點此瀏覽全系列文章,並建議順便收藏站點。

微信:「mqyqingfeng」,加我進冴羽唯一的讀者群。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章