Skip to content

TypeScript 的 class 类型

class 的基本操作

类的属性

类的属性可以在顶层声明,也可以在构造方法内部声明

ts
// 如果声明时给出初值,可以不写类型。
class Point {
  x: number;
  y: number;
}

有个配置项strictPropertyInitialization,只要打开,就会检查属性是否设置了初值,如果没有就报错。某些情况下,属性在声明时和构造方法之外赋值,为防止报错,可以使用非空断言

ts
class Point {
  x!: number;
  y!: number;
}

readonly 属性的初始值,可以写在顶层属性,也可以写在构造函数里。

ts
class A {
  readonly id = "foo";
}

const a = new A();
a.id = "bar"; // 报错

类的方法

类的方法与函数一致。

ts
class Point {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }

  add(point: Point) {
    return new Point(this.x + point.x, this.y + point.y);
  }
}

构造函数不能声明返回值类型,否则报错,因为它总是返回实例对象。

类的属性访问器

属性访问器(accessor)包括取值器(getter)和赋值器(setter)两种方法。

ts
class C {
  _name = "";
  get name() {
    return this._name;
  }
  set name(value) {
    this._name = value;
  }
}

TypeScript 对属性访问器有以下规则。

(1)如果某个属性只有get方法,没有set方法,那么该属性自动成为只读属性

(2)set方法的参数类型,必须兼容get方法的返回值类型,否则报错。

(3)get方法与set方法的可访问性必须一致,要么都为公开方法,要么都为私有方法。

ts
class C {
  _name = "";
  get name(): string {
    return this._name;
  }
  set name(value: number | string) {
    this._name = String(value); // 正确
  }
}

类的属性索引

类允许定义属性索引。

ts
class MyClass {
  [s: string]: boolean | ((s: string) => boolean);

  get(s: string) {
    return this[s] as boolean;
  }
}

上面示例中,[s:string]表示所有属性名类型为字符串的属性,它们的属性值要么是布尔值,要么是返回布尔值的函数。

注意,由于类的方法是一种特殊属性(属性值为函数的属性),所以属性索引的类型定义也涵盖了方法。如果一个对象同时定义了属性索引和方法,那么前者必须包含后者的类型

ts
class MyClass {
  [s: string]: boolean;
  f() {
    // 报错,因为属性索引不兼容 f属性
    return true;
  }
}

class MyClass {
  [s: string]: boolean | (() => boolean);
  f() {
    return true; // 正确,属性兼容
  }
}

类似的,属性存取器等同于方法,也必须包括在属性索引里面。

类成员的可访问性修饰符

类的内部成员的外部可访问性,由三个可访问性修饰符控制:publicprivateprotected

public

public修饰符表示这是公开成员,外部可以自由访问。

public修饰符是默认修饰符,如果省略不写,实际上就带有该修饰符。因此,类的属性和方法默认都是外部可访问的。

正常情况下,除非为了醒目和代码可读性,public都是省略不写的。

private

private修饰符表示私有成员,只能用在当前类的内部,类的实例和子类都不能使用该成员。

注意,子类不能定义父类私有成员的同名成员。

ts
class A {
  private x = 0;
}

class B extends A {
  x = 1; // 报错
}

private定义的私有成员,并不是真正意义的私有成员。一方面,编译成 JavaScript 后,private关键字就被剥离了,这时外部访问该成员就不会报错。另一方面,由于前一个原因,TypeScript 对于访问private成员没有严格禁止,使用方括号[]或者in运算符,实例对象就能访问该成员。

由于private存在这些问题,加上它是 ES6 标准发布前出台的,而 ES6 引入了自己的私有成员写法#propName。因此建议不使用private,改用 ES6 的写法,获得真正意义的私有成员。

ts
class A {
  #x = 1;
}

const a = new A();
a["x"]; // 报错

上面示例中,采用了 ES6 的私有成员写法(属性名前加#),TypeScript 就正确识别了实例对象没有属性x,从而报错。

构造方法也可以是私有的,这就直接防止了使用new命令生成实例对象,只能在类的内部创建实例对象。

这时一般会有一个静态方法,充当工厂函数,强制所有实例都通过该方法生成。

ts
class Singleton {
  private static instance?: Singleton;

  private constructor() {}

  static getInstance() {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton();
    }
    return Singleton.instance;
  }
}

const s = Singleton.getInstance();

protected

protected修饰符表示该成员是保护成员,只能在类的内部使用该成员,实例无法使用该成员,但是子类内部可以使用。

子类不仅可以拿到父类的保护成员,还可以定义同名成员。

类实例属性的简写

当构造方法的参数前面有public修饰符,这时 TypeScript 就会自动声明一个公开属性x,不必在构造方法里面写任何代码,同时还会设置x的值为构造方法的参数值。注意,这里的public不能省略。

除了public修饰符,构造方法的参数名只要有privateprotectedreadonly修饰符,都会自动声明对应修饰符的实例属性

ts
class A {
  constructor(
    public a: number,
    protected b: number,
    private c: number,
    readonly d: number
  ) {}
}

// 编译结果
class A {
  a;
  b;
  c;
  d;
  constructor(a, b, c, d) {
    this.a = a;
    this.b = b;
    this.c = c;
    this.d = d;
  }
}

类的静态成员

类的内部可以使用static关键字,定义静态成员。

静态成员是只能通过类本身使用的成员,不能通过实例对象使用。

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

MyClass.x; // 0
MyClass.printX(); // 0

上面示例中,x是静态属性,printX()是静态方法。它们都必须通过MyClass获取,而不能通过实例对象调用。

static关键字前面可以使用 public、private、protected 修饰符。

静态私有属性也可以用 ES6 语法的#前缀表示,上面示例可以改写如下。

**publicprotected的静态成员可以被继承**。

ts
class A {
  public static x = 1;
  protected static y = 1;
}

class B extends A {
  static getY() {
    return B.y;
  }
}

B.x; // 1
B.getY(); // 1

class 类型

类的实例类型

TypeScript 的 class 本身就是一种类型,但是它代表该类的实例类型,而不是 class 的自身类型。

ts
class Color {
  name: string;

  constructor(name: string) {
    this.name = name;
  }
}

const green: Color = new Color("green");

上面示例中,定义了一个类Color。它的类名就代表一种类型,实例对象green就属于该类型。

对于引用实例对象的变量来说,既可以声明类型为 Class,也可以声明类型为 Interface,因为两者都代表实例对象的类型。

ts
interface MotorVehicle {}

class Car implements MotorVehicle {}

// 写法一
const c1: Car = new Car();
// 写法二
const c2: MotorVehicle = new Car();

类的自身类型

要获得一个类的自身类型,一个简便的方法就是使用 typeof 运算符。

ts
function createPoint(PointClass: Point, x: number, y: number): Point {
  // 错,Point是实例类型,而不是类自身的类型
  return new PointClass(x, y);
}

function createPoint(PointClass: typeof Point, x: number, y: number): Point {
  // PointClass 是类的自身类型,可以作为构造函数使用
  return new PointClass(x, y);
}

JavaScript 语言中,类只是构造函数的语法糖,本质上是构造函数的另一种写法。所以,类的自身类型可以写成构造函数的形式。

ts
function createPoint(
  PointClass: new (x: number, y: number) => Point,
  x: number,
  y: number
): Point {
  return new PointClass(x, y);
}

根据上面的写法,可以把构造函数提取出来,单独定义一个接口(interface),提高代码的通用性。

ts
interface PointConstructor {
  new (x: number, y: number): Point;
}

总结一下,类的自身类型就是一个构造函数,可以单独定义一个接口来表示。

鸭子类型原则

Class 也遵循“结构类型原则”。一个对象只要满足 Class 的实例结构,就跟该 Class 属于同一个类型。

如果两个类的实例结构相同,那么这两个类就是兼容的,可以用在对方的使用场合。

ts
class Person {
  name: string;
  age: number;
}

class Customer {
  name: string;
}

// 正确
const cust: Customer = new Person();

不仅是类,如果某个对象跟某个 class 的实例结构相同,TypeScript 也认为两者的类型相同。

ts
class Person {
  name: string;
}

const obj = { name: "John" };
const p: Person = obj; // 正确

由于这种情况,运算符instanceof 不适用于判断某个对象是否跟某个 class 属于同一类型。

ts
obj instanceof Person; // false

上面示例中,运算符instanceof确认变量obj不是 Person 的实例,但是两者的类型是相同的。

注意,确定两个类的兼容关系时,只检查实例成员,不考虑静态成员和构造方法

ts
class Point {
  x: number;
  y: number;
  static t: number;
  constructor(x: number) {}
}

class Position {
  x: number;
  y: number;
  z: number;
  constructor(x: string) {}
}

const point: Point = new Position(1);

接口的实现 (implements)

implements 关键字

implements关键字后面,可以是 interface,可以是 type,也可以是另一个 class,他们为 class 指定了一组检查条件

类使用 implements 关键字,表示当前类满足这些外部类型条件的限制。

注意,当一个类被作为接口来 implement 时,它描述的是类的对外接口,也就是实例的公开属性和方法不能定义私有的属性和方法。私有属性是类的内部实现,而接口作为开放的模板,不应该涉及类的内部代码。

实现多个接口

类可以实现多个接口,每个接口之间使用逗号分隔。

ts
class Car implements MotorVehicle, Flyable, Swimmable {
  // ...
}

但是,同时实现多个接口容易使得代码难以管理,通常会通过中间类和中间接口来避免这种情况。

ts
// 中间类
class Car implements MotorVehicle {}

class SecretCar extends Car implements Flyable, Swimmable {}
ts
// 中间接口
interface MotorVehicle {
  // ...
}
interface Flyable {
  // ...
}
interface Swimmable {
  // ...
}

interface SuperCar extends MotoVehicle, Flyable, Swimmable {
  // ...
}

class SecretCar implements SuperCar {
  // ...
}

类的继承(extends)

类可以使用 extends 关键字继承另一个类的所有属性和方法。

ts
class A {
  greet() {
    console.log("Hello, world!");
  }
}

class B extends A {}

const b = new B();
b.greet(); // "Hello, world!"

根据结构类型原则,子类也可以用于类型为基类的场合。

ts
const a: A = b;
a.greet();

子类可以覆盖基类的同名方法。

ts
class B extends A {
  greet(name?: string) {
    if (name === undefined) {
      super.greet();
    } else {
      console.log(`Hello, ${name}`);
    }
  }
}

但是,子类的同名方法不能与基类的类型定义相冲突。

泛型类

类也可以写成泛型,使用类型参数。

ts
class Box<Type> {
  contents: Type;

  constructor(value: Type) {
    this.contents = value;
  }
}

const b: Box<string> = new Box("hello!");

注意,静态成员不能使用泛型的类型参数。

抽象类,抽象成员

TypeScript 允许在类的定义前面,加上关键字abstract,表示该类不能被实例化,只能当作其他类的模板。这种类就叫做“抽象类”(abastract class)。

ts
abstract class A {
  id = 1;
}
const a = new A(); // 报错

上面示例中,直接新建抽象类的实例,会报错。

抽象类只能当作基类使用,用来在它的基础上定义子类。

抽象类的子类也可以是抽象类,也就是说,抽象类可以继承其他抽象类。

抽象类的内部可以有已经实现好的属性和方法,也可以有还未实现的属性和方法。后者就叫做“抽象成员”(abstract member),即属性名和方法名有abstract关键字,表示该方法需要子类实现。如果子类没有实现抽象成员,就会报错。

ts
abstract class A {
  abstract foo: string;
  bar: string = "";
}
class B extends A {
  foo = "b";
}

如果抽象类的属性前面加上abstract,就表明子类必须给出该方法的实现。

ts
abstract class A {
  abstract execute(): string;
}

class B extends A {
  execute() {
    return `B executed`;
  }
}

这里有几个注意点。

(1)抽象属性/方法只能存在于抽象类,不能存在于普通类。

(2)抽象成员不能有具体实现的代码。也就是说,已经实现好的成员前面不能加abstract关键字。

(3)抽象成员前也不能有private修饰符,否则无法在子类中实现该成员。

(4)一个子类最多只能继承一个抽象类。

总之,抽象类的作用是,确保各种相关的子类都拥有跟基类相同的接口,可以看作是模板。其中的抽象成员都是必须由子类实现的成员,非抽象成员则表示基类已经实现的、由所有子类共享的成员。

this

类的方法经常用到this关键字,它表示该方法当前所在的对象。

有些场合需要给出this类型,但是 JavaScript 函数通常不带有this参数,这时 TypeScript 允许函数增加一个名为this的参数,放在参数列表的第一位,用来描述函数内部的this关键字的类型。

编译时,TypeScript 一旦发现函数的第一个参数名为this,则会去除这个参数

ts
// 编译前
function fn(this: SomeType, x: number) {
  /* ... */
}

// 编译后
function fn(x) {
  /* ... */
}

this参数的类型可以声明为各种对象。

ts
function foo(this: { name: string }) {
  this.name = "Jack";
  this.name = 0; // 报错
}

foo.call({ name: 123 }); // 报错