TypeScript 官方手冊翻譯計劃【十二】:類

Chor發表於2021-12-11
  • 說明:目前網上沒有 TypeScript 最新官方文件的中文翻譯,所以有了這麼一個翻譯計劃。因為我也是 TypeScript 的初學者,所以無法保證翻譯百分之百準確,若有錯誤,歡迎評論區指出;
  • 翻譯內容:暫定翻譯內容為 TypeScript Handbook,後續有空會補充翻譯文件的其它部分;
  • 專案地址TypeScript-Doc-Zh,如果對你有幫助,可以點一個 star ~

本章節官方文件地址:Classes

背景導讀:類(MDN)

TypeScript 為 ES2015 引入的 class 關鍵字提供了全面的支援。

就像其它的 JavaScript 語言特性一樣,TypeScript 也為類提供了型別註解和其它語法,以幫助開發者表示類和其它型別之間的關係。

類成員

這是一個最基本的類 —— 它是空的:

class Point {}

這個類目前沒有什麼用,所以我們給它新增一些成員吧。

欄位

宣告欄位相當於是給類新增了一個公共的、可寫的屬性:

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

和其它特性一樣,這裡的型別註解也是可選的,但如果沒有指定型別,則會隱式採用 any 型別。

欄位也可以進行初始化,初始化過程會在類例項化的時候自動進行:

class Point {
  x = 0;
  y = 0;
}
 
const pt = new Point();
// 列印 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 OKGreeter {
  // 沒有初始化,但不會報錯
  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.

構造器

類的構造器和函式很像,你可以給它的引數新增型別註解,可以使用引數預設值或者是函式過載:

class Point {
    x: number;
    y: number;
    // 使用了引數預設值的正常簽名
    constructor(x = 0, y = 0) {
        this.x = x;
        this.y = y;
    }
}
class Point {
  // 使用過載
  constructor(x: number, y: string);
  constructor(s: string);
  constructor(xs: any, y?: any) {
    // TBD
  }
}

類的構造器簽名和函式簽名只有一點區別:

  • 構造器不能使用型別引數 —— 型別引數屬於類宣告的部分,稍後我們會進行學習
  • 構造器不能給返回值新增型別註解 —— 它返回的型別始終是類例項的型別
super 呼叫

和 JavaScript 一樣,如果你有一個基類和一個派生類,那麼在派生類中使用 this. 訪問類成員之前,必須先在構造器中呼叫 super();

class Base {
  k = 4;
}
 
class Derived extends Base {
  constructor() {
    // ES5 下列印出錯誤的值,ES6 下報錯
    console.log(this.k);
                  ^
// 'super' must be called before accessing 'this' in the constructor of a derived class.
    super();
  }
}

在 JavaScript 中,忘記呼叫 super 是一個常見的錯誤,但 TypeScript 會在必要時給你提醒。

方法

類的屬性可能是一個函式,這時候我們稱其為方法。方法和函式以及構造器一樣,也可以使用各種型別註解:

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

除了標準的型別註解之外,TypeScript 沒有給方法新增什麼新的東西。

注意,在方法體中,必須通過 this. 才能訪問到類的欄位和其它方法。在方法體中使用不合規的名字,將會被視為是在訪問鄰近作用域中的變數:

let x: number = 0;
 
class C {
  x: string = "hello";
 
  m() {
    // 下面這句是在試圖修改第一行的 x,而不是類的屬性
    x = "world";
    ^  
// Type 'string' is not assignable to type 'number'.
  }
}

Getters/Setters

類也可以有訪問器:

class C {
    _length = 0;
    get length(){
        return this._length;
    }
    set length(value){
        this._length = value;
    }
}
注意:在 JavaScript 中,一個沒有額外邏輯的 get/set 對是沒有什麼作用的。如果在執行 get/set 操作的時候不需要新增額外的邏輯,那麼只需要將欄位暴露為公共欄位即可。

對於訪問器,TypeScript 有一些特殊的推斷規則:

  • 如果 get 存在而 set 不存在,那麼屬性會自動成為只讀屬性
  • 如果沒有指定 setter 引數的型別,那麼會基於 getter 返回值的型別去推斷引數型別
  • getter 和 setter 必須具備相同的成員可見性

TypeScript 4.3 開始,訪問器的 getter 和 setter 可以使用不同的型別。

class Thing {
  _size = 0;
 
  get size(): number {
    return this._size;
  }
 
  set size(value: string | number | boolean) {
    let num = Number(value);
 
    // 不允許使用 NaN、Infinity 等
 
    if (!Number.isFinite(num)) {
      this._size = 0;
      return;
    }
 
    this._size = num;
  }
}

索引簽名

類可以宣告索引簽名,其工作方式和其它物件型別的索引簽名一樣:

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

因為索引簽名型別也需要捕獲方法的型別,所以要有效地使用這些型別並不容易。通常情況下,最好將索引資料儲存在另一個位置,而不是類例項本身。

類繼承

和其它面嚮物件語言一樣,JavaScript 中的類可以繼承自基類。

implements 子句

你可以使用一個 implements 子句去檢查類是否符合某個特定的介面。如果類沒有正確地實現這個介面,那麼就會丟擲一個錯誤:

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 {

注意事項

有個要點需要理解,那就是 implements 子句只是用於檢查類是否可以被視為某個介面型別,它完全不會改變類的型別或者它的方法。常見的錯誤是認為 implements 子句會改變類的型別 —— 實際上是不會的!

interface Checkable {
  check(name: string): boolean;
}
 
class NameChecker implements Checkable {
  check(s) {
        ^
//Parameter 's' implicitly has an 'any' type.
    // 注意這裡不會丟擲錯誤
    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 子句

類可以繼承自某個基類。派生類擁有基類的所有屬性和方法,同時也可以定義額外的成員。

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();
// 基類方法
d.move();
// 派生類方法
d.woof(3);
重寫方法

派生類也可以重寫基類的欄位或者屬性。你可以使用 super. 語法訪問基類的方法。注意,由於 JavaScript 的類只是一個簡單的查詢物件,所以不存在“父類欄位”的概念。

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");

很重要的一點是,派生類會遵循基類的約束。通過一個基類引用去引用一個派生類,是很常見(並且總是合法的!)的一種做法:

// 通過一個基類引用去命名一個派生類例項
const b: Base = d;
// 沒有問題
b.greet();

如果派生類 Derived 沒有遵循基類 Base 的約束,會怎麼樣呢?

class Base {
  greet() {
    console.log("Hello, world!");
  }
}
 
class Derived extends Base {
  // 讓這個引數成為必選引數
  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();
// 因為 name 是 undefined,所以報錯
b.greet();
初始化順序

JavaScript 類的初始化順序在某些情況下可能會讓你感到意外。我們看看下面的程式碼:

class Base {
  name = "base";
  constructor() {
    console.log("My name is " + this.name);
  }
}
 
class Derived extends Base {
  name = "derived";
}
 
// 列印 base 而不是 derived
const d = new Derived();

這裡發生了什麼事呢?

根據 JavaScript 的定義,類初始化的順序是:

  • 初始化基類的欄位
  • 執行基類的構造器
  • 初始化派生類的欄位
  • 執行派生類的構造器

這意味著,因為基類構造器執行的時候派生類的欄位尚未進行初始化,所以基類構造器只能看到自己的 name 值。

繼承內建型別
注意:如果你不打算繼承諸如 Array、Error、Map 等內建型別,或者你的編譯目標顯式設定為 ES6/ES2015 或者更高的版本,那麼你可以跳過這部分的內容。

在 ES2015 中,返回例項物件的構造器會隱式地將 this 的值替換為 super(...) 的任意呼叫者。有必要讓生成的構造器程式碼捕獲 super(...) 的任意潛在的返回值,並用 this 替換它。

因此,ErrorArray 等的子類可能無法如預期那樣生效。這是因為諸如 ErrorArray 這樣的建構函式使用了 ES6 的 new.target 去調整原型鏈,但是,在 ES5 中呼叫構造器函式的時候,沒有類似的方法可以確保 new.target 的值。預設情況下,其它底層編譯器通常也具有相同的限制。

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

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

你可能會發現:

  • 呼叫子類之後返回的例項物件,其方法可能是 undefined,所以呼叫 sayHello 將會丟擲錯誤
  • 子類例項和子類之間的 instanceof 可能被破壞,所以 (new MsgError()) instanceof MsgError 將會返回 false

推薦的做法是,在任意的 super(...) 呼叫後面手動地調整原型鏈:

class MsgError extends Error {
  constructor(m: string) {
    super(m);
    // 顯式設定原型鏈
    Object.setPrototypeOf(this, MsgError.prototype);
  }
 
  sayHello() {
    return "hello " + this.message;
  }
}

不過,MsgError 的任意子類也需要手動設定原型。對於不支援 Object.setPrototypeOf 的執行時,你可以改用 __proto__

糟糕的是,這些變通方法在 IE10 或者更舊的版本上無法使用.aspx)。你可以手動將原型上的方法複製到例項上(比如將 MsgError.prototype 的方法複製給 this),但原型鏈本身無法被修復。

成員可見性

你可以使用 TypeScript 控制特定的方法或屬性是否在類的外面可見。

public

類成員的預設可見性是公有的(public)。公有成員隨處可以訪問:

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

由於成員的可見性預設就是公有的,所以你不需要在類成員前面進行顯式宣告,但出於程式碼規範或者可讀性的考慮,你也可以這麼做。

protected

受保護(protected)成員只在類的子類中可見。

class Greeter {
  public greet() {
    console.log("Hello, " + this.getName());
  }
  protected getName() {
    return "hi";
  }
}
 
class SpecialGreeter extends Greeter {
  public howdy() {
    // 這裡可以訪問受保護成員
    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.
公開受保護成員

派生類需要遵循其基類的約束,但可以選擇公開具有更多功能的基類的子類。這包括了讓受保護成員變成公有成員:

class Base {
    protected m = 10;
}
class Derived extends Base {
    // 沒有修飾符,所以預設可見性是公有的
    m = 15;
}
const d = new Dervied();
console.log(d.m);  // OK

注意 Dervied 已經可以自由讀寫成員 m 了,所以這麼寫並不會改變這種情況的“安全性”。這裡需要注意的要點是,在派生類中,如果我們無意公開其成員,那麼需要新增 protected 修飾符。

跨層級訪問受保護成員

對於通過一個基類引用訪問受保護成員是否合法,不同的 OOP 語言之間存在爭議:

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 也認為這是不合法的,因為只有在 Derived2 的子類中訪問 Derived2x 才是合法的,但 Derived1 並不是 Derived2 的子類。而且,如果通過 Derived1 引用訪問 x 就已經是不合法的了(這確實應該是不合法的!),那麼通過基類引用訪問它也同樣應該是不合法的。

關於 C# 為什麼會認為這段程式碼是不合法的,可以閱讀這篇文章瞭解更多資訊:為什麼我無法在一個派生類中去訪問一個受保護成員?

private

privateprotected 一樣,但宣告瞭 private 的私有成員即使在子類中也無法被訪問到:

class Base {
    private x = 0;
}
const b = new Base();
// 無法在類外面訪問
console.log(b.x);
// Property 'x' is private and only accessible within class 'Base'.
class Derived extends Base {
  showX() {
    // 無法在子類中訪問
    console.log(this.x);
                      ^    
// Property 'x' is private and only accessible within class 'Base'.
  }
}

由於私有成員對派生類不可見,所以派生類無法提高其可見性:

class Base {
    private x = 0;
}
class Dervied extends Base {
/*
Class 'Derived' incorrectly extends base class 'Base'.
  Property 'x' is private in type 'Base' but not in type 'Derived'.    
*/  
    x = 1;
}
跨例項訪問私有成員

對於同一個類的不同例項互相訪問對方的私有成員是否合法,不同的 OOP 語言之間存在爭議。Java、C#、C++、Swift 和 PHP 允許這麼做,但 Ruby 則認為這樣做是不合法的。

TypeScript 允許跨例項訪問私有成員:

class A {
    private x = 10;
    public sameAs(other: A) {
        // 不會報錯
        return other.x === this.x;
    }
}
注意事項

和 TypeScript 型別系統中的其它東西一樣,privateprotected 只在型別檢查期間生效

這意味著 JavaScript 執行時的一些操作,諸如 in 或者簡單的屬性查詢仍然可以訪問私有成員或者受保護成員:

class MySafe {
    private serectKey = 123345;
}
// 在 JavaScript 檔案中會列印 12345
const s = new MySafe();
console.log(s.secretKey);

而即使是在型別檢查期間,我們也可以通過方括號語法去訪問私有成員。因此,在進行諸如單元測試這樣的操作時,訪問私有欄位會比較容易,但缺點就是這些欄位是“弱私有的”,無法保證嚴格意義上的私有性。

class MySafe {
  private secretKey = 12345;
}
 
const s = new MySafe();
 
// 在型別檢查期間,不允許這樣訪問私有成員
console.log(s.secretKey);
                ^
// Property 'secretKey' is private and only accessible within class 'MySafe'.
 
// 但是可以通過方括號語法訪問
console.log(s["secretKey"]);

和 TypeScript 用 private 宣告的私有成員不同,JavaScript 用 # 宣告的私有欄位在編譯之後也仍然是私有的,並且沒有提供像上面那樣的方括號語法用於訪問私有成員,所以 JavaScript 的私有成員是“強私有的”。

class Dog {
    #barkAmount = 0;
    personality = 'happy';
    
    constructor() {}
}

以下面這段 TypeScript 程式碼為例:

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

把它編譯為 ES2021 或者更低版本的程式碼之後,TypeScript 會使用 WeakMap 代替 #

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

如果你需要保護類中的值不被惡意修改,那麼你應該使用提供了執行時私有性保障的機制,比如閉包、WeakMap 或者私有欄位等。注意,這些在執行時新增的私有性檢查可能會影響效能。

靜態成員

背景導讀:靜態成員(MDN)

類可以擁有靜態(static)成員。這些成員和類的特定例項無關,我們可以通過類構造器物件本身訪問到它們:

class MyClass {
    static x = 0;
    static printX(){
        console.log(MyClass.x);
    }
}
console.log(MyClass.x);
MyClass.printX();

靜態成員也可以使用 publicprotectedprivate 等可見性修飾符:

class MyClass {
    private static x = 0;
}
console.log(MyClass.x);
                  ^
// Property 'x' is private and only accessible within class 'MyClass'.           

靜態成員也可以被繼承:

class Base {
  static getGreeting() {
    return "Hello world";
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting();
}

特殊的靜態成員名字

重寫 Function 原型的屬性通常是不安全/不可能的。因為類本身也是一個可以通過 new 呼叫的函式,所以無法使用一些特定的靜態成員名字。諸如 namelengthcall 這樣的函式屬性無法作為靜態成員的名字:

class S {
    static name = 'S!';
            ^
// Static property 'name' conflicts with built-in property 'Function.name' of constructor function 'S'.                
}

為什麼沒有靜態類?

TypeScript(和 JavaScript)並沒有像 C# 和 Java 那樣提供靜態類這種結構。

C# 和 Java 之所以需要靜態類,是因為這些語言要求所有的資料和函式必須放在一個類中。因為在 TypeScirpt 中不存在這個限制,所以也就不需要靜態類。只擁有單個例項的類在 JavaScript/TypeScirpt 中通常用一個普通物件表示。

舉個例子,在 TypeScript 中我們不需要“靜態類”語法,因為一個常規的物件(甚至是頂層函式)也可以完成相同的工作:

// 不必要的靜態類
class MyStaticClass {
    static doSomething() {}
}
// 首選(方案一)
function doSomething() {}

// 首選(方案二)
const MyHelperObject = {
  dosomething() {},
};

類中的靜態塊

靜態塊允許你編寫一系列宣告語句,它們擁有自己的作用域,並且可以訪問包含類中的私有欄位。這意味著我們能夠編寫初始化程式碼,這些程式碼包含了宣告語句,不會有變數洩漏的問題,並且完全可以訪問類的內部。

class Foo {
    static #count = 0;
    get count(){
        return Foo.#count;
    }
    static {
        try {
            const lastInstances = loadLastInstances();
            Foo.#count += lastInstances.length;
        }
        catch {}
    }
}

泛型類

類和介面一樣,也可以使用泛型。當用 new 例項化一個泛型類的時候,它的型別引數就像在函式呼叫中那樣被推斷出來:

class Box<Type> {
    contents: Type;
    constructor(value: Type){
        this.contents = value;
    }
}
const b = new Box('hello!');
      ^    
    // const b: Box<string>      

類可以像介面那樣使用泛型約束和預設值。

靜態成員中的型別引數

下面的程式碼是不合法的,但原因可能不那麼明顯:

class Box<Type> {
    static defaultValue: Type;
                        ^
//  Static members cannot reference class type parameters.                       
}

記住,型別在編譯後總是會被完全抹除的!在執行時,只有一個 Box.defaultValue 屬性插槽。這意味著設定 Box<string>.defaultValue(如果可以設定的話)也會改變 Box<number>.defaultValue —— 這是不行的。泛型類的靜態成員永遠都不能引用類的型別引數。

類的執行時 this

有個要點需要記住,那就是 TypeScript 不會改變 JavaScript 的執行時行為。而眾所周知,JavaScript 擁有一些特殊的執行時行為。

JavaScript 對於 this 的處理確實是很不尋常:

class MyClass {
  name = "MyClass";
  getName() {
    return this.name;
  }
}
const c = new MyClass();
const obj = {
  name: "obj",
  getName: c.getName,
};
 
// 列印 "obj" 而不是 "MyClass"
console.log(obj.getName());

長話短說,預設情況下,函式中 this 的值取決於函式是如何被呼叫的。在這個例子中,由於我們通過 obj 引用去呼叫函式,所以它的 this 的值是 obj,而不是類例項。

這通常不是我們期望的結果!TypeScript 提供了一些方法讓我們可以減少或者防止這種錯誤的發生。

箭頭函式

如果你的函式在被呼叫的時候經常會丟失 this 上下文,那麼最好使用箭頭函式屬性,而不是方法定義:

class MyClass {
    name = 'MyClass';
    getName = () => {
        return this.name;
    };
}
const c = new MyClass();
const g = c.getName;
// 列印 MyClass 
console.log(g());

這種做法有一些利弊權衡:

  • 在執行時可以保證 this 的值是正確的,即使對於那些沒有使用 TypeScript 進行檢查的程式碼也是如此
  • 這樣會佔用更多記憶體,因為以這種方式定義的函式,會導致每個類例項都有一份函式副本
  • 你無法在派生類中使用 super.getName,因為在原型鏈上沒有入口可以去獲取基類的方法

this 引數

在 TypeScript 的方法或者函式定義中,第一個引數的名字如果是 this,那麼它有特殊的含義。這樣的引數在編譯期間會被抹除:

// TypeScript 接受 this 引數
function fn(this: SomeType, x: number) {
    /* ... */
}
// 輸出得 JavaScript 
function fn(x) {
    /* ... */
}

TypeScript 會檢查傳入 this 引數的函式呼叫是否位於正確的上下文中。這裡我們沒有使用箭頭函式,而是給方法定義新增了一個 this 引數,以靜態的方式確保方法可以被正確呼叫:

class MyClass {
  name = "MyClass";
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// OK
c.getName();
 
// 報錯
const g = c.getName;
console.log(g());
// The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'.

這種方法的利弊權衡和上面使用箭頭函式的方法相反:

  • JavaScript 的呼叫方可能仍然會在沒有意識的情況下錯誤地呼叫類方法
  • 只會給每個類定義分配一個函式,而不是給每個類例項分配一個函式
  • 仍然可以通過 super 呼叫基類定義的方法

this 型別

在類中,名為 this 的特殊型別可以動態地引用當前類的型別。我們看一下它是怎麼發揮作用的:

class Box {
    contents: string = "";
    set(value: string){
     ^
    // (method) Box.set(value: string): this
         this.contents = value;
        return this;
    }
}

這裡,TypeScript 將 set 的返回值型別推斷為 this,而不是 Box。現在我們來建立一個 Box 的子類:

class ClearableBox extends Box {
    clear() {
        this.contents = "";
    }
}
const a = new ClearableBox();
const b = a.set("hello");
      ^
// const b: ClearableBox

你也可以在引數的型別註解中使用 this

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

這和使用 other: Box 是不一樣的 —— 如果你有一個派生類,那麼它的 sameAs 方法將只會接受該派生類的其它例項:

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}
 
class DerivedBox extends Box {
  otherContent: string = "?";
}
 
const base = new Box();
const derived = new DerivedBox();
derived.sameAs(base);
                ^
/*
Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'.
  Property 'otherContent' is missing in type 'Box' but required in type 'DerivedBox'.
*/  

基於 this 的型別保護

你可以在類和介面的方法的返回值型別註解處使用 this is Type。該語句和型別收縮(比如說 if 語句)一起使用的時候,目標物件的型別會被收縮為指定的 Type

class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
  isNetworked(): this is Networked & this {
    return this.networked;
  }
  constructor(public path: string, private networked: boolean) {}
}
 
class FileRep extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false);
  }
}
 
class Directory extends FileSystemObject {
  children: FileSystemObject[];
}
 
interface Networked {
  host: string;
}
 
const fso: FileSystemObject = new FileRep("foo/bar.txt", "foo");
 
if (fso.isFile()) {
  fso.content;
   ^
 // const fso: FileRep
} else if (fso.isDirectory()) {
  fso.children;
   ^ 
 // const fso: Directory
} else if (fso.isNetworked()) {
  fso.host;
   ^ 
 // const fso: Networked & FileSystemObject
}

基於 this 的型別保護的常見用例是允許特定欄位的延遲驗證。以下面的程式碼為例,當 hasValue 被驗證為 true 的時候,可以移除 Box 中為 undefinedvalue 值:

class Box<T> {
  value?: T;
 
  hasValue(): this is { value: T } {
    return this.value !== undefined;
  }
}
 
const box = new Box();
box.value = "Gameboy";
 
box.value;
      ^
    // (property) Box<unknown>.value?: unknown
 
if (box.hasValue()) {
  box.value;
        ^   
   // (property) value: unknown
}

引數屬性

TypeScript 提供了一種特殊的語法,可以將構造器引數轉化為具有相同名字和值的類屬性。這種語法叫做引數屬性,實現方式是在構造器引數前面加上 publicprivateprotected 或者 readonly 等其中一種可見性修飾符作為字首。最終的欄位將會獲得這些修飾符:

class Params {
    constructor(
        public readonly x: number,
        protected y: number,
        private z: number    
    ) {
        // 沒有必要編寫構造器的函式體     
    }    
}
const a = new Params(1,2,3);
console.log(a.x);
             ^
            // (property) Params.x: number
console.log(a.z);
             ^
// Property 'z' is private and only accessible within class 'Params'.           

類表示式

背景導讀:類表示式(MDN)

類表示式和類宣告非常相似。唯一的不同在於,類表示式不需要名字,但我們仍然可以通過任意繫結給類表示式的識別符號去引用它們:

const someClass = class<Type> {
    content: Type;
    constructor(value: Type) {
        this.content = value;
    }
};

const m = new someClass("Hello, world");
      ^
    // const m: someClass<string>      

抽象類和成員

在 TypeScript 中,類、方法和欄位可能是抽象的。

抽象方法或者抽象欄位在類中沒有對應的實現。這些成員必須存在於一個無法直接被例項化的抽象類中。

抽象類的角色是充當一個基類,讓其子類去實現所有的抽象成員。當一個類沒有任何抽象成員的時候,我們就說它是具體的。

來看一個例子:

abstract class Base {
    abstract getName(): string;
    printName(){
        console.log("Hello, " + this.getName());
    }
}

const b = new Base();
// Cannot create an instance of an abstract class.

因為 Base 是一個抽象類,所以我們不能使用 new 去例項化它。相反地,我們需要建立一個派生類,讓它去實現抽象成員:

class Derived extends Base {
    getName() {
        rteurn "world";
    }
}

const d = new Derived();
d.printName();

注意,如果我們忘記實現基類的抽象成員,那麼會丟擲一個錯誤:

class Derived extends Base {
        ^
// Non-abstract class 'Derived' does not implement inherited abstract member 'getName' from class 'Base'.
  // 忘記實現抽象成員
}

抽象構造簽名

有時候你想要接受一個類構造器函式作為引數,讓它產生某個類的例項,並且這個類是從某個抽象類派生過來的。

舉個例子,你可能想要編寫下面這樣的程式碼:

function greet(ctor: typeof Base) {
  const instance = new ctor();
// Cannot create an instance of an abstract class.
  instance.printName();
}

TypeScript 會正確地告訴你,你正試圖例項化一個抽象類。畢竟,根據 greet 的定義,編寫這樣的程式碼理應是完全合法的,它最終會構造一個抽象類的例項:

// 不行!
greet(Base);

但它實際上會報錯。所以,你編寫的函式所接受的引數應該帶有一個構造簽名:

function greet(ctor: new () => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);
greet(Base);
       ^    
/*
Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'.
  Cannot assign an abstract constructor type to a non-abstract constructor type.
*/  

現在 TypeScript 可以正確地告知你哪個類構造器函式可以被呼叫了 —— Derived 可以被呼叫,因為它是一個具體類,而 Base 不能被呼叫,因為它是一個抽象類。

類之間的聯絡

在大多數情況下,TypeScript 中的類是在結構上進行比較的,就跟其它型別一樣。

舉個例子,下面這兩個類可以互相替代對方,因為它們在結構上是一模一樣的:

class Point1 {
  x = 0;
  y = 0;
}
 
class Point2 {
  x = 0;
  y = 0;
}
 
// OK
const p: Point1 = new Point2();

類似地,即使沒有顯式宣告繼承關係,類和類之間也可以存在子類聯絡:

class Person {
  name: string;
  age: number;
}
 
class Employee {
  name: string;
  age: number;
  salary: number;
}
 
// OK
const p: Person = new Employee();

這聽起來很簡單易懂,但還有一些情況會比較奇怪。

空類沒有成員。在一個結構化的型別系統中,一個沒有成員的型別通常是任何其它型別的超類。所以如果你編寫了一個空類(不要這麼做!),那麼你可以用任何型別去替代它:

class Empty {}

function fn(x: Empty) {
    // 無法對 x 執行任何操作,所以不建議這麼寫
}

// 這些引數都是可以傳入的!
fn(window);
fn({});
fn(fn);

相關文章