最初发表于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 已经被定义为类上可以存在的属性。然而在默认情况下,className和id没有值。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可以推断出sharpTeeth 和fangs 是相同的类型,后者是一个数字。
注意,更复杂的初始化代码,如使用初始化函数,仍然需要手动输入。在下面的例子中,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。通过使用protected 或private ,可以进一步限制对新属性的访问。
获取器和设置器
类属性可以有获取器和设置器。获取器可以让你计算一个值作为属性值返回,而设置器可以让你在属性被设置时运行任意代码。
考虑一个表示简单二维向量的类:
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() === 'symbol'):
// 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
参见我们的ES6符号。Drumroll please!一文,以了解更多关于符号的信息。
装饰器
请注意,装饰器很早就被添加到TypeScript中,并且只在--experimentalDecorators 标志下可用,因为它们没有反映TC39提案的当前状态。装饰器是一个函数,它允许对类、属性、方法和参数进行简略的线上修改。一个方法装饰器接收3个参数:
target:该方法所定义的对象key:方法的名称descriptor该方法的对象描述符
装饰器函数可以选择返回一个属性描述符来安装在目标对象上:
function myDecorator(target, key, descriptor) {
}
class MyClass {
@myDecorator
myMethod() {}
}
myDecorator 将以参数值 、 、 来调用。MyClass.prototype 'myMethod' Object.getOwnPropertyDescriptor(MyClass.prototype, 'myMethod')
TypeScript也支持计算的属性名称和Unicode转义序列。
关于装饰器的更多信息,请参见我们的TypeScript装饰器文章。
接口与类型
Typescript有interface 和type 两个别名,但它们经常会被错误地使用。这两者之间的一个关键区别是,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应用程序,请今天与我们联系,与我们交谈