Advanced TypeScript 4.2 Concepts:类和类型

128 阅读6分钟

最初发表于2018年11月。2020年6月更新。本文介绍了TypeScript 4.2的特点和功能。

虽然TypeScript在执行基本任务时非常简单易懂,但对其类型系统的工作原理有更深入的了解,对解锁高级语言功能至关重要。一旦我们对TypeScript的真正工作原理有了更多了解,我们就可以利用这些知识来编写更简洁、更有条理的代码。

如果你发现自己对本文讨论的一些概念有困难,可以尝试先阅读《TypeScript权威指南》,以确保你对所有的基础知识有一个坚实的了解。

class 关键字的背后

在TypeScript中,class 关键字为生成构造函数和执行简单的继承提供了一个更熟悉的语法。它的语法与ES2015的 class语法大致相同,但有一些关键的区别。最值得注意的是,它允许非方法属性,类似于这个阶段3的提议。事实上,对将被类使用的每个实例方法或属性的声明是强制性的,因为这将被用来在类内为this 的值建立一个类型。

但如果我们因为某些原因不能使用class 关键字呢?我们将如何制作一个等价的结构?这有可能吗?为了回答这些问题,让我们从TypeScript类的一个基本例子开始:

class Point {
  static fromOtherPoint(point: Point): Point {
    // ...
  }

  x: number;
  y: number;

  constructor(x: number, y: number) {
    // ...
  }

  toString(): string {
    // ...
  }
}

这个典型的类包括一个静态方法、实例属性和实例方法。当创建这个类型的新实例时,我们会调用new Point(, ),而当引用这个类型的实例时,我们会使用Point 。但这是如何运作的呢?Point 类型和点的构造函数不是同一个东西吗?实际上,不是的!在TypeScript中,类型是重叠的。

在TypeScript中,类型是通过一个完全独立的类型系统覆盖在JavaScript代码上的,而不是成为JavaScript代码本身的一部分。这意味着TypeScript中的接口("类型")可以--而且经常--使用与JavaScript中的变量相同的标识符名称,而不会引入名称冲突。(在类型系统中,唯一的一次标识符指的是JavaScript中的一个名字,当使用typeof操作符的时候)。

当在TypeScript中使用class 关键字时,你实际上是在用同一个标识符创建两个东西:

  • 一个包含类的所有实例方法和属性的TypeScript接口;和
  • 一个具有不同(匿名)构造函数类型的JavaScript变量

换句话说,上面的例子类实际上只是这个代码的速记:

// our TypeScript `Point` type
interface Point {
  x: number;
  y: number;
  toString(): string;
}

// our JavaScript `Point` variable, with a constructor type
let Point: {
  new (x: number, y: number): Point;
  prototype: Point;

  // static class properties and methods are actually part
  // of the constructor type!
  fromOtherPoint(point: Point): Point;
};

// `Function` does not fulfill the defined type so
// it needs to be cast to <any>
Point = <any> function (this: Point, x: number, y: number): void {
  // ...
};

// static properties/methods go on the JavaScript variable...
Point.fromOtherPoint = function (point: Point): Point {
  // ...
};

// instance properties/methods go on the prototype
Point.prototype.toString = function (): string {
  // ...
};

TypeScript也支持ES6类表达式

向类添加类型属性

如上所述,在TypeScript中向类添加非方法属性是被鼓励的,也是类型系统理解类上可用的东西所需要的:

class Animal {
 species: string;
 color: string = 'red';
 id: string;
}

在这个例子中,className,color, 和id 已经被定义为类上可以存在的属性。然而在默认情况下,classNameid没有值。TypeScript可以通过--strictPropertyInitialization 标志来警告我们,如果一个类的属性没有直接在定义中或在构造函数中被赋值,它将抛出一个错误。分配给color的值实际上并没有直接分配给prototype 。相反,它的值是在转码后的构造函数内分配的,这意味着直接分配非原始类型是安全的,没有任何意外地与类的所有实例共享这些值的风险。

在复杂的应用程序中,一个常见的问题是如何将相关的功能集聚在一起。我们已经通过将代码组织成模块的方式来实现这一目标,但对于那些只适用于单个类或接口的类型,该怎么办呢?例如,如果我们有一个接受attributes 对象的Animal 类:

export class Animal {
  constructor(attributes: {
    species: string;
    id: string;
    color: string;
  }) {
    // ...
  }
}

export default Animal;

在这段代码中,我们已经成功地为attributes 参数定义了一个匿名类型,但这是非常脆的。当我们对Animal 进行子类化并想添加一些额外的属性时会发生什么?我们将不得不重新编写整个类型。或者,如果我们想在多个地方引用这个类型,比如在一些实例化Animal 的代码中,我们就不能这样做,因为它是一个分配给函数参数的匿名类型。

为了解决这个问题,我们可以用一个interface 来定义构造函数参数,并与类一起导出:

export interface AnimalProperties {
  species?: string;
  id?: string;
  color?: string;
}

export class Animal {
  constructor(attributes: AnimalProperties = {}) {
    for (let key in attributes) {
      this[key] = attributes[key];
    }
  }
}

export default Animal;

现在,我们不再有一个匿名的对象类型弄脏我们的代码,而是有一个特定的AnimalProperties 接口,可以被我们的代码以及任何其他导入Animal 的代码所引用。这意味着我们可以很容易地对我们的属性参数进行子类化,同时保持一切干燥和良好的组织:

import Animal, { AnimalProperties } from './Animal';

export interface LionProperties extends AnimalProperties {
  roarVolume: string;
}

// normal class inheritance…
export class Lion extends Animal {
  // replace the parameter type with our new, more specific subtype
  constructor(attributes: LionProperties = { roarVolume: 'high' }) {
    super(attributes);
  }
}

export default Lion;

如前所述,使用这种模式,我们也可以通过导入需要的接口从其他代码中引用这些类型:

import Animal, { AnimalProperties } from './Animal';
import Lion from './Lion';

export function createAnimal<
  T extends Animal = Animal,
  K extends AnimalProperties = AnimalProperties
>(Ctor: { new (...args: any[]): T; }, attributes: K): T {
  return new Ctor(attributes);
}

// w has type `Animal`
const w = createAnimal(Animal, { species: 'rodent' });
// t has type `Lion`
const t = createAnimal(Lion, { species: 'feline', roarVolume: 'massive' });

从TypeScript 4.0开始,类的属性类型可以从构造函数中的赋值推断出来。 以下面的例子为例:

class Animal {
 sharpTeeth; // <-- no type here! 😱
 constructor(fangs = 2) {
  this.sharpTeeth = fangs;
 }
}

在TypeScript 4.0之前,这将导致sharpTeeth 被打成任何类型(如果使用严格选项则为错误)。但是现在,TypeScript可以推断出sharpTeethfangs 是相同的类型,后者是一个数字。

注意,更复杂的初始化代码,如使用初始化函数,仍然需要手动输入。在下面的例子中,Typescript将不能推断类型,你将不得不手动输入类的属性:

class Animal {
 sharpTeeth!: number;
 
 constructor() {
   this.initialize();
 }
 
 initialize() {
   this.sharpTeeth = 2;
 }
}

访问修改器

TypeScript中类的另一个受欢迎的补充是访问修饰符,它允许开发者将方法和属性声明为public,private,protected, 和readonly从TS 3.8开始,ECMAScript的私有字段也可以通过# 字符来支持,从而形成一个硬的私有字段。请注意,访问修饰符不能用在硬私有字段上:

class Widget {
  class: string; // No modifier implies public
  private _id: string;
  #uuid: string;
  readonly id: string;

  protected foo() {
    // ...
  }
}

如果没有提供修饰符,那么该方法或属性被认为是public ,这意味着它可以被内部或外部访问。如果它被标记为private ,那么该方法或属性只能在类的内部访问。然而,这个修改器只在编译时可执行。TypeScript编译器会对所有不恰当的使用发出警告,但它不会在运行时阻止不恰当的使用。protected 意味着该方法或属性只能在该类或任何扩展该类的内部访问,而不能在外部访问。最后,readonly ,如果该属性的值在类构造函数中的初始赋值后被改变,将导致TypeScript编译器抛出一个错误。

在构造函数中定义类的属性

类属性也可以通过类构造函数定义, 以尖牙为例:

class Animal {
 sharpTeeth; 
 constructor(fangs = 2) {
  this.sharpTeeth = fangs;
 }
}

定义和初始化sharpTeeth 属性可以通过对构造函数参数应用一个访问修饰符来简化。

class Animal {
 constructor(public sharpTeeth = 2) {}
}

构造函数现在将sharpTeeth 属性定义为public,并将其初始化为传入构造函数的值或默认的2。通过使用protectedprivate ,可以进一步限制对新属性的访问。

获取器和设置器

类属性可以有获取器和设置器。获取器可以让你计算一个值作为属性值返回,而设置器可以让你在属性被设置时运行任意代码。

考虑一个表示简单二维向量的类:

class Vector2 {
 constructor(public x: number, public y: number) {}
}
 
const v = new Vector2(1, 1);

现在假设我们想给这个向量一个长度属性。一个选择是添加一个属性,每当x或y的值发生变化时,这个属性就会保持更新。 我们可以使用setter来监控x和y的值:

class Vector2 {
   private _x = 0;
   private _y = 0;
 
   length!: number;
 
   get x() { return this._x; }
   get y() { return this._y; }
 
 
   set x(value: number) {
       this._x = value;
       this.calculateLength();
   }
 
   set y(value: number) {
       this._y = value;
       this.calculateLength();
   }
 
   private calculateLength() {
       this.length = Math.sqrt(this._x ** 2 + this._y ** 2);
   }
 
   constructor(x: number, y: number) {
       this._x = x;
       this._y = y;
       this.calculateLength();
   }
}
 
const v = new Vector2(1, 1);
 
console.log(v.length);

现在,每当x或y发生变化时,我们的length ,重新计算并准备使用。虽然这个方法可行,但这并不是一个非常实用的解决方案。每当一个属性发生变化时,重新计算向量的长度有可能导致大量的计算被浪费。如果我们在代码中不使用length 属性,我们就根本不需要进行这种计算!我们可以使用一个更优雅的解决方案,即使用 。

我们可以使用getter来制作一个更优雅的解决方案。使用getter,我们将定义一个新的只读属性,length ,这个属性只在请求时才会被计算:

class Vector2 {
 get length() {
   return Math.sqrt(this.x ** 2 + this.y ** 2);
 }
 
 constructor(public x: number, public y: number) {}
}
 
const v = new Vector2(1, 1);
console.log(v.length);

这就好得多了!我们不仅有更少的整体代码,而且我们的长度computation ,只在我们需要时才运行。

抽象类

TypeScript支持abstract 关键字,用于类和它们的方法、属性和访问器。一个抽象类可以有没有实现的方法、属性和访问器,并且不能被构造。参见抽象类和方法以及抽象属性和访问器以获得更多信息。

混合类和组合类

TypeScript 2.2 做了一些改变,使其更容易实现混合类和/或组合类。这是通过消除对类的一些限制来实现的。例如,它可以从一个构造交集类型的值中扩展出来。他们还改变了交叉类型的签名被组合的方式。

符号、装饰器,以及更多

符号

符号是唯一的、不可变的标识符,可以作为对象的键。它们的好处是可以保证命名冲突的安全。符号是一个原始值,其类型为 "符号" (typeof Symbol() === &#039;symbol&#039;):

// even symbols created from the same key are unique
Symbol('foo') !== Symbol('foo');

当作为对象键使用时,你不必担心名称冲突的问题:

const ID_KEY = Symbol('id');
let obj = {};
obj[ID_KEY] = 5;
obj[Symbol('id')] = 10;
obj[ID_KEY] === 5; // true

TS中的类型信息只对内置符号有效。

参见我们的ES6符号。Drumroll please!一文,以了解更多关于符号的信息。

装饰器

请注意,装饰器很早就被添加到TypeScript中,并且只在--experimentalDecorators 标志下可用,因为它们没有反映TC39提案的当前状态。装饰器是一个函数,它允许对类、属性、方法和参数进行简略的线上修改。一个方法装饰器接收3个参数:

  • target:该方法所定义的对象
  • key:方法的名称
  • descriptor该方法的对象描述符

装饰器函数可以选择返回一个属性描述符来安装在目标对象上:

function myDecorator(target, key, descriptor) {
}

class MyClass {
    @myDecorator
    myMethod() {}
}

myDecorator 将以参数值 、 、 来调用。MyClass.prototype &#039;myMethod&#039; Object.getOwnPropertyDescriptor(MyClass.prototype, &#039;myMethod&#039;)

TypeScript也支持计算的属性名称Unicode转义序列

关于装饰器的更多信息,请参见我们的TypeScript装饰器文章。

接口与类型

Typescript有interfacetype 两个别名,但它们经常会被错误地使用。这两者之间的一个关键区别是,Interface仅限于描述Object 结构,而type 可以由Objects、primitives、unions types等组成。

这里的另一个区别是它们的预期用途。一个接口主要描述了某个东西应该如何实现,应该如何使用。另一方面,一个类型是对数据类型的定义:

// union type of two species
type CatSpecies = 'lion' | 'tabby';

// interface defining cat shape and using the above type
interface CatInterface {
   species: CatSpecies;
   speak(): string;
}

class Cat implements CatInterface {
   constructor(public species: CatSpecies) { }
   speak() {
      return this.species === 'lion' ? 'ROAR' : 'meeeooow';
   }
}

const lion = new Cat("lion");
console.log(lion.speak());
// ROAR

类型的一个好处是你可以通过in 关键字使用计算属性。这以编程方式生成了映射的类型。你可以进一步利用这个例子,把它与泛型的使用结合起来,定义一个需要指定传入的泛型的键的类型:

type FruitColours<T> = { [P in keyof T]: string[] };

const fruitCodes = {
   apple: 11123,
   pear: 33343,
   banana: 33323
};

// This object must include all the keys present in fruitCodes.
// If you used this type again and passed a different generic
// then different keys would be required.
const fruitColours: FruitColours< typeof fruitCodes > = {
   apple: ['red', 'green'],
   banana: ['yellow'],
   pear: ['green']
};

综上所述

希望这篇文章有助于揭开TypeScript类型系统的部分神秘面纱,并给你一些关于如何利用其高级功能来改善你自己的TypeScript应用程序结构的想法。如果你有任何其他问题,或者想要一些专家协助你编写TypeScript应用程序,请今天与我们联系,与我们交谈