TypeScript 的 class 类型

简介 #

类(class)是面向对象编程的基本构件,封装了属性和方法,TypeScript 给予了全面支持。

属性的类型 #

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

对于顶层声明的属性,可以在声明时同时给出类型。

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

上面声明中,属性 x y 的类型都是 number

如果不给出类型,TypeScript 会认为 x y 的类型都是 any

class Point {
  x;
  y;
}

上面示例中, x y 的类型都是 any

如果声明时给出初值,可以不写类型,TypeScript 会自行推断属性的类型。

class Point {
  x = 0;
  y = 0;
}

上面示例中,属性 x y 的类型都会被推断为 number。

TypeScript 有一个配置项 strictPropertyInitialization ,只要打开(默认是打开的),就会检查属性是否设置了初值,如果没有就报错。

// 打开 strictPropertyInitialization
class Point {
  x: number; // 报错
  y: number; // 报错
}

上面示例中,如果类的顶层属性不赋值,就会报错。如果不希望出现报错,可以使用非空断言。

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

上面示例中,属性 x y 没有初值,但是属性名后面添加了感叹号,表示这两个属性肯定不会为空,所以 TypeScript 就不报错了,详见《类型断言》一章。

readonly 修饰符 #

属性名前面加上 readonly 修饰符,就表示该属性是只读的。实例对象不能修改这个属性。

class A {
  readonly id = 'foo';
}

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

上面示例中, id 属性前面有 readonly 修饰符,实例对象修改这个属性就会报错。

readonly 属性的初始值,可以写在顶层属性,也可以写在构造方法里面。

class A {
  readonly id:string;

  constructor() {
    this.id = 'bar'; // 正确
  }
}

上面示例中,构造方法内部设置只读属性的初值,这是可以的。

class A {
  readonly id:string = 'foo';

  constructor() {
    this.id = 'bar'; // 正确
  }
}

上面示例中,构造方法修改只读属性的值也是可以的。或者说,如果两个地方都设置了只读属性的值,以构造方法为准。在其他方法修改只读属性都会报错。

方法的类型 #

类的方法就是普通函数,类型声明方式与函数一致。

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

上面示例中,构造方法 constructor() 和普通方法 add() 都注明了参数类型,但是省略了返回值类型,因为 TypeScript 可以自己推断出来。

类的方法跟普通函数一样,可以使用参数默认值,以及函数重载。

下面是参数默认值的例子。

class Point {
  x: number;
  y: number;

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

上面示例中,如果新建实例时,不提供属性 x y 的值,它们都等于默认值 0

下面是函数重载的例子。

class Point {
  constructor(x:number, y:string);
  constructor(s:string);
  constructor(xs:number|string, y?:string) {
    // ...
  }
}

上面示例中,构造方法可以接受一个参数,也可以接受两个参数,采用函数重载进行类型声明。

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

class B {
  constructor():object { // 报错
    // ...
  }
}

上面示例中,构造方法声明了返回值类型 object ,导致报错。

存取器方法 #

存取器(accessor)是特殊的类方法,包括取值器(getter)和存值器(setter)两种方法。

它们用于读写某个属性,取值器用来读取属性,存值器用来写入属性。

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

上面示例中, get name() 是取值器,其中 get 是关键词, name 是属性名。外部读取 name 属性时,实例对象会自动调用这个方法,该方法的返回值就是 name 属性的值。

set name() 是存值器,其中 set 是关键词, name 是属性名。外部写入 name 属性时,实例对象会自动调用这个方法,并将所赋的值作为函数参数传入。

TypeScript 对存取器有以下规则。

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

class C {
  _name = 'foo';

  get name() {
    return this._name;
  }
}

const c = new C();
c.name = 'bar'; // 报错

上面示例中, name 属性没有 set 方法,对该属性赋值就会报错。

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

// TypeScript 5.1 版之前
class C {
  _name = '';
  get name():string {  // 报错
    return this._name;
  }
  set name(value:number) {
    this._name = String(value);
  }
}

上面示例中, get 方法的返回值类型是字符串,与 set 方法的参数类型 number 不兼容,导致报错。改成下面这样,就不会报错。

class C {
  _name = '';
  get name():string {
    return this._name;
  }
  set name(value:number|string) {
    this._name = String(value);
  }
}

上面示例中, set 方法的参数类型( number|string )兼容 get 方法的返回值类型( string ),这是允许的。

TypeScript 5.1 版做出了 改变 ,现在两者可以不兼容。

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

属性索引 #

类允许定义属性索引。

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

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

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

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

class MyClass {
  [s:string]: boolean;
  f() { // 报错
    return true;
  }
}

上面示例中,属性索引的类型里面不包括方法,导致后面的方法 f() 定义直接报错。正确的写法是下面这样。

class MyClass {
  [s:string]: boolean | (() => boolean);
  f() {
    return true;
  }
}

属性存取器视同属性。

class MyClass {
  [s:string]: boolean;

  get isInstance() {
    return true;
  }
}

上面示例中,属性 inInstance 的读取器虽然是一个函数方法,但是视同属性,所以属性索引虽然没有涉及方法类型,但是不会报错。

类的 interface 接口 #

implements 关键字 #

interface 接口或 type 别名,可以用对象的形式,为 class 指定一组检查条件。然后,类使用 implements 关键字,表示当前类满足这些外部类型条件的限制。

interface Country {
  name:string;
  capital:string;
}
// 或者
type Country = {
  name:string;
  capital:string;
}

class MyCountry implements Country {
  name = '';
  capital = '';
}

上面示例中, interface type 都可以定义一个对象类型。类 MyCountry 使用 implements 关键字,表示该类的实例对象满足这个外部类型。

interface 只是指定检查条件,如果不满足这些条件就会报错。它并不能代替 class 自身的类型声明。

interface A {
  get(name:string): boolean;
}

class B implements A {
  get(s) { // s 的类型是 any
    return true;
  }
}

上面示例中,类 B 实现了接口 A ,但是后者并不能代替 B 的类型声明。因此, B get() 方法的参数 s 的类型是 any ,而不是 string B 类依然需要声明参数 s 的类型。

class B implements A {
  get(s:string) {
    return true;
  }
}

下面是另一个例子。

interface A {
  x: number;
  y?: number;
}

class B implements A {
  x = 0;
}

const b = new B();
b.y = 10; // 报错

上面示例中,接口 A 有一个可选属性 y ,类 B 没有声明这个属性,所以可以通过类型检查。但是,如果给 B 的实例对象的属性 y 赋值,就会报错。所以, B 类还是需要声明可选属性 y

class B implements A {
  x = 0;
  y?: number;
}

同理,类可以定义接口没有声明的方法和属性。

interface Point {
  x: number;
  y: number;
}

class MyPoint implements Point {
  x = 1;
  y = 1;
  z:number = 1;
}

上面示例中, MyPoint 类实现了 Point 接口,但是内部还定义了一个额外的属性 z ,这是允许的,表示除了满足接口给出的条件,类还有额外的条件。

implements 关键字后面,不仅可以是接口,也可以是另一个类。这时,后面的类将被当作接口。

class Car {
  id:number = 1;
  move():void {};
}

class MyCar implements Car {
  id = 2; // 不可省略
  move():void {};   // 不可省略
}

上面示例中, implements 后面是类 Car ,这时 TypeScript 就把 Car 视为一个接口,要求 MyCar 实现 Car 里面的每一个属性和方法,否则就会报错。所以,这时不能因为 Car 类已经实现过一次,而在 MyCar 类省略属性或方法。

注意,interface 描述的是类的对外接口,也就是实例的公开属性和公开方法,不能定义私有的属性和方法。这是因为 TypeScript 设计者认为,私有属性是类的内部实现,接口作为模板,不应该涉及类的内部代码写法。

interface Foo {
  private member:{}; // 报错
}

上面示例中,接口 Foo 有一个私有属性,结果就报错了。

实现多个接口 #

类可以实现多个接口(其实是接受多重限制),每个接口之间使用逗号分隔。

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

上面示例中, Car 类同时实现了 MotorVehicle Flyable Swimmable 三个接口。这意味着,它必须部署这三个接口声明的所有属性和方法,满足它们的所有条件。

但是,同时实现多个接口并不是一个好的写法,容易使得代码难以管理,可以使用两种方法替代。

第一种方法是类的继承。

class Car implements MotorVehicle {
}

class SecretCar extends Car implements Flyable, Swimmable {
}

上面示例中, Car 类实现了 MotorVehicle ,而 SecretCar 类继承了 Car 类,然后再实现 Flyable Swimmable 两个接口,相当于 SecretCar 类同时实现了三个接口。

第二种方法是接口的继承。

interface A {
  a:number;
}

interface B extends A {
  b:number;
}

上面示例中,接口 B 继承了接口 A ,类只要实现接口 B ,就相当于实现 A B 两个接口。

前一个例子可以用接口继承改写。

interface MotorVehicle {
  // ...
}
interface Flyable {
  // ...
}
interface Swimmable {
  // ...
}

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

class SecretCar implements SuperCar {
  // ...
}

上面示例中,类 SecretCar 通过 SuperCar 接口,就间接实现了多个接口。

注意,发生多重实现时(即一个接口同时实现多个接口),不同接口不能有互相冲突的属性。

interface Flyable {
  foo:number;
}

interface Swimmable {
  foo:string;
}

上面示例中,属性 foo 在两个接口里面的类型不同,如果同时实现这两个接口,就会报错。

类与接口的合并 #

TypeScript 不允许两个同名的类,但是如果一个类和一个接口同名,那么接口会被合并进类。

class A {
  x:number = 1;
}

interface A {
  y:number;
}

let a = new A();
a.y = 10;

a.x // 1
a.y // 10

上面示例中,类 A 与接口 A 同名,后者会被合并进前者的类型定义。

注意,合并进类的非空属性(上例的 y ),如果在赋值之前读取,会返回 undefined

class A {
  x:number = 1;
}

interface A {
  y:number;
}

let a = new A();
a.y // undefined

上面示例中,根据类型定义, y 应该是一个非空属性。但是合并后, y 有可能是 undefined

Class 类型 #

实例类型 #

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

class Color {
  name:string;

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

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

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

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

interface MotorVehicle {
}

class Car implements MotorVehicle {
}

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

上面示例中,变量的类型可以写成类 Car ,也可以写成接口 MotorVehicle 。它们的区别是,如果类 Car 有接口 MotorVehicle 没有的属性和方法,那么只有变量 c1 可以调用这些属性和方法。

作为类型使用时,类名只能表示实例的类型,不能表示类的自身类型。

class Point {
  x:number;
  y:number;

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

// 错误
function createPoint(
  PointClass:Point,
  x: number,
  y: number
) {
  return new PointClass(x, y);
}

上面示例中,函数 createPoint() 的第一个参数 PointClass ,需要传入 Point 这个类,但是如果把参数的类型写成 Point 就会报错,因为 Point 描述的是实例类型,而不是 Class 的自身类型。

由于类名作为类型使用,实际上代表一个对象,因此可以把类看作为对象类型起名。事实上,TypeScript 有三种方法可以为对象类型起名:type、interface 和 class。

类的自身类型 #

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

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

上面示例中, createPoint() 的第一个参数 PointClass Point 类自身,要声明这个参数的类型,简便的方法就是使用 typeof Point 。因为 Point 类是一个值, typeof Point 返回这个值的类型。注意, createPoint() 的返回值类型是 Point ,代表实例类型。

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

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

上面示例中,参数 PointClass 的类型写成了一个构造函数,这时就可以把 Point 类传入。

构造函数也可以写成对象形式,所以参数 PointClass 的类型还有另一种写法。

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

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

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

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

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

结构类型原则 #

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

class Foo {
  id!:number;
}

function fn(arg:Foo) {
  // ...
}

const bar = {
  id: 10,
  amount: 100,
};

fn(bar); // 正确

上面示例中,对象 bar 满足类 Foo 的实例结构,只是多了一个属性 amount 。所以,它可以当作参数,传入函数 fn()

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

class Person {
  name: string;
}

class Customer {
  name: string;
}

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

上面示例中, Person Customer 是两个结构相同的类,TypeScript 将它们视为相同类型,因此 Person 可以用在类型为 Customer 的场合。

现在修改一下代码, Person 类添加一个属性。

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

class Customer {
  name: string;
}

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

上面示例中, Person 类添加了一个属性 age ,跟 Customer 类的结构不再相同。但是这种情况下,TypeScript 依然认为, Person 属于 Customer 类型。

这是因为根据“结构类型原则”,只要 Person 类具有 name 属性,就满足 Customer 类型的实例结构,所以可以代替它。反过来就不行,如果 Customer 类多出一个属性,就会报错。

class Person {
  name: string;
}

class Customer {
  name: string;
  age: number;
}

// 报错
const cust:Customer = new Person();

上面示例中, Person 类比 Customer 类少一个属性 age ,它就不满足 Customer 类型的实例结构,就报错了。因为在使用 Customer 类型的情况下,可能会用到它的 age 属性,而 Person 类就没有这个属性。

总之,只要 A 类具有 B 类的结构,哪怕还有额外的属性和方法,TypeScript 也认为 A 兼容 B 的类型。

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

class Person {
  name: string;
}

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

上面示例中,对象 obj 并不是 Person 的实例,但是赋值给变量 p 不会报错,TypeScript 认为 obj 也属于 Person 类型,因为它们的属性相同。

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

obj instanceof Person // false

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

空类不包含任何成员,任何其他类都可以看作与空类结构相同。因此,凡是类型为空类的地方,所有类(包括对象)都可以使用。

class Empty {}

function fn(x:Empty) {
  // ...
}

fn({});
fn(window);
fn(fn);

上面示例中,函数 fn() 的参数是一个空类,这意味着任何对象都可以用作 fn() 的参数。

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

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

上面示例中, Point Position 的静态属性和构造方法都不一样,但因为 Point 的实例成员与 Position 相同,所以 Position 兼容 Point

如果类中存在私有成员(private)或保护成员(protected),那么确定兼容关系时,TypeScript 要求私有成员和保护成员来自同一个类,这意味着两个类需要存在继承关系。

// 情况一
class A {
  private name = 'a';
}

class B extends A {
}

const a:A = new B();

// 情况二
class A {
  protected name = 'a';
}

class B extends A {
  protected name = 'b';
}

const a:A = new B();

上面示例中, A B 都有私有成员(或保护成员) name ,这时只有在 B 继承 A 的情况下( class B extends A ), B 才兼容 A

类的继承 #

类(这里又称“子类”)可以使用 extends 关键字继承另一个类(这里又称“基类”)的所有属性和方法。

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

class B extends A {
}

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

上面示例中,子类 B 继承了基类 A ,因此就拥有了 greet() 方法,不需要再次在类的内部定义这个方法了。

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

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

上面示例中,变量 a 的类型是基类,但是可以赋值为子类的实例。

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

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

上面示例中,子类 B 定义了一个方法 greet() ,覆盖了基类 A 的同名方法。其中,参数 name 省略时,就调用基类 A greet() 方法,这里可以写成 super.greet() ,使用 super 关键字指代基类是常见做法。

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

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

class B extends A {
  // 报错
  greet(name:string) {
    console.log(`Hello, ${name}`);
  }
}

上面示例中,子类 B greet() 有一个 name 参数,跟基类 A greet() 定义不兼容,因此就报错了。

如果基类包括保护成员( protected 修饰符),子类可以将该成员的可访问性设置为公开( public 修饰符),也可以保持保护成员不变,但是不能改用私有成员( private 修饰符),详见后文。

class A {
  protected x: string = '';
  protected y: string = '';
  protected z: string = '';
}

class B extends A {
  // 正确
  public x:string = '';

  // 正确
  protected y:string = '';

  // 报错
  private z: string = '';
}

上面示例中,子类 B 将基类 A 的受保护成员改成私有成员,就会报错。

注意, extends 关键字后面不一定是类名,可以是一个表达式,只要它的类型是构造函数就可以了。

// 例一
class MyArray extends Array<number> {}

// 例二
class MyError extends Error {}

// 例三
class A {
  greeting() {
    return 'Hello from A';
  }
}
class B {
  greeting() {
    return 'Hello from B';
  }
}

interface Greeter {
  greeting(): string;
}

interface GreeterConstructor {
  new (): Greeter;
}

function getGreeterBase():GreeterConstructor {
  return Math.random() >= 0.5 ? A : B;
}

class Test extends getGreeterBase() {
  sayHello() {
    console.log(this.greeting());
  }
}

上面示例中,例一和例二的 extends 关键字后面都是构造函数,例三的 extends 关键字后面是一个表达式,执行后得到的也是一个构造函数。

override 关键字 #

子类继承父类时,可以覆盖父类的同名方法。

class A {
  show() {
    // ...
  }
  hide() {
    // ...
  }
}
class B extends A {
  show() {
    // ...
  }
  hide() {
    // ...
  }
}

上面示例中,B 类定义了自己的 show() 方法和 hide() 方法,覆盖了 A 类的同名方法。

但是有些时候,我们继承他人的类,可能会在不知不觉中,就覆盖了他人的方法。为了防止这种情况,TypeScript 4.3 引入了 override 关键字

class B extends A {
  override show() {
    // ...
  }
  override hide() {
    // ...
  }
}

上面示例中,B 类的 show() 方法和 hide() 方法前面加了 override 关键字,明确表明作者的意图,就是要覆盖 A 类里面的这两个同名方法。这时,如果 A 类没有定义自己的 show() 方法和 hide() 方法,就会报错。

但是,这依然没有解决,子类无意中覆盖父类同名方法的问题。因此,TypeScript 又提供了一个编译参数 noImplicitOverride 。一旦打开这个参数,子类覆盖父类的同名方法就会报错,除非使用了 override 关键字。

可访问性修饰符 #

类的内部成员的外部可访问性,由三个可访问性修饰符(access modifiers)控制: public private protected

这三个修饰符的位置,都写在属性或方法的最前面。

public #

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

class Greeter {
  public greet() {
    console.log("hi!");
  }
}

const g = new Greeter();
g.greet();

上面示例中, greet() 方法前面的 public 修饰符,表示该方法可以在类的外部调用,即外部实例可以调用。

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

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

private #

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

class A {
  private x:number = 0;
}

const a = new A();
a.x // 报错

class B extends A {
  showX() {
    console.log(this.x); // 报错
  }
}

上面示例中,属性 x 前面有 private 修饰符,表示这是私有成员。因此,实例对象和子类使用该成员,都会报错。

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

class A {
  private x = 0;
}

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

上面示例中, A 类有一个私有属性 x ,子类 B 就不能定义自己的属性 x 了。

如果在类的内部,当前类的实例可以获取私有成员。

class A {
  private x = 10;

  f(obj:A) {
    console.log(obj.x);
  }
}

const a = new A();
a.f(a) // 10

上面示例中,在类 A 内部, A 的实例对象可以获取私有成员 x

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

class A {
  private x = 1;
}

const a = new A();
a['x'] // 1

if ('x' in a) { // 正确
  // ...
}

上面示例中, A 类的属性 x 是私有属性,但是实例使用方括号,就可以读取这个属性,或者使用 in 运算符检查这个属性是否存在,都可以正确执行。

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

class A {
  #x = 1;
}

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

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

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

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

class Singleton {
  private static instance?: Singleton;

  private constructor() {}

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

const s = Singleton.getInstance();

上面示例使用私有构造方法,实现了单例模式。想要获得 Singleton 的实例,不能使用 new 命令,只能使用 getInstance() 方法。

protected #

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

class A {
  protected x = 1;
}

class B extends A {
  getX() {
    return this.x;
  }
}

const a = new A();
const b = new B();

a.x // 报错
b.getX() // 1

上面示例中,类 A 的属性 x 是保护成员,直接从实例读取该属性( a.x )会报错,但是子类 B 内部可以读取该属性。

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

class A {
  protected x = 1;
}

class B extends A {
  x = 2;
}

上面示例中,子类 B 定义了父类 A 的同名成员 x ,并且父类的 x 是保护成员,子类将其改成了公开成员。 B 类的 x 属性前面没有修饰符,等同于修饰符是 public ,外界可以读取这个属性。

在类的外部,实例对象不能读取保护成员,但是在类的内部可以。

class A {
  protected x = 1;

  f(obj:A) {
    console.log(obj.x);
  }
}

const a = new A();

a.x // 报错
a.f(a) // 1

上面示例中,属性 x 是类 A 的保护成员,在类的外部,实例对象 a 拿不到这个属性。但是,实例对象 a 传入类 A 的内部,就可以从 a 拿到 x

实例属性的简写形式 #

实际开发中,很多实例属性的值,是通过构造方法传入的。

class Point {
  x:number;
  y:number;

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

上面实例中,属性 x y 的值是通过构造方法的参数传入的。

这样的写法等于对同一个属性要声明两次类型,一次在类的头部,另一次在构造方法的参数里面。这有些累赘,TypeScript 就提供了一种简写形式。

class Point {
  constructor(
    public x:number,
    public y:number
  ) {}
}

const p = new Point(10, 10);
p.x // 10
p.y // 10

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

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

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

上面示例中,从编译结果可以看到,构造方法的 a b c d 会生成对应的实例属性。

readonly 还可以与其他三个可访问性修饰符,一起使用。

class A {
  constructor(
    public readonly x:number,
    protected readonly y:number,
    private readonly z:number
  ) {}
}

顶层属性的处理方法 #

对于类的顶层属性,TypeScript 早期的处理方法,与后来的 ES2022 标准不一致。这会导致某些代码的运行结果不一样。

类的顶层属性在 TypeScript 里面,有两种写法。

class User {
  // 写法一
  age = 25;

  // 写法二
  constructor(private currentYear: number) {}
}

上面示例中,写法一是直接声明一个实例属性 age ,并初始化;写法二是顶层属性的简写形式,直接将构造方法的参数 currentYear 声明为实例属性。

TypeScript 早期的处理方法是,先在顶层声明属性,但不进行初始化,等到运行构造方法时,再完成所有初始化。

class User {
  age = 25;
}

// TypeScript 的早期处理方法
class User {
  age: number;

  constructor() {
    this.age = 25;
  }
}

上面示例中,TypeScript 早期会先声明顶层属性 age ,然后等到运行构造函数时,再将其初始化为 25

ES2022 标准里面的处理方法是,先进行顶层属性的初始化,再运行构造方法。这在某些情况下,会使得同一段代码在 TypeScript 和 JavaScript 下运行结果不一致。

这种不一致一般发生在两种情况。第一种情况是,顶层属性的初始化依赖于其他实例属性。

class User {
  age = this.currentYear - 1998;

  constructor(private currentYear: number) {
    // 输出结果将不一致
    console.log('Current age:', this.age);
  }
}

const user = new User(2023);

上面示例中,顶层属性 age 的初始化值依赖于实例属性 this.currentYear 。按照 TypeScript 的处理方法,初始化是在构造方法里面完成的,会输出结果为 25 。但是,按照 ES2022 标准的处理方法,初始化在声明顶层属性时就会完成,这时 this.currentYear 还等于 undefined ,所以 age 的初始化结果为 NaN ,因此最后输出的也是 NaN

第二种情况与类的继承有关,子类声明的顶层属性在父类完成初始化。

interface Animal {
  animalStuff: any;
}

interface Dog extends Animal {
  dogStuff: any;
}

class AnimalHouse {
  resident: Animal;

  constructor(animal:Animal) {
    this.resident = animal;
  }
}

class DogHouse extends AnimalHouse {
  resident: Dog;

  constructor(dog:Dog) {
    super(dog);
  }
}

上面示例中,类 DogHouse 继承自 AnimalHouse 。它声明了顶层属性 resident ,但是该属性的初始化是在父类 AnimalHouse 完成的。不同的设置运行下面的代码,结果将不一致。

const dog = {
  animalStuff: 'animal',
  dogStuff: 'dog'
};

const dogHouse = new DogHouse(dog);

console.log(dogHouse.resident) // 输出结果将不一致

上面示例中,TypeScript 的处理方法,会使得 resident 属性能够初始化,所以输出参数对象的值。但是,ES2022 标准的处理方法是,顶层属性的初始化先于构造方法的运行。这使得 resident 属性不会得到赋值,因此输出为 undefined

为了解决这个问题,同时保证以前代码的行为一致,TypeScript 从3.7版开始,引入了编译设置 useDefineForClassFields 。这个设置设为 true ,则采用 ES2022 标准的处理方法,否则采用 TypeScript 早期的处理方法。

它的默认值与 target 属性有关,如果输出目标设为 ES2022 或者更高,那么 useDefineForClassFields 的默认值为 true ,否则为 false 。关于这个设置的详细说明,参见官方 3.7 版本的 发布说明

如果希望避免这种不一致,让代码在不同设置下的行为都一样,那么可以将所有顶层属性的初始化,都放到构造方法里面。

class User  {
  age: number;

  constructor(private currentYear: number) {
    this.age = this.currentYear - 1998;
    console.log('Current age:', this.age);
  }
}

const user = new User(2023);

上面示例中,顶层属性 age 的初始化就放在构造方法里面,那么任何情况下,代码行为都是一致的。

对于类的继承,还有另一种解决方法,就是使用 declare 命令,去声明子类顶层属性的类型,告诉 TypeScript 这些属性的初始化由父类实现。

class DogHouse extends AnimalHouse {
  declare resident: Dog;

  constructor(dog:Dog) {
    super(dog);
  }
}

上面示例中, resident 属性的类型声明前面用了 declare 命令。这种情况下,这一行代码在编译成 JavaScript 后就不存在,那么也就不会有行为不一致,无论是否设置 useDefineForClassFields ,输出结果都是一样的。

静态成员 #

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

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

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

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

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

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

class MyClass {
  private static x = 0;
}

MyClass.x // 报错

上面示例中,静态属性 x 前面有 private 修饰符,表示只能在 MyClass 内部使用,如果在外部调用这个属性就会报错。

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

class MyClass {
  static #x = 0;
}

public protected 的静态成员可以被继承。

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

上面示例中,类 A 的静态属性 x y 都被 B 继承,公开成员 x 可以在 B 的外部获取,保护成员 y 只能在 B 的内部获取。

泛型类 #

类也可以写成泛型,使用类型参数。关于泛型的详细介绍,请看《泛型》一章。

class Box<Type> {
  contents: Type;

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

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

上面示例中,类 Box 有类型参数 Type ,因此属于泛型类。新建实例时,变量的类型声明需要带有类型参数的值,不过本例等号左边的 Box<string> 可以省略不写,因为可以从等号右边推断得到。

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

class Box<Type> {
  static defaultContents: Type; // 报错
}

上面示例中,静态属性 defaultContents 的类型写成类型参数 Type 会报错。因为这意味着调用时必须给出类型参数(即写成 Box<string>.defaultContents ),并且类型参数发生变化,这个属性也会跟着变,这并不是好的做法。

抽象类,抽象成员 #

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

abstract class A {
  id = 1;
}

const a = new A(); // 报错

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

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

abstract class A {
  id = 1;
}

class B extends A {
  amount = 100;
}

const b = new B();

b.id // 1
b.amount // 100

上面示例中, A 是一个抽象类, B A 的子类,继承了 A 的所有成员,并且可以定义自己的成员和实例化。

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

abstract class A {
  foo:number;
}

abstract class B extends A {
  bar:string;
}

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

abstract class A {
  abstract foo:string;
  bar:string = '';
}

class B extends A {
  foo = 'b';
}

上面示例中,抽象类 A 定义了抽象属性 foo ,子类 B 必须实现这个属性,否则会报错。

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

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

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

这里有几个注意点。

(1)抽象成员只能存在于抽象类,不能存在于普通类。

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

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

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

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

this 问题 #

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

class A {
  name = 'A';

  getName() {
    return this.name;
  }
}

const a = new A();
a.getName() // 'A'

const b = {
  name: 'b',
  getName: a.getName
};
b.getName() // 'b'

上面示例中,变量 a b getName() 是同一个方法,但是执行结果不一样,原因就是它们内部的 this 指向不一样的对象。如果 getName() 在变量 a 上运行, this 指向 a ;如果在 b 上运行, this 指向 b

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

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

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

上面示例中,函数 fn() 的第一个参数是 this ,用来声明函数内部的 this 的类型。编译时,TypeScript 一旦发现函数的第一个参数名为 this ,则会去除这个参数,即编译结果不会带有该参数。

class A {
  name = 'A';

  getName(this: A) {
    return this.name;
  }
}

const a = new A();
const b = a.getName;

b() // 报错

上面示例中,类 A getName() 添加了 this 参数,如果直接调用这个方法, this 的类型就会跟声明的类型不一致,从而报错。

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

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

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

上面示例中,参数 this 的类型是一个带有 name 属性的对象,不符合这个条件的 this 都会报错。

TypeScript 提供了一个 noImplicitThis 编译选项。如果打开了这个设置项,如果 this 的值推断为 any 类型,就会报错。

// noImplicitThis 打开

class Rectangle {
  constructor(
    public width:number,
    public height:number
  ) {}

  getAreaFunction() {
    return function () {
      return this.width * this.height; // 报错
    };
  }
}

上面示例中, getAreaFunction() 方法返回一个函数,这个函数里面用到了 this ,但是这个 this Rectangle 这个类没关系,它的类型推断为 any ,所以就报错了。

在类的内部, this 本身也可以当作类型使用,表示当前类的实例对象。

class Box {
  contents:string = '';

  set(value:string):this {
    this.contents = value;
    return this;
  }
}

上面示例中, set() 方法的返回值类型就是 this ,表示当前的实例对象。

注意, this 类型不允许应用于静态成员。

class A {
  static a:this; // 报错
}

上面示例中,静态属性 a 的返回值类型是 this ,就报错了。原因是 this 类型表示实例对象,但是静态成员拿不到实例对象。

有些方法返回一个布尔值,表示当前的 this 是否属于某种类型。这时,这些方法的返回值类型可以写成 this is Type 的形式,其中用到了 is 运算符。

class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }

  isDirectory(): this is Directory {
    return this instanceof Directory;
  }

  // ...
}

上面示例中,两个方法的返回值类型都是布尔值,写成 this is Type 的形式,可以精确表示返回值。 is 运算符的介绍详见《类型断言》一章。

参考链接 #