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}`);
就像 const
、let
和 var
,一個類屬性的初始值會被用於推斷它的型別:
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
的型別會被 check
的 name: 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)
注意:如果你不打算繼承內建的型別比如Array
、Error
、Map
等或者你的編譯目標是 ES6/ES2015 或者更新的版本,你可以跳過這個章節。
在 ES2015 中,當呼叫 super(...)
的時候,如果建構函式返回了一個物件,會隱式替換 this
的值。所以捕獲 super()
可能的返回值並用 this
替換它是非常有必要的。
這就導致,像 Error
、Array
等子類,也許不會再如你期望的那樣執行。這是因為 Error
、Array
等類似內建物件的建構函式,會使用 ECMAScript 6 的 new.target
調整原型鏈。然而,在 ECMAScript 5 中,當呼叫一個建構函式的時候,並沒有方法可以確保 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);
// Set the prototype explicitly.
Object.setPrototypeOf(this, MsgError.prototype);
}
sayHello() {
return "hello " + this.message;
}
}
不過,任何 MsgError
的子類也不得不手動設定原型。如果執行時不支援 Object.setPrototypeOf
,你也許可以使用 __proto__
。
不幸的是,這些方案並不會能在 IE 10 或者之前的版本正常執行。解決的一個方法是手動拷貝原型中的方法到例項中(就比如 MsgError.prototype
到 this
),但是它自己的原型鏈依然沒有被修復。
成員可見性(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++ 這邊。因為 Derived2
的 x
應該只有從 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)
private
和 protected
僅僅在型別檢查的時候才會強制生效。
這意味著在 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,對作者也是一種鼓勵。