TypeScript 之 Class(下)

冴羽發表於2021-12-16

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

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

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

靜態成員(Static Members)

類可以有靜態成員,靜態成員跟類例項沒有關係,可以通過類本身訪問到:

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

靜態成員同樣可以使用 public protectedprivate 這些可見性修飾符:

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();
}

特殊靜態名稱(Special Static Names)

類本身是函式,而覆寫 Function 原型上的屬性通常認為是不安全的,因此不能使用一些固定的靜態名稱,函式屬性像 namelengthcall 不能被用來定義 static 成員:

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

為什麼沒有靜態類?(Why No Static Classes?)

TypeScript(和 JavaScript) 並沒有名為靜態類(static class)的結構,但是像 C# 和 Java 有。

所謂靜態類,指的是作為類的靜態成員存在於某個類的內部的類。比如這種:

// java
public class OuterClass {
  private static String a = "1";
    static class InnerClass {
      private int b = 2;
  }
}

靜態類之所以存在是因為這些語言強迫所有的資料和函式都要在一個類內部,但這個限制在 TypeScript 中並不存在,所以也沒有靜態類的需要。一個只有一個單獨例項的類,在 JavaScript/TypeScript 中,完全可以使用普通的物件替代。

舉個例子,我們不需要一個 static class 語法,因為 TypeScript 中一個常規物件(或者頂級函式)可以實現一樣的功能:

// Unnecessary "static" class
class MyStaticClass {
  static doSomething() {}
}
 
// Preferred (alternative 1)
function doSomething() {}
 
// Preferred (alternative 2)
const MyHelperObject = {
  dosomething() {},
};

類靜態塊(static Blocks in Classes)

靜態塊允許你寫一系列有自己作用域的語句,也可以獲取類裡的私有欄位。這意味著我們可以安心的寫初始化程式碼:正常書寫語句,無變數洩漏,還可以完全獲取類中的屬性和方法。

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

泛型類(Generic Classes)

類跟介面一樣,也可以寫泛型。當使用 new 例項化一個泛型類,它的型別引數的推斷跟函式呼叫是同樣的方式:

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

類跟介面一樣也可以使用泛型約束以及預設值。

靜態成員中的型別引數(Type Parameters in Static Members)

這程式碼並不合法,但是原因可能並沒有那麼明顯:

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

記住型別會被完全抹除,執行時,只有一個 Box.defaultValue 屬性槽。這也意味著如果設定 Box<string>.defaultValue 是可以的話,這也會改變 Box<number>.defaultValue,而這樣是不好的。

所以泛型類的靜態成員不應該引用類的型別引數。

類執行時的 this(this at Runtime in Classes)

TypeScript 並不會更改 JavaScript 執行時的行為,並且 JavaScript 有時會出現一些奇怪的執行時行為。

就比如 JavaScript 處理 this 就很奇怪:

class MyClass {
  name = "MyClass";
  getName() {
    return this.name;
  }
}
const c = new MyClass();
const obj = {
  name: "obj",
  getName: c.getName,
};
 
// Prints "obj", not "MyClass"
console.log(obj.getName());

預設情況下,函式中 this 的值取決於函式是如何被呼叫的。在這個例子中,因為函式通過 obj 被呼叫,所以 this 的值是 obj 而不是類例項。

這顯然不是你所希望的。TypeScript 提供了一些方式緩解或者阻止這種錯誤。

箭頭函式(Arrow Functions)

如果你有一個函式,經常在被呼叫的時候丟失 this 上下文,使用一個箭頭函式或許更好些。

class MyClass {
  name = "MyClass";
  getName = () => {
    return this.name;
  };
}
const c = new MyClass();
const g = c.getName;
// Prints "MyClass" instead of crashing
console.log(g());

這裡有幾點需要注意下:

  • this 的值在執行時是正確的,即使 TypeScript 不檢查程式碼
  • 這會使用更多的記憶體,因為每一個類例項都會拷貝一遍這個函式。
  • 你不能在派生類使用 super.getName ,因為在原型鏈中並沒有入口可以獲取基類方法。

this 引數(this parameters)

在 TypeScript 方法或者函式的定義中,第一個引數且名字為 this 有特殊的含義。該引數會在編譯的時候被抹除:

// TypeScript input with 'this' parameter
function fn(this: SomeType, x: number) {
  /* ... */
}
// JavaScript output
function fn(x) {
  /* ... */
}

TypeScript 會檢查一個有 this 引數的函式在呼叫時是否有一個正確的上下文。不像上個例子使用箭頭函式,我們可以給方法定義新增一個 this 引數,靜態強制方法被正確呼叫:

class MyClass {
  name = "MyClass";
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// OK
c.getName();
 
// Error, would crash
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 Types)

在類中,有一個特殊的名為 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-based type guards)

你可以在類和介面的方法返回的位置,使用 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 的型別保護的使用例子,會對一個特定的欄位進行懶校驗(lazy validation)。舉個例子,在這個例子中,當 hasValue 被驗證為 true 時,會從型別中移除 undefined

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
}

引數屬性(Parameter Properties)

TypeScript 提供了特殊的語法,可以把一個建構函式引數轉成一個同名同值的類屬性。這些就被稱為引數屬性(parameter properties)。你可以通過在建構函式引數前新增一個可見性修飾符 public private protected 或者 readonly 來建立引數屬性,最後這些類屬性欄位也會得到這些修飾符:

class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // No body necessary
  }
}
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'.

類表示式(Class Expressions)

類表示式跟類宣告非常類似,唯一不同的是類表示式不需要一個名字,儘管我們可以通過繫結的識別符號進行引用:

const someClass = class<Type> {
  content: Type;
  constructor(value: Type) {
    this.content = value;
  }
};
 
const m = new someClass("Hello, world");  
// const m: someClass<string>

抽象類和成員(abstract Classes and Members)

TypeScript 中,類、方法、欄位都可以是抽象的(abstract)。

抽象方法或者抽象欄位是不提供實現的。這些成員必須存在在一個抽象類中,這個抽象類也不能直接被例項化。

抽象類的作用是作為子類的基類,讓子類實現所有的抽象成員。當一個類沒有任何抽象成員,他就會被認為是具體的(concrete)。

讓我們看個例子:

abstract class Base {
  abstract getName(): string;
 
  printName() {
    console.log("Hello, " + this.getName());
  }
}
 
const b = new Base();
// Cannot create an instance of an abstract class.

我們不能使用 new 例項 Base 因為它是抽象類。我們需要寫一個派生類,並且實現抽象成員。

class Derived extends Base {
  getName() {
    return "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'.
  // forgot to do anything
}

抽象構造簽名(Abstract Construct Signatures)

有的時候,你希望接受傳入可以繼承一些抽象類產生一個類的例項的類建構函式。

舉個例子,你也許會寫這樣的程式碼:

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

TypeScript 會報錯,告訴你正在嘗試例項化一個抽象類。畢竟,根據 greet 的定義,這段程式碼應該是合法的:

// Bad!
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 是不能的。

類之間的關係(Relationships Between Classes)

大部分時候,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) {
  // can't do anything with 'x', so I won't
}
 
// All OK!
fn(window);
fn({});
fn(fn);

TypeScript 系列

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

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

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

相關文章