TypeScript 4.2 官方手册译文 - 类

358 阅读11分钟

其它章节的译文:
从类型创建类型
模块
对象类型

概述

背景阅读

Classes (MDN)

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

和其它的 JavaScript 语言特性一样,TypeScript 为类添加了类型注释和其它语法用于表示和其它类型之间的关系。

类成员

这里是一个最基本的类 - 空类:

class Point {}

这个类没有任何作用,让我们来添加一些成员。

字段

字段声明在类上创建了一个公共的可写属性:

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

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

和其它地方一样,类型注释是可选的,如果不指定就是隐式的 any 类型。

字段可以设置初始值;当类实例化时会自动执行初始化过程:

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

const pt = new Point();
// Prints 0, 0
console.log(`${pt.x}, ${pt.y}`);

就像 const, letvar 一样,类属性的初始化会被用来推断其类型:

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 不会通过分析构造函数中调用的方法来检测初始化,因为继承类可能会重写这些方法导致初始化失败。

如果你确实需要通过方法而不是构造函数初始化字段(例如,可能一个外部库被引入到类),可以使用明确赋值断言操作符( 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.

构造函数

背景阅读:

Constructor (MDN)

类的构造函数和函数十分相似。你可以添加带有类型注释,默认值和重载的参数:

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

和 JavaScript 一样,如果你有一个基类,你需要调用 super();在你的构造函数体内使用任何 this. 成员之前:

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 会在必要的时候告诉你。

方法

背景阅读:

Method definitions

类中的函数属性称之为方法。方法可以使用和一般函数以及构造函数完全一样的类型注释:

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() {
    // This is trying to modify 'x' from line 1, not the class property
    x = "world";
    // Type 'string' is not assignable to type 'number'.
  }
}

Getters / Setters

类同样也可以有访问器:

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

注意,没有额外逻辑的由字段支撑的 get/set 在 JavaScript 中很少用到。如果你不需要在 get/set 操作期间添加额外的逻辑,最好是暴露公开字段。

TypeScript 对于访问器有一些特殊的推断规则:

  • 如果不存在 set , 属性自动变为 readonly
  • setter 参数的类型从 getter 的返回值类型推断
  • 如果 setter 参数有类型注释,它必须和 getter 的返回值类型匹配
  • getter 和 setter 必须有同样的成员可见性

不可能存在 getting 和 setting 类型不一样的访问器。

如果你有一个 getter 而没有 setter,那么这个字段自动就是 readonly

索引签名

类可以声明索引签名;这和其它对象类型的索引签名一样:

class MyClass {
  [s: string]: boolean | ((s: string) => boolean);
  check(s: string) {
    return this[s] as boolean;
  }
}

由于索引签名类型也需要获取方法的类型,要有效的使用这些类型并不容易。一般地,将索引数据存放在其它地方而不是类实例本身会是更好的选择。

类继承

和其它面向对象语言一样,JavaScript 中的类可以继承基类。

implements 子句

你可以使用 implements 子句让类满足特定的 interface。如果类没有正确的实现该接口就会报错:

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 {.

注意事项

implements 子句只是检查类是否可以作为接口类型处理,明白这一点很重要。它完全不会改变类或是其方法的类型。最常见的错误就是假定 implements 会改变 class 类型 - 这是不会发生的!

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.toLowerCase() === "ok";
    // ^ = any
  }
}

在这个例子中,我们可能期望 s 的类型会受 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 keyword

类可以继承自基类。派生类拥有基类的全部属性和方法,还可以定义额外的成员。

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

重写方法

背景阅读:

super keyword

派生类也可以重写基类的字段和属性。你可以使用 super. 语法获取基类的方法。注意,因为 JavaScript 类是一个简单的查找对象,没有“super field” 的概念。

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

初始化顺序

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 的值,因为派生类的字段初始化还没执行。

继承内置类型

注:如果你不打算继承像 Array, Error, Map 这样的内置类型,你可以跳过这部分。

成员可见性

你可以通过 TypeScript 控制某些方法或是属性对类外部的代码是否可见。

public

类成员的可见性默认是 public 。公开成员可以在任何地方被访问:

class Greeter {
  public greet() {
    console.log("hi!");
  }
}
const g = new Greeter();
g.greet();

因为 public 已经是默认的可见性修饰词,你不需要把它写在类成员上,但可能会因为风格/可读性这样做。

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();
g.getName();
// Property 'getName' is protected and only accessible within class 'Greeter' and its subclass.

暴露受保护的成员

派生类需要遵循其基类的约定,但可以选择公开具有更多功能的更通用类型。这就包括使得受保护的成员变为公开的:

class Base {
  protected m = 10;
}
class Derived extends Base {
  // Np modifier, so default is 'public'
  m = 15;
}
const d = new Derived();
console.log(d.m);	// OK

注意 Derived 已经能够随意读写 m , 因此在这种情况下给出 "security" 警告并无意义。这里要注意的是,在派生类中我们需要小心重复带有 protected 修饰词的成员,如果这种暴露行为不是我们的本意。

跨层级的 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'.
  }
}

比如,Java 认为这是合法的。另一方面,C# 和 C++ 则认为不合法。

TypeScript 站在了 C# 和 C++ 这边,因为在 Derived2 中访问 x 应该只对 Derived2 的子类合法, 而Derived1 并不是其中之一。此外,如果通过 Derived2 引用访问 x 是非法的(它当然应该是非法的!),通过基类引用访问它永远不会改善这种情况。

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 the class 'Base'.
  }
}

由于私有成员对派生类不可见,派生类不能增加其可见性:

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

跨实例的 private 访问

不同的 OOP 语言对于同一个类的不同实例是否可以访问彼此的私有成员意见不一。虽然像 Java、C#、C++、Swift 和 PHP 这样的语言允许这样做,Ruby 却不允许。

TypeScript 是允许跨实例的 private 访问的:

class A {
  private x = 10;
  
  public sameAs(other: A) {
    // No error
    return other.x === this.x;
  }
}

说明

就像 TypeScript 类型系统的其他方面一样,privateprotected 仅在类型检查期间执行。这意味着 JavaScript 运行时构造诸如 in 或是简单的属性查看仍然可以访问私有或受保护的成员:

class MySafe {
  private secretKey = 12345;
}

// In a JavaScript file...
const a = new MySafe();
// will print 12345
console.log(a.secretKey);

如果你需要保护类中的值不遭受恶意的攻击,应该使用闭包、弱映射或私有字段这样的机制提供强运行时隐私。

静态成员

背景阅读

Static Members

类可以有 static 成员。这些成员不与类的特定实例相关联。它们可以通过类的构造函数对象访问:

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

静态成员同样也可以使用 publicprotectedprivate 可见性修饰词:

class MyClass {
  private static x = 0;
}
console.log(MyClass.x);
// Property 'x' is private and only accessible within class 'MyClass'.

静态成员也可以继承:

class Base {
  static getGreeting() {
    return "Hello world";
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting();
}

特殊的静态成员名称

重写 Function 原型中的属性通常是不安全的/不可能的。因为类本身就是可以通过 new 调用的函数、某些静态名称是不可用的。函数属性如 namelengthcall 是不能定义为 static 成员的:

class S {
  static name = "S!";
  // Static property 'name' conflicts with build-in property 'Function.name' of constructor function 'S'.
}

为什么没有静态类?

TypeScript(和JavaScript)不像 C# 和 Java 那样有一个叫做 static class 的构造。

这些构造之所以存在,是因为这些语言强制所有数据和函数都在类中;因为 TypeScript 中不存在这种限制,所以不需要它们。在JavaScript/TypeScript 中,只有一个实例的类通常表示为普通对象。

例如,我们在 TypeScript 中不需要“静态类”语法,因为普通对象(甚至是顶级函数)也可以完成这项工作:

// Unnecessary "static" class
class MyStaticClass {
  static doSomething() {}
}

// Preferred (alternative 1)
function doSomething() {}

// Preferred (alternative 2)
const MyHelperObject {
  doSomething() {},
}

泛型类

类很像接口,可以是泛型的。当用 new 实例化泛型类时,其类型参数的推断方式与函数调用相同:

class Box<Type> {
  contents: Type;
  constructor(value: Type) {
    this.contents = value;
  }
}
const b = new Box("hello!");
//	  ^ = const b: Box<string> 

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

静态成员中的类型参数

这个代码是不合法的,原因也不明显:

class Box<Type> {
  static defaultValue: Type;
  // Static members cannot reference class Type parameters.
}

记住,类型总是被完全删除的!在运行时,只有一个 Box.defaultValue 属性槽。这意味着设置 Box<string>.defaultvalue (如果可能的话)也会更改 Box<number>.defaultvalue - 这不太好。泛型类的 static 成员永远不能引用类的类型参数。

运行时类的 this

背景阅读

this keyword

需要记住,TypeScript 不会改变 JavaScript 的运行时行为,而 JavaScript 以拥有一些特殊的运行时行为而闻名。

JavaScript 处理 this 的方式确实不同寻常:

class MyClass {
  name = "MyClass";
  getName() {
    return this.name;
  }
}
const c = new MyClass();
const obj = {
  name: "obj",
  getName: c.getName,
};

// Prints "obj", not "MyClass"
console.log(obj.getName());

长话短说,默认的,函数内 this 的值取决于函数是如何被调用的。在本例中,因为函数是通过 obj 引用调用的,所以 this 的值是 obj 而不是类实例。

这是你不希望发生的事情!TypeScript 提供了一些方法来减轻或防止这类错误。

箭头函数

背景阅读

Arrow functions

如果你有一个函数在被调用时会失去 this 上下文,使用箭头函数属性而不是方法定义是有意义的:

class MyClass {
  name = "MyClass";
  getName = () => {
    return this.name;
  }
}
const c = new MyClass();
const g = c.getName;
// Prints "MyClass" instead of crashing
console.log(g());

this 有一些权衡:

  • this 的值保证在运行时是正确的,即使对没有使用 TypeScript 检查的代码也是如此
  • 这将使用更多的内存,因为每个类实例对于按照这种方式定义的每个函数都会拷贝一份副本
  • 你不能在派生类中使用 super.getName ,因为在原型链中没有获取基类方法的入口

this 参数

在方法或函数定义中,命名为 this 的初始参数在 TypeScript 中有特殊含义。这些参数在编译期间被删除:

// TypeScript input with 'this' parameter
function fn(this: SomeType, x: number) {
  /* ... */
}
// JavaScript output
function fn(x) {
  /* ... */
}

TypeScript 会检查调用带有 this 参数的函数时上下文是否正确。我们可以在方法定义中添加 this 参数静态地确保正确调用方法,而不是使用箭头函数:

class MyClass {
  name = "MyClass";
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// OK
c.getName();

// Error, would crash
const g = c.getName;
console.log(g());
// The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'.

这种方法与箭头函数方法有着相反的权衡:

  • 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

你还可以在参数类型注释中使用它:

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

这与编写 other: Box 不同 -- 如果你有一个派生类,其 sameAs 方法现在只接受该派生类的其他实例:

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

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

const base = new Box();
const derived = new DerivedBox();
derived.sameAs(base);
// Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'.
// Property 'otherContent' is missing in type 'Box' but required in type 'derivedBox'.

参数属性

TypeScript 提供了特殊的语法,可以将构造函数参数转换为具有相同名称和值的类属性。这些被称为参数属性,通过在构造函数参数前加上可见性修饰词 publicprivateprotectedreadonly 来创建。结果字段得到那些修饰词:

class A {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
  	// No body necessary    
  }
}
const a = new A(1, 2, 3);
console.log(a.x);
//  ^ = (property) A.x: number
console.log(a.z);
// Property 'z' is private and only accessible within class 'A'.

类表达式

背景阅读

class expression

类表达式非常类似于类声明。唯一真正的区别是类表达式不需要名称,尽管我们可以通过它们最终绑定到的任何标识符来引用它们:

const someClass = class<Type> {
  content: Type;
  constructor(value: Type) {
    this.content = value;
  }
};

const m = new someClass("Hello world!");
//  ^ = const m: someClass<string>

抽象类和成员

TypeScript 中的类、方法和字段可能是抽象的。

抽象方法或抽象字段是没有提供具体实现的。这些成员必须存在于不能直接实例化的抽象类中。

抽象类的作用是作为实现所有抽象成员的子类的基类。当一个类没有任何抽象成员时,就说它是具体的。

让我们来看一个例子:

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

const b = new Base();
// Cannot create an instance of an abstract class.

我们不能用 new 实例化 Base,因为它是抽象的。相反,我们需要创建一个派生类并实现其抽象成员:

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

const d = new Derived();
d.printName();

请注意,如果我们忘记实现基类的抽象成员,就会报错:

class Derived extends Base {
  // Non-abstract class 'Derived' does not implement inherited abstract member 'getName' from class 'Base'.
  // forgot to do anything
}

抽象构建签名

有时,你希望从抽象基类中继承生成类实例的类构造函数。

例如,你可能会这样做:

function greet(ctor: typeof Base) {
  const instance = new ctor();
  // Cannot create an instance of an abstract class.
  instance.printName();
}

TypeScript 正确地告诉你,你正在尝试实例化一个抽象类。毕竟,根据 greet 的定义,编写这段代码是完全合法的,这将最终构造一个抽象类:

// Bad!
greet(Base);

相反,你需要编写一个函数来接受带有构造函数签名的内容:

function greet(ctor: new () => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);
greet(Base);
// Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'.
// Cannot assign an abstract constructor type a non-abstract constructor type.

现在 TypeScript 正确地告诉你哪些类构造函数可以被调用 -- 派生类可以,因为它是具体的,但基类不能。

类之间的关系

在大多数情况下,TypeScript 中的类和其他类型一样,都是从结构上进行比较的。

例如,这两个类可以用来代替对方,因为它们是相同的:

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

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

// OK
const p: Point1 = new Point2();

类似地,即使没有显式继承,类之间也存在子类型关系:

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

class Employee {
  name: string;
  age: number;
  salary: number;
}

// OK
const p: Person = new Empolyee();

这听起来很简单,但有些情况似乎比其他情况更奇怪。

空类没有成员。在结构类型系统中,没有成员的类型通常是任何其他类型的超类型。因此,如果你编写了一个空类(不要这样做!),任何东西都可以用来代替它:

class Empty {}

function fn(x: Empty) {
  // can't do anything with 'x', so I won't
}

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