TypeScript 的官方文档早已更新,但我能找到的中文文档都还停留在比较老的版本。所以对其中新增以及修订较多的一些章节进行了翻译整理。

本篇翻译整理自 TypeScript Handbook 中 「Classes」 章节。

本文并不严格按照原文翻译,对部分内容也做了解释补充。

类(Classes)

TypeScript 完全支持 ES2015 引入的 class 关键字。

和其他 JavaScript 语言特性一样,TypeScript 提供了类型注解和其他语法,允许你表达类与其他类型之间的关系。

类成员(Class Members)

这是一个最基本的类,一个空类:

  1. class Point {}

这个类并没有什么用,所以让我们添加一些成员。

字段(Fields)

一个字段声明会创建一个公共(public)可写入(writeable)的属性:

  1. class Point {
  2. x: number;
  3. y: number;
  4. }
  5. const pt = new Point();
  6. pt.x = 0;
  7. pt.y = 0;

注意:类型注解是可选的,如果没有指定,会隐式的设置为 any

字段可以设置初始值(initializers):

  1. class Point {
  2. x = 0;
  3. y = 0;
  4. }
  5. const pt = new Point();
  6. // Prints 0, 0
  7. console.log(`${pt.x}, ${pt.y}`);

就像 constletvar ,一个类属性的初始值会被用于推断它的类型:

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

—strictPropertyInitialization

strictPropertyInitialization 选项控制了类字段是否需要在构造函数里初始化:

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

注意,字段需要在构造函数自身进行初始化。TypeScript 并不会分析构造函数里你调用的方法,进而判断初始化的值,因为一个派生类也许会覆盖这些方法并且初始化成员失败:

  1. class BadGreeter {
  2. name: string;
  3. // Property 'name' has no initializer and is not definitely assigned in the constructor.
  4. setName(): void {
  5. this.name = '123'
  6. }
  7. constructor() {
  8. this.setName();
  9. }
  10. }

如果你执意要通过其他方式初始化一个字段,而不是在构造函数里(举个例子,引入外部库为你补充类的部分内容),你可以使用明确赋值断言操作符(definite assignment assertion operator) !:

  1. class OKGreeter {
  2. // Not initialized, but no error
  3. name!: string;
  4. }

readonly

字段可以添加一个 readonly 前缀修饰符,这会阻止在构造函数之外的赋值。

  1. class Greeter {
  2. readonly name: string = "world";
  3. constructor(otherName?: string) {
  4. if (otherName !== undefined) {
  5. this.name = otherName;
  6. }
  7. }
  8. err() {
  9. this.name = "not ok";
  10. // Cannot assign to 'name' because it is a read-only property.
  11. }
  12. }
  13. const g = new Greeter();
  14. g.name = "also not ok";
  15. // Cannot assign to 'name' because it is a read-only property.

构造函数(Constructors)

类的构造函数跟函数非常类似,你可以使用带类型注解的参数、默认值、重载等。

  1. class Point {
  2. x: number;
  3. y: number;
  4. // Normal signature with defaults
  5. constructor(x = 0, y = 0) {
  6. this.x = x;
  7. this.y = y;
  8. }
  9. }
  1. class Point {
  2. // Overloads
  3. constructor(x: number, y: string);
  4. constructor(s: string);
  5. constructor(xs: any, y?: any) {
  6. // TBD
  7. }
  8. }

但类构造函数签名与函数签名之间也有一些区别:

  • 构造函数不能有类型参数(关于类型参数,回想下泛型里的内容),这些属于外层的类声明,我们稍后就会学习到。
  • 构造函数不能有返回类型注解,因为总是返回类实例类型

Super 调用(Super Calls)

就像在 JavaScript 中,如果你有一个基类,你需要在使用任何 this. 成员之前,先在构造函数里调用 super()

  1. class Base {
  2. k = 4;
  3. }
  4. class Derived extends Base {
  5. constructor() {
  6. // Prints a wrong value in ES5; throws exception in ES6
  7. console.log(this.k);
  8. // 'super' must be called before accessing 'this' in the constructor of a derived class.
  9. super();
  10. }
  11. }

忘记调用 super 是 JavaScript 中一个简单的错误,但是 TypeScript 会在需要的时候提醒你。

方法(Methods)

类中的函数属性被称为方法。方法跟函数、构造函数一样,使用相同的类型注解。

  1. class Point {
  2. x = 10;
  3. y = 10;
  4. scale(n: number): void {
  5. this.x *= n;
  6. this.y *= n;
  7. }
  8. }

除了标准的类型注解,TypeScript 并没有给方法添加任何新的东西。

注意在一个方法体内,它依然可以通过 this. 访问字段和其他的方法。方法体内一个未限定的名称(unqualified name,没有明确限定作用域的名称)总是指向闭包作用域里的内容。

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

Getters / Setter

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

  1. class C {
  2. _length = 0;
  3. get length() {
  4. return this._length;
  5. }
  6. set length(value) {
  7. this._length = value;
  8. }
  9. }

TypeScript 对存取器有一些特殊的推断规则:

  • 如果 get 存在而 set 不存在,属性会被自动设置为 readonly
  • 如果 setter 参数的类型没有指定,它会被推断为 getter 的返回类型
  • getters 和 setters 必须有相同的成员可见性(Member Visibility)。

从 TypeScript 4.3 起,存取器在读取和设置的时候可以使用不同的类型。

  1. class Thing {
  2. _size = 0;
  3. // 注意这里返回的是 number 类型
  4. get size(): number {
  5. return this._size;
  6. }
  7. // 注意这里允许传入的是 string | number | boolean 类型
  8. set size(value: string | number | boolean) {
  9. let num = Number(value);
  10. // Don't allow NaN, Infinity, etc
  11. if (!Number.isFinite(num)) {
  12. this._size = 0;
  13. return;
  14. }
  15. this._size = num;
  16. }
  17. }

索引签名(Index Signatures)

类可以声明索引签名,它和对象类型的索引签名是一样的:

  1. class MyClass {
  2. [s: string]: boolean | ((s: string) => boolean);
  3. check(s: string) {
  4. return this[s] as boolean;
  5. }
  6. }

因为索引签名类型也需要捕获方法的类型,这使得并不容易有效的使用这些类型。通常的来说,在其他地方存储索引数据而不是在类实例本身,会更好一些。

类继承(Class Heritage)

JavaScript 的类可以继承基类。

implements 语句(implements Clauses)

你可以使用 implements 语句检查一个类是否满足一个特定的 interface。如果一个类没有正确的实现(implement)它,TypeScript 会报错:

  1. interface Pingable {
  2. ping(): void;
  3. }
  4. class Sonar implements Pingable {
  5. ping() {
  6. console.log("ping!");
  7. }
  8. }
  9. class Ball implements Pingable {
  10. // Class 'Ball' incorrectly implements interface 'Pingable'.
  11. // Property 'ping' is missing in type 'Ball' but required in type 'Pingable'.
  12. pong() {
  13. console.log("pong!");
  14. }
  15. }

类也可以实现多个接口,比如 class C implements A, B {

注意事项(Cautions)

implements 语句仅仅检查类是否按照接口类型实现,但它并不会改变类的类型或者方法的类型。一个常见的错误就是以为 implements 语句会改变类的类型——然而实际上它并不会:

  1. interface Checkable {
  2. check(name: string): boolean;
  3. }
  4. class NameChecker implements Checkable {
  5. check(s) {
  6. // Parameter 's' implicitly has an 'any' type.
  7. // Notice no error here
  8. return s.toLowercse() === "ok";
  9. // any
  10. }

在这个例子中,我们可能会以为 s 的类型会被 checkname: string 参数影响。实际上并没有,implements 语句并不会影响类的内部是如何检查或者类型推断的。

类似的,实现一个有可选属性的接口,并不会创建这个属性:

  1. interface A {
  2. x: number;
  3. y?: number;
  4. }
  5. class C implements A {
  6. x = 0;
  7. }
  8. const c = new C();
  9. c.y = 10;
  10. // Property 'y' does not exist on type 'C'.

extends 语句(extends Clauses)

类可以 extend 一个基类。一个派生类有基类所有的属性和方法,还可以定义额外的成员。

  1. class Animal {
  2. move() {
  3. console.log("Moving along!");
  4. }
  5. }
  6. class Dog extends Animal {
  7. woof(times: number) {
  8. for (let i = 0; i < times; i++) {
  9. console.log("woof!");
  10. }
  11. }
  12. }
  13. const d = new Dog();
  14. // Base class method
  15. d.move();
  16. // Derived class method
  17. d.woof(3);

覆写属性(Overriding Methods)

一个派生类可以覆写一个基类的字段或属性。你可以使用 super 语法访问基类的方法。

TypeScript 强制要求派生类总是它的基类的子类型。

举个例子,这是一个合法的覆写方法的方式:

  1. class Base {
  2. greet() {
  3. console.log("Hello, world!");
  4. }
  5. }
  6. class Derived extends Base {
  7. greet(name?: string) {
  8. if (name === undefined) {
  9. super.greet();
  10. } else {
  11. console.log(`Hello, ${name.toUpperCase()}`);
  12. }
  13. }
  14. }
  15. const d = new Derived();
  16. d.greet();
  17. d.greet("reader");

派生类需要遵循着它的基类的实现。

而且通过一个基类引用指向一个派生类实例,这是非常常见并合法的:

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

但是如果 Derived 不遵循 Base 的约定实现呢?

  1. class Base {
  2. greet() {
  3. console.log("Hello, world!");
  4. }
  5. }
  6. class Derived extends Base {
  7. // Make this parameter required
  8. greet(name: string) {
  9. // Property 'greet' in type 'Derived' is not assignable to the same property in base type 'Base'.
  10. // Type '(name: string) => void' is not assignable to type '() => void'.
  11. console.log(`Hello, ${name.toUpperCase()}`);
  12. }
  13. }

即便我们忽视错误编译代码,这个例子也会运行错误:

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

初始化顺序(Initialization Order)

有些情况下,JavaScript 类初始化的顺序会让你感到很奇怪,让我们看这个例子:

  1. class Base {
  2. name = "base";
  3. constructor() {
  4. console.log("My name is " + this.name);
  5. }
  6. }
  7. class Derived extends Base {
  8. name = "derived";
  9. }
  10. // Prints "base", not "derived"
  11. 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 的值。 其他的降级编译器默认也会有同样的限制。

对于一个像下面这样的子类:

  1. class MsgError extends Error {
  2. constructor(m: string) {
  3. super(m);
  4. }
  5. sayHello() {
  6. return "hello " + this.message;
  7. }
  8. }

你也许可以发现:

  1. 对象的方法可能是 undefined ,所以调用 sayHello 会导致错误
  2. instanceof 失效, (new MsgError()) instanceof MsgError 会返回 false

我们推荐,手动的在 super(...) 调用后调整原型:

  1. class MsgError extends Error {
  2. constructor(m: string) {
  3. super(m);
  4. // Set the prototype explicitly.
  5. Object.setPrototypeOf(this, MsgError.prototype);
  6. }
  7. sayHello() {
  8. return "hello " + this.message;
  9. }
  10. }

不过,任何 MsgError 的子类也不得不手动设置原型。如果运行时不支持 Object.setPrototypeOf,你也许可以使用 __proto__

不幸的是,这些方案并不会能在 IE 10 或者之前的版本正常运行。解决的一个方法是手动拷贝原型中的方法到实例中(就比如 MsgError.prototypethis),但是它自己的原型链依然没有被修复。

成员可见性(Member Visibility)

你可以使用 TypeScript 控制某个方法或者属性是否对类以外的代码可见。

public

类成员默认的可见性为 public,一个 public 的成员可以在任何地方被获取:

  1. class Greeter {
  2. public greet() {
  3. console.log("hi!");
  4. }
  5. }
  6. const g = new Greeter();
  7. g.greet();

因为 public 是默认的可见性修饰符,所以你不需要写它,除非处于格式或者可读性的原因。

protected

protected 成员仅仅对子类可见:

  1. class Greeter {
  2. public greet() {
  3. console.log("Hello, " + this.getName());
  4. }
  5. protected getName() {
  6. return "hi";
  7. }
  8. }
  9. class SpecialGreeter extends Greeter {
  10. public howdy() {
  11. // OK to access protected member here
  12. console.log("Howdy, " + this.getName());
  13. }
  14. }
  15. const g = new SpecialGreeter();
  16. g.greet(); // OK
  17. g.getName();
  18. // Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses.

受保护成员的公开(Exposure of protected members)

派生类需要遵循基类的实现,但是依然可以选择公开拥有更多能力的基类子类型,这就包括让一个 protected 成员变成 public

  1. class Base {
  2. protected m = 10;
  3. }
  4. class Derived extends Base {
  5. // No modifier, so default is 'public'
  6. m = 15;
  7. }
  8. const d = new Derived();
  9. console.log(d.m); // OK

这里需要注意的是,如果公开不是故意的,在这个派生类中,我们需要小心的拷贝 protected 修饰符。

交叉等级受保护成员访问(Cross-hierarchy protected access)

不同的 OOP 语言在通过一个基类引用是否可以合法的获取一个 protected 成员是有争议的。

  1. class Base {
  2. protected x: number = 1;
  3. }
  4. class Derived1 extends Base {
  5. protected x: number = 5;
  6. }
  7. class Derived2 extends Base {
  8. f1(other: Derived2) {
  9. other.x = 10;
  10. }
  11. f2(other: Base) {
  12. other.x = 10;
  13. // Property 'x' is protected and only accessible through an instance of class 'Derived2'. This is an instance of class 'Base'.
  14. }
  15. }

在 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 ,但是不允许访问成员,即便是子类。

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

因为 private 成员对派生类并不可见,所以一个派生类也不能增加它的可见性:

  1. class Base {
  2. private x = 0;
  3. }
  4. class Derived extends Base {
  5. // Class 'Derived' incorrectly extends base class 'Base'.
  6. // Property 'x' is private in type 'Base' but not in type 'Derived'.
  7. x = 1;
  8. }

交叉实例私有成员访问(Cross-instance private access)

不同的 OOP 语言在关于一个类的不同实例是否可以获取彼此的 private 成员上,也是不一致的。像 Java、C#、C++、Swift 和 PHP 都是允许的,Ruby 是不允许。

TypeScript 允许交叉实例私有成员的获取:

  1. class A {
  2. private x = 10;
  3. public sameAs(other: A) {
  4. // No error
  5. return other.x === this.x;
  6. }
  7. }

警告(Caveats)

privateprotected 仅仅在类型检查的时候才会强制生效。

这意味着在 JavaScript 运行时,像 in 或者简单的属性查找,依然可以获取 private 或者 protected 成员。

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

private 允许在类型检查的时候,通过方括号语法进行访问。这让比如单元测试的时候,会更容易访问 private 字段,这也让这些字段是弱私有(soft private)而不是严格的强制私有。

  1. class MySafe {
  2. private secretKey = 12345;
  3. }
  4. const s = new MySafe();
  5. // Not allowed during type checking
  6. console.log(s.secretKey);
  7. // Property 'secretKey' is private and only accessible within class 'MySafe'.
  8. // OK
  9. console.log(s["secretKey"]);

不像 TypeScript 的 private,JavaScript 的私有字段#)即便是编译后依然保留私有性,并且不会提供像上面这种方括号获取的方法,这让它们变得强私有(hard private)。

  1. class Dog {
  2. #barkAmount = 0;
  3. personality = "happy";
  4. constructor() {}
  5. }
  1. "use strict";
  2. class Dog {
  3. #barkAmount = 0;
  4. personality = "happy";
  5. constructor() { }
  6. }

当被编译成 ES2021 或者之前的版本,TypeScript 会使用 WeakMaps 替代 #:

  1. "use strict";
  2. var _Dog_barkAmount;
  3. class Dog {
  4. constructor() {
  5. _Dog_barkAmount.set(this, 0);
  6. this.personality = "happy";
  7. }
  8. }
  9. _Dog_barkAmount = new WeakMap();

如果你需要防止恶意攻击,保护类中的值,你应该使用强私有的机制比如闭包,WeakMaps ,或者私有字段。但是注意,这也会在运行时影响性能。

TypeScript 系列

  1. TypeScript 之 基础入门
  2. TypeScript 之 常见类型(上)
  3. TypeScript 之 常见类型(下)
  4. TypeScript 之 类型收窄
  5. TypeScript 之 函数
  6. TypeScript 之 对象类型
  7. TypeScript 之 泛型
  8. TypeScript 之 Keyof 操作符
  9. TypeScript 之 Typeof 操作符
  10. TypeScript 之 索引访问类型
  11. TypeScript 之 条件类型
  12. TypeScript 之 映射类型
  13. TypeScript之模板字面量类型

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

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。