TypeScript 类

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

类成员

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

class Point {}

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

字段

一个字段会声明一个公共可写入的属性:

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

const pt = new Point();
pt.x = 0;
pt.y = 0;

类型注解是可选的,如果没有指定,会被隐式的设置成any。 字段可以设置初始值,并且TypeScript会根据这个初始值推断类型,就像letconstvar一样:

class Point {
  x = 0; // (property) Point.x: number
  y = 0; // (property) Point.y: number
}

之前我们在泛型章节提过,在TypeScript2.7+的版本中,加入了严格的类初始化检查strictPropertyInitialization,该检查要确保类的每一个实例属性,都在声明时初始化或在构造函数中初始化。tsconfig.json文件中stricttrue时也会默认打开这个检查。

class Point {
  x: number; // error: 属性“x”没有初始化表达式,且未在构造函数中明确赋值。
  y: number; // error: 属性“y”没有初始化表达式,且未在构造函数中明确赋值。

需要注意的是,TypeScript并不会根据你构造函数中的方法调用进而推断你声明的属性是否初始化,因为一个派生类也许会覆盖这些方法并且初始化成员失败:

class Person {
  name: string; // error: 属性“name”没有初始化表达式,且未在构造函数中明确赋值。
  setName():void {
    this.name = 'zs'
  }
  constructor() {
    this.setName()
  }
}

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

class Person {
  name!: string; // ok
}

只读属性readonly

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

class Person {
  readonly name!: string; // ok
  setName():void {
    this.name = 'zs'; // error: 无法分配到 "name" ,因为它是只读属性。
  }
  constructor(name?: string) {
    if(name)this.name = name;
  }
}

const p = new Person();
p.name = 'ls'; // error: 无法分配到 "name" ,因为它是只读属性。

构造函数

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

class Point {
  x: number;
  y: number;

  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调用

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

class Base {
  k = 4;
}
 
class Derived extends Base {
  constructor() {
    console.log(this.k);
		// error: 访问派生类的构造函数中的 "this" 前,必须调用 "super"。
    super();
  }
}

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

方法

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

class Point {
  x = 10;
  y = 10;
 
  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
}

除了标准的类型注解,TypeScript没有给方法添加什么新东西。 注意:在一个方法体内,它依然可以通过this.访问字段和其他方法。方法体内一个未限定的名称(没有明确作用域的名称),总是指向闭包作用域的内容。

let x: number = 0;
 
class C {
  x: string = "hello";
 
  m() {
    x = "world";
		// error: 不能将类型“string”分配给类型“number”。
  }
}

Getters/Setter

类也可以有存取器。

class C {
  _length = 0;
  get length() {
    return this._length;
  }
  set length(value) {
    this._length = value;
  }
}

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

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

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

class Thing {
  _size = 0;

  get size(): number {
    return this._size;
  }

  set size(value: number | string | boolean) {
    let num = Number(value);

    if (!Number.isFinite(num)) {
      this._size = 0;
      return;
    }
    this._size = num;
  }
}

索引签名

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

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

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

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

类继承

JavaScript的类可以继承基类。

implements语句

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

interface Pingable {
  ping(): void;
}

class Sonar implements Pingable {
  ping() {
    console.log('ping!')
  }
}

class Ball implements Pingable {
  // error: 类“Ball”错误实现接口“Pingable”。
  // error: 类型 "Ball" 中缺少属性 "ping",但类型 "Pingable" 中需要该属性。
  pong() {
    console.log('pong!')
  }
}

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

interface Pingable {
  ping(): void;
}
interface B {
  msg: string;
}

class Sonar implements Pingable, B {
  msg = 'hello';
  ping() {
    console.log('ping!')
  }
}

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

interface Checkable {
  check(name: string): boolean;
}

class NameChecker implements Checkable {
  check(s) {
    // error: 参数“s”隐式具有“any”类型。
    // 注意: 下面使用错误的方法 没有错误提示 是因为s被隐式推断为any类型
    return s.toLowercse() === 'ok';
  }
}

在这个例子中,我们可能会以为s会被Checkablecheckname: string影响,实际上并不会。implements语句并不会影响类的内部是如何检查或者类型推断的。 类似的,实现一个有可选属性的接口,实例化时并不会创建这个属性。

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

class C implements A {
  x = 0;
}

const c = new C();
c.y = 10; // error: 类型“C”上不存在属性“y”。

extends语句

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

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

d.move();
d.woof(3);

覆写属性 一个派生类可以覆写一个基类的字段或者属性。你可以使用super语法访问基类的所有方法。 TypeScript强制要求派生类是它的基类的子类型。 举个例子,这是一个合法的覆写方式的例子:

class Base {
  greet() {
    console.log('hello world!');
  }
}

class Derived extends Base {
  greet(name?: string) {
    if (!name) {
      super.greet();
    } else {
      console.log(`hello ${name.toUpperCase()}`);
    }
  }
}

const d = new Derived();
d.greet(); // hello world!
d.greet('May'); // hello MAY

派生类需要遵循着它的基类的实现。 通过一个基类引用指向它的派生类实例,这是非常常见且合法的:

const b: Base = d;
b.greet(); // hello world!

如果派生类不遵循基类的约定实现,TypeScript会直接报错:

class Base {
  greet() {
    console.log('hello world!');
  }
}

class Derived extends Base {
  // error: 类型“Derived”中的属性“greet”不可分配给基类型“Base”中的同一属性。
  // error: 不能将类型“(name: string) => void”分配给类型“() => void”。
  greet(name: string) {
    console.log(`hello ${name.toUpperCase()}`);
  }
}

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

class Base {
  name = 'base';

  constructor() {
    console.log('My name is ' + this.name);
  }
}

class Derived extends Base {
  name = 'derived';
}

const d = new Derived();
// 打印base 而不是derived

发生了什么呢? 类的初始化顺序,就像在JavaScript中定义的那样:

  • 基类字段初始化
  • 基类构造函数运行
  • 派生类字段初始化
  • 派生类构造函数运行

这意味着基类构造函数在运行时只能看到它自己的name值,因为此时派生类的字段还没有初始化。 继承内置类型

注意:如果你不打算继承内置的类型比如 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);
    Object.setPrototypeOf(this, MsgError.prototype)
  }
  sayHello() {
    return 'hello' + this.message;
  }
}

const m = new MsgError('@_@');
console.log(m instanceof MsgError); // false
m.sayHello(); // error: sayHello is not a function

你也许可以发现:

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

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

class MsgError extends Error {
  constructor(m: string) {
    super(m);
    Object.setPrototypeOf(this, MsgError.prototype)
  }
  sayHello() {
    return 'hello' + this.message;
  }
}

const m = new MsgError('@_@');
console.log(m instanceof MsgError); // true

不过,任何 MsgError 的子类也不得不手动设置原型。如果运行时不支持 Object.setPrototypeOf,你也许可以使用 proto 。 不幸的是,这些方案并不会能在 IE 10 或者之前的版本正常运行。解决的一个方法是手动拷贝原型中的方法到实例中(就比如 MsgError.prototype 到 this),但是它自己的原型链依然没有被修复。

成员可见性

你可以使用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('hi, ' + this.getName());
  }
  protected getName() {
    return 'zs'
  }
}

class SpecialGreeter extends Greeter {
  howdy() {
    console.log('Howdy, ' + this.getName());
  }
}

const g = new SpecialGreeter();
g.greet(); // ok
// g.getName(); // error: 属性“getName”受保护,只能在类“Greeter”及其子类中访问。

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

class Base {
  protected m = 10;
}

class Derived extends Base {
  m = 1;
}

const d = new Derived();
console.log(d.m) // 1

需要注意的是,如果不是故意公开此成员的话,在继承时要记得拷贝protected修饰符。 交叉等级受保护成员访问 不同的OOP语言在通过一个基类引用是否可以合法的获取一个protected成员是有争议的。

class Base {
  protected x: number = 1;
}

class Derived1 extends Base {
  protected x: number = 10;
}

class Derived2 extends Base {
  f1(other: Derived2) {
    other.x = 10;
  }
  f2(other: Base) {
    other.x = 10; // error: 属性“x”受保护,只能通过类“Derived2”的实例进行访问。这是类“Base”的实例。
  }
}

在Java中,这是合法的,但在C#和C++中,这是不合法的。 TypeScript站在C#和C++这边,因为Derived2x应该只有在Derived2的子类中才能访问。

private

privateprotected有点像,但是不允许实例和子类访问成员。

class Base {
  private x = 0;
}

const b = new Base();
console.log(b.x); // error: 属性“x”为私有属性,只能在类“Base”中访问。
class Base {
  private x = 0;
}

class Derived extends Base {
  showX() {
    console.log(this.x); // error: 属性“x”为私有属性,只能在类“Base”中访问。
  }
}

因为private对派生类并不可见,所以一个派生类也不能修改它的可见性。

class Base {
  private x = 0;
}

class Derived extends Base {
  // error:  类“Derived”错误扩展基类“Base”。
  // error: 属性“x”在类型“Base”中是私有属性,但在类型“Derived”中不是。
  x = 10;
}

交叉实例私有成员访问(Cross-instance private access) 不同的 OOP 语言在关于一个类的不同实例是否可以获取彼此的 private 成员上,也是不一致的。像 Java、C#、C++、Swift 和 PHP 都是允许的,Ruby 是不允许。 TypeScript 允许交叉实例私有成员的获取:

class A {
  private x = 10;

  sameAs(other: A) {
    return other.x === this.x;
  }
}

const a = new A();
console.log(a.sameAs(a)); // true

警告 privateprotected仅仅在类型检查的时候才会生效。 也就是说在JavaScript运行时,in或者简单的属性查找,依然可以获取privateprotected成员。

class MySafe {
  private secretKey = 12345;
}

const m = new MySafe();
console.log(m.secretKey); // 属性“secretKey”为私有属性,只能在类“MySafe”中访问。

当类型检查时会报错,但是当运行Js代码时,依然可以获取到属性。 并且private允许在类型检查的时候,使用方括号语法进行访问。这也让这些字段是弱私有,而不是严格的强制私有。

class MySafe {
  private secretKey = 12345;
}

const m = new MySafe();
console.log(m.secretKey); // error
console.log(m['secretKey']); // ok

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

class Dog {
  #barkAmount = 0;
  personality = 'happy';
  constructor() {}
}
const d = new Dog();
console.log(d.#barkAmount); // error: 属性 "#barkAmount" 在类 "Dog" 外部不可访问,因为它具有专用标识符。

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

var _Dog_barkAmount;
var Dog = /** @class */ (function () {
    function Dog() {
        _Dog_barkAmount.set(this, 0);
        this.personality = 'happy';
    }
    return Dog;
}());
_Dog_barkAmount = new WeakMap();
var d = new Dog();
console.log(d.); // error: 属性 "#barkAmount" 在类 "Dog" 外部不可访问,因为它具有专用标识符。

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

静态成员

类可以通过static关键字声明静态成员,静态成员和类实例没有关系,可以通过类本身访问。

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

静态成员同样可以使用publicprotectedprivate这些可见性修饰符:

class MyClass {
  private static x = 0;
  static printX() {
    console.log(MyClass.x);
  }
}
console.log(MyClass.x); // error: 属性“x”为私有属性,只能在类“MyClass”中访问。

静态成员也可以被继承:

class Base {
  static getGreeting() {
    return 'hello!';
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting();
}
const d = new Derived();
console.log(d.myGreeting); // print: hello!

特殊静态名称

类本身是函数,而覆写Function原型上的属性认为是不安全的,因此不能使用一些固定的静态名称,函数属性像namelengthcall不能用来被定义static成员:

class S { 
  static name = 'zs'; // error: 静态属性“name”与构造函数“S”的内置属性函数“name”冲突。
}

泛型类

类和接口一样也可以使用泛型。当使用new实例化一个泛型类,它的类型参数推断跟函数调用是同样的方式:

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

类跟接口一样也可以使用泛型约束和默认值。

静态成员中的类型参数

泛型类的静态成员不应该引用类的类型参数:

class Box<Type> {
  static defaultValue: Type; // error: 静态成员不能引用类类型参数。	
}

类运行时的this

TypeScript并不会改变JavaScript运行时的行为,并且JavaScript有时会出现一些奇怪的运行时行为。 就比如像JavaScript处理this

class MyClass {
  name = 'MyClass';
  getName() {
    return this.name;
  }
}

const c = new MyClass();
const obj = {
  name: 'obj',
  getName: c.getName,
}

console.log(obj.getName()); // print: obj

默认情况下,函数中this的指向取决于函数是如何调用的。在这个例子中,函数通过obj调用,所以打印的也是obj,但这显然不是我们希望的结果。

箭头函数

上面的例子可以通过箭头函数来写:

class MyClass {
  name = 'MyClass';
  getName = () => {
    return this.name;
  }
}

const c = new MyClass();
const obj = {
  name: 'obj',
  getName: c.getName,
}

console.log(obj.getName()); // print: MyClass

这里有几点要注意下:

  • this的值在运行时是正确的,即使TypeScript不检查代码。
  • 这会使用更多的内存,因为每一个类实例都会拷贝一遍这个函数。
  • 你不能在派生类使用super.getName,因为在原型链中并没有入口获取基类的这个方法。

this参数

在TypeScript方法或函数定义中,第一个参数且名为this被认为有特殊含义,该参数会在编译的时候被抹除。

function fn<T>(this: T, x: number) {
  ...
}

编译后:

function fn(x) {
  ...
}

结合上面的例子,我们可以不使用箭头函数,而变为将this指定一个具体的类型,以此来确保this的正确指向。

class MyClass {
  name = 'MyClass';
  getName(this: MyClass) {
    return this.name;
  }
}

const c = new MyClass();
console.log(c.getName());

const g = c.getName;
console.log(g());
// error: 类型为“void”的 "this" 上下文不能分配给类型为“MyClass”的方法的 "this"。

这种方法也有一些注意点,刚好和使用箭头函数相反:

  • JavaScript 调用者依然可能在没有意识到它的时候错误使用类方法
  • 每个类一个函数,而不是每一个类实例一个函数
  • 基类方法定义依然可以通过 super 调用

this类型

在类中,有一个特殊的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;
  }
}

const b = new Box();
console.log(b.sameAs(b)); // true

不同于写other: Box,如果你有一个派生类,它的sameAs方法只接受来自同一个派生类的实例。

class DerivedBox extends Box {
  otherContent: string = '?';
}

const b = new Box();
const d = new DerivedBox();
console.log(d.sameAs(b));
//error: 类型“Box”的参数不能赋给类型“DerivedBox”的参数。
//error: 类型 "Box" 中缺少属性 "otherContent",但类型 "DerivedBox" 中需要该属性。

抽象类和成员

TypeScript中,类,方法,字段都是可以抽象的。 抽象方法或者抽象字段是不提供实现的。这些成员必须存在在一个抽象类中,这个抽象类也不能直接被实例化。 抽象类的作用是作为子类的基类,让子类实现所有的抽象成员。当一个类没有任何抽象成员,他就会被认为是具体的。 让我们看个例子:

abstract class Base {
  abstract getName(): string;
  printName() {
    console.log('hello, ' + this.getName());
  }
}

const b = new Base(); //error: 无法创建抽象类的实例。

我们不能使用new实例Base,因为它是一个抽象类。我们需要写一个派生类并且实现抽象成员:

class Derived extends Base {
  getName() {
    return 'world';
  }
}

const d = new Derived();
d.printName(); // print: hello, world

注意,如果我们忘记实现抽象成员,TypeScript会报错:

class Derived extends Base {
  //error: 非抽象类“Derived”不会实现继承自“Base”类的抽象成员“getName”。
}

抽象构造签名

让我们看下面这个例子,假设我们想有一个函数,传入一个抽象类并在函数中讲传入的类实例化。 你可能会写这样的代码:

function greet(ctor: typeof Base) {
  const instance = new ctor(); // error: 无法创建抽象类的实例。
}

TypeScript会报错,告诉你正在尝试实例化一个抽象类。 但如果你写一个函数传入一个构造签名:

function greet(ctor: new () => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);
greet(Base);
//error: 类型“typeof Base”的参数不能赋给类型“new () => Base”的参数。
//error: 无法将抽象构造函数类型分配给非抽象构造函数类型。

现在TypeScript会告诉你,哪一个类构造函数可以使用。Derived可以使用,因为它是具体的,Base无法使用,因为它是抽象的。

类之间的关系

大部分时候,TypeScript的类型和其他类型一个,会被结构性比较。 举个例子,这两个类可以代替彼此,因为它们的结构是一样的:

class Point1 {
  x = 0;
  y = 0;
}
class Point2 {
  x = 0;
  y = 0;
}
const p: Point1 = new Point2(); // ok

类似的还有类的子类型之间可以建立关系,即使他们没有明显的继承:

class Person {
  name!: string;
  age!: number;
}
class Employee {
  name!: string;
  age!: number;
  salary!: number;
}
const p: Person = new Employee(); // ok

注意:空类没有任何成员。在一个结构化类型系统中,没有成员的类型通常是任何其他类型的父类型。所以如果你写一个空类(只是举例,你可不要这样做),任何东西都可以用来替换它。

class Empty { }
function fn(x: Empty) {

}
// All ok!
fn(window);
fn({});
fn(fn);