TypeScript再学习

尽管项目中已经用上了TypeScript,但是主要场景下对TS的高级特性设计较少,再看过leetcode面试题后,觉得自己的了解程度还远远不够。于是参考《TypeScript Deep Dive》这本开源书(中文版)开始再学习

TypeScript Playground: http://www.typescriptlang.org/play/

TypeScript项目

编译

TS的编译过程主要通过tsconfig.json文件来配置(当然你也可以通过命令行的方式指定)。TS有些自己的默认配置,你也可以在complierOptions下自定义你的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
{
"compilerOptions": {
/* 基本选项 */
"target": "es5", // 指定 ECMAScript 目标版本: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'
"module": "commonjs", // 指定使用模块: 'commonjs', 'amd', 'system', 'umd' or 'es2015'
"lib": [], // 指定要包含在编译中的库文件
"allowJs": true, // 允许编译 javascript 文件
"checkJs": true, // 报告 javascript 文件中的错误
"jsx": "preserve", // 指定 jsx 代码的生成: 'preserve', 'react-native', or 'react'
"declaration": true, // 生成相应的 '.d.ts' 文件
"sourceMap": true, // 生成相应的 '.map' 文件
"outFile": "./", // 将输出文件合并为一个文件
"outDir": "./", // 指定输出目录
"rootDir": "./", // 用来控制输出目录结构 --outDir.
"removeComments": true, // 删除编译后的所有的注释
"noEmit": true, // 不生成输出文件
"importHelpers": true, // 从 tslib 导入辅助工具函数
"isolatedModules": true, // 将每个文件做为单独的模块 (与 'ts.transpileModule' 类似).
/* 严格的类型检查选项 */
"strict": true, // 启用所有严格类型检查选项
"noImplicitAny": true, // 在表达式和声明上有隐含的 any类型时报错
"strictNullChecks": true, // 启用严格的 null 检查
"noImplicitThis": true, // 当 this 表达式值为 any 类型的时候,生成一个错误
"alwaysStrict": true, // 以严格模式检查每个模块,并在每个文件里加入 'use strict'
/* 额外的检查 */
"noUnusedLocals": true, // 有未使用的变量时,抛出错误
"noUnusedParameters": true, // 有未使用的参数时,抛出错误
"noImplicitReturns": true, // 并不是所有函数里的代码都有返回值时,抛出错误
"noFallthroughCasesInSwitch": true, // 报告 switch 语句的 fallthrough 错误。(即,不允许 switch 的 case 语句贯穿)
/* 模块解析选项 */
"moduleResolution": "node", // 选择模块解析策略: 'node' (Node.js) or 'classic' (TypeScript pre-1.6)
"baseUrl": "./", // 用于解析非相对模块名称的基目录
"paths": {}, // 模块名到基于 baseUrl 的路径映射的列表
"rootDirs": [], // 根文件夹列表,其组合内容表示项目运行时的结构内容
"typeRoots": [], // 包含类型声明的文件列表
"types": [], // 需要包含的类型声明文件名列表
"allowSyntheticDefaultImports": true, // 允许从没有设置默认导出的模块中默认导入。
/* Source Map Options */
"sourceRoot": "./", // 指定调试器应该找到 TypeScript 文件而不是源文件的位置
"mapRoot": "./", // 指定调试器应该找到映射文件而不是生成文件的位置
"inlineSourceMap": true, // 生成单个 soucemaps 文件,而不是将 sourcemaps 生成不同的文件
"inlineSources": true, // 将代码与 sourcemaps 生成到一个文件中,要求同时设置了 --inlineSourceMap 或 --sourceMap 属性
/* 其他选项 */
"experimentalDecorators": true, // 启用装饰器
"emitDecoratorMetadata": true // 为装饰器提供元数据的支持
}
}

TS有几种不同的编译方式:

  • 运行tsc,自动定位当前目录下tsconfig.json
  • 运行tsc -p [your path],手动指定config路径
  • 运行tsc -w进入观测模式,在文件更改时自动重新编译

你可以通过不同方式指定要编译的文件:

  • files直接指定要编译的文件
  • include指定包含的文件
  • exclude指定排除的文件

配置值可以是glob格式。

声明空间

TypeScript中有两种声明空间:类型声明空间变量声明空间。前者只能用作类型注解,后者可以用来当做变量使用。

文件模块

TS中有多种模块系统选项:

  • AMD:仅在浏览器端使用
  • SystemJS:已被ES模块替代
  • ES模块:当前的支持有限
  • CommonJS:当前比较好的一个选择

一般在工程中使用ES模块语法,模块选项使用CommonJS。TS中对类型也可以同样适用import和export。

路径

通常情况由moduleResolution选项指定。这个选项在tsconfig.json中声明。在声明module: commonjs时,moduleResolution自动指定为node

导入路径分两种:

  • 相对路径,使用./或是../与文件、文件夹名称组成
  • 动态路径,TS模块解析将会模仿Node模块解析规则,即去当前目录、所有父目录的node_modules下寻找对应路径模块

如果你本身对node下的模块查找很熟悉,那么恭喜,你已经掌握了TS的模块查找。

global.d.ts

在项目中可以通过declare module 'somepath' {}的方式声明一个全局模块,这样的一个global.d.ts是声明全局类型的好地方。从js迁移到ts的项目通常需要一个这样的声明

命名空间

TypeScript下可以使用namespace拆分变量的命名空间。

1
2
3
4
5
6
7
8
9
10
11
12
namespace Logger {
export function log(msg) {
console.log(msg);
}
export function error(msg) {
console.error(msg);
}
}
// usage
Logger.log('A message');
Logger.error('An error');

namespace支持嵌套定义,在快速演示移植旧的JavaScript

动态导入

在使用ES的动态导入功能时,为了保证TS在转换代码时保留import()语句,tsconfig.json中的module需要是esnext

类型系统

概览

基本注解

包括JS的原始类型

  • string
  • number
  • boolean
  • object
  • 其他基本类型

数组类型在元素类型后追加[]实现。键值对类型使用{[key: string]: any}实现。

你可以使用interface封装自己的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface User {
name: string;
age: number;
school: {
name: string;
location: string;
postcode: string;
},
tags: {
id: string;
name: string;
}[]
}

另外,对于临时的类型需要。可以直接使用内联的方式注解类型。

1
2
3
4
5
6
7
const user: {
name: string;
title: string;
} = {
name: 'foo',
title: 'CEO'
}

特殊类型

除了上面的基本类型,还有一些常用的特殊类型。

any意味着任意类型,any类型可以兼容任何TypeScript中的类型。因此:

  • 任意类型都可以赋值给any
  • any也可以赋值给任意类型

初学者在从JavaScript迁移到TypeScript时,通常要借助any的帮助。但实际上使用any就代表告诉TypeScript编译器不要进行任何类型检查。

TypeScript 3.0特性中,出现了和any类似的unknown关键字。但是后者是type safe的.

  • 任何类型都可以赋值给unknown
  • unknown在类型检查后才能赋值给任意类型

另外在设置编译属性strictNullChecksfalse时,字面量nullundefined也可以赋值给任意类型变量。

void用来表示一个函数没有返回值,never表示不会发生的类型。例如抛出错误的函数、死循环函数的返回值类型、以及字面量空数组的元素类型。

1
2
let emptyArr = []; // never[]
let func: never = (() => throw Error('Throw an error'));

never类型间可以相互赋值,但不能和其他类型相互赋值。

泛型

计算机算法在对封装类型操作时,往往不依赖于元素类型,这种情况下使用泛型描述,TypeScript会帮助推断元素类型,保证类型安全。

1
2
3
4
5
6
function reverse<T>(items: T[]): T[] {
// ...
}
const res1 = reverse([1, 2, 3]);
res1[0] = '1'; // Error
res1[1] = 2; // ok

高级类型

交叉类型,写作A & B,表示同时具有AB两种类型的属性,新类型的对象可以使用A或者B的功能。

联合类型,写作A | B,表示是AB其中一种类型,较常用在入参的内联描述中。

1
2
3
4
5
6
7
8
9
10
11
function extend<T, U>(first: T, second: U): T & U {
let res: <T & U> = {};
return {
...first,
...second
};
}
function batchOperate(id: string | string[]) {
operate([].concat(id));
}

元组类型,这不是一种新类型,它用来描述不同类型元素的集合,就像宽容的JS数组一样。

1
2
3
type user = [string, number];
const userInfo: user = ['John', 32];
const [userName, age] = userInfo;

类型别名

除开interface,还有type可以更快捷地定义类型别名。在结合上述高级属性使用时,类型别名type会是不错的选择。对比interfacetype:

  • 使用interface定义基本的层级结构,它可以和implements以及extends配合使用
  • 在需要的类型不需要从头构造,而是从已有类型推导出来时,使用type,它更像是给这些computed type一个语义化的名字

枚举

枚举是常见的组织互斥的一组常量的方式。TypeScript中用enum关键字表示。默认的枚举是数字类型的,即使用数字作为索引值;

1
2
3
4
5
6
7
8
9
enum Color {
Red,
Green,
Blue
}
let col = Color.Red; // 0
const anotherCol = Color[0]; // 'Red'
col = 0 // ok

在使用数字类型时,枚举值可以用数字代替。默认情况下,枚举值从0开始,当然可以用= 1修改默认的枚举值。下面有一个枚举值和标记的组合用法。

1
2
3
4
5
6
7
8
9
10
11
12
enum AnimalFlags {
None = 0,
HasClaws = 1 << 0,
CanFly = 1 << 1,
EatsFish = 1 << 2,
Endangered = 1 << 3
}
interface Animal {
flags: AnimalFlags;
[key: string]: any;
}

在不同的Animalflags做位运算时可以非常方便地完成布尔代数的一些操作。

另外,枚举类型的值可以通过赋值成为字符串类型。在使用常量枚举时,TypeScript会将所有出现枚举的位置都替换成内联的常量,而不需要查找枚举变量,从而提高性能提升。

从JavaScript中迁移

总的来说有下面几步:

Step1:添加tsconfig.json文件。

Step2:修改文件拓展名为ts,使用any避免干扰你主要工作的报错,记得在之后规范

Step3:写新的TypeScript代码,减少any使用

Step4:回头为你的老代码添加类型

Step5:为你的第三方库引用类型声明,绝大多数优秀的JS库都已经有人帮忙写好类型声明

Step6:对于那些没有声明的第三方库,需要你自己书写类型声明或者declare module yourmodule一劳永逸

上面提到的类型声明,即DefinitelyTyped通过npm包的方式引入,包有固定前缀@types

有些类型声明的引入会带来全局scope的定义,可以通过在tsconfig.json里配置types来限制引入的声明文件

1
2
3
4
5
6
7
{
"compilerOptions": {
"types" : [
"jquery"
]
}
}

类型声明文件

通过declare关键字告诉TypeScript,你正在表述其他位置已经存在的全局变量。强烈建议把所有的声都放在以.d.ts结尾的文件名的文件内。环境声明不会被编译成代码。

在这样的模块、变量、类型声明文件里,interface是最常见的。用户代码中可以用类实现这些接口。但是请记住,interface旨在声明JavaScript中可能存在的数据结构。

1
2
3
4
5
6
7
8
9
10
11
interface Point {
x: number;
y: number;
z: number; // New member
}
class MyPoint implements Point {
// ERROR : missing member `z`
x: number;
y: number;
}

lib.d.ts

为了便于你能快速开始书写类型检查的代码,TypeScript自带了BOM的变量声明(包含window、document、math等)位于lib.d.ts中。你可以在你的项目下添加global.d.ts,对已有的全局变量做自己的拓展。

1
2
3
4
5
6
7
8
interface Window {
foo(): void;
}
interface DateConstructor {
lastDay(): number;
}
window.foo();
Date.lastDay()

你在自己定义的global.d.ts中可以通过拓展global,修改全局空间内的类型定义。

1
2
3
4
5
declare global {
interface String {
endsWith(suffix: string): boolean;
}
}

编译选项

  • 指定--noLib可以排除TypeScript自动引入的lib.d.ts,这通常出现在
    • 运行JavaScript的环境和标准浏览器相距甚远
    • 你希望严格控制全局变量的使用
  • 指定--lib可以对编译环境进行细粒度控制引入的包类型
    • tsc中,tsc --target es5 --lib dom,es6
    • 也可以在tsconfig.json中声明
      1
      2
      3
      "compilerOptions": {
      "lib": ["dom", "es6"]
      }

如果没有指定--lib,TypeScript会根据当前编译选项中的target导入默认库。

  • --target为es5时,导入es5、dom、scriptdom
  • --target为es6时,导入es6、dom、dom.iterable、scripthost

函数

函数注解可以使用内联或interface的方式。通常编译器可以根据代码自动推断函数的返回类型。函数入参的可选参数通过类型注解前的?说明。另外,TypeScript允许你声明函数重载,注意,这里只是声明,重载需要自己实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function adult(itself: human);
function adult(itself: human, mate: human, children: human[]);
function adult(itself: human, mate?: human, children?: human[]) {
if (!mate) {
return { itself }
}
return {
itself,
mate,
children: children || []
};
}
adult(yourself, anotherGuy); // Error

可调用的

可以用类型别名或接口表示可调用的类型。函数重载和构造函数定义都可以在其中实现。使用new作为前缀后,需要使用new关键字去调用这个函数。

1
2
3
4
5
6
7
8
interface Overloaded {
(foo: number) => void;
(foo: string) => number;
}
interface ConstructorFunc {
new (): string;
}

除此之外,还可以使用箭头函数作内联函数注解,但这种时候无法表示重载。

1
const foo: (bar: number) => string = bar => bar.toString();

字面量类型

字面量 + 联合类型构成TS中常用的字面量类型。

1
2
3
type Seasons = 'spring' | 'summer' | 'autumn' | 'winter';
type binary = 0 | 1;
type bools = true | false;

很多时候字面量类型会通过keyof一个键值对的形式来构造。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 用于创建字符串列表映射至 `K: V` 的函数
function strEnum<T extends string>(o: Array<T>): { [K in T]: K } {
return o.reduce((res, key) => {
res[key] = key;
return res;
}, Object.create(null));
}
// 创建 K: V
const Direction = strEnum(['North', 'South', 'East', 'West']);
// 创建一个类型
type Direction = keyof typeof Direction;

类型断言

TypeScript有自己的类型推断,但是允许你使用类型断言去覆盖。通过as Something<Something>的方式。但是后者接近JSX语法,所以更多使用前者。

断言是编译时的,为编译器提供分析代码的方法。TypeScript在进行类型断言时,会判断源类型S是否是目标类型T的子集,若不是则不能成功断言。

类型保护

使用JS中typeofinstanceof可以帮助TypeScript推导出条件语句内的变量类型。使用in操作符,也可以帮助TypeScript判断类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface A {
x: number;
}
interface B {
y: string;
}
function doStuff(q: A | B) {
if ('x' in q) {
// q: A
} else {
// q: B
}
}

在联合类型中,如果有类型使用字面量,TypeScript甚至可以通过判断字面量确定变量类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Foo = {
kind: 'foo'; // 字面量类型
foo: number;
};
type Bar = {
kind: 'bar'; // 字面量类型
bar: number;
};
function doStuff(arg: Foo | Bar) {
if (arg.kind === 'foo') {
console.log(arg.foo); // ok
console.log(arg.bar); // Error
} else {
// 一定是 Bar
console.log(arg.foo); // Error
console.log(arg.bar); // ok
}
}

最后,弥补JS中plain object没有instanceoftypeof自我检查的漏洞。TypeScript提供了is允许自定义类型判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 仅仅是一个 interface
interface Foo {
foo: number;
common: string;
}
interface Bar {
bar: number;
common: string;
}
// 用户自己定义的类型保护!
function isFoo(arg: Foo | Bar): arg is Foo {
return (arg as Foo).foo !== undefined;
}

类型推断

TypeScript可以根据一些规则推断出变量类型:

  • 定义变量
  • 函数返回
  • 赋值
  • 结构化(数组元素、对象属性)
  • 解构

在推断不出类型或使用第三方JS库时,类型会被判定为any。开启编译选项noImplicitAny可以避免这种问题。

类型兼容

  • 结构化:只要对象结构匹配,名称无关紧要
  • 多态性:子类实例可以复制给基类实例,相反则不行
  • 函数
    • 返回类型:数据较多的可以赋值给数据较少的
    • 入参:入参较少的可以赋值给入参较多的
    • 可选参数、Rest参数:可以相互赋值(可选和必选仅在strictNullChecks选中时相互兼容)
    • 入参类型:父类子类相互兼容(牺牲安全性确保便利性)
  • 枚举:和数字类型兼容、不同枚举间不兼容
  • 类:仅比较实例成员和实例方法,不比较构造函数和静态成员,privateprotected成员必须来自相同的类
  • 泛型:泛型对兼容性没有影响(这可能会带来一些潜在问题)
1
2
3
4
5
6
7
8
9
10
interface Poin2D {
x: number;
y: number;
}
let iTakePoint2D = (point: Point2D) => {};
let iTakePoint3D = (point: Point3D) => {};
iTakePoint3D = iTakePoint2D; // ok, 这是合理的
iTakePoint2D = iTakePoint3D; // also ok,为什么?

readonly

readonly标记接口属性,表示预期不可修改。获取使用Readonly封装一个泛型T,表示泛型内的属性均不可修改。同样地,你可以为索引签名声明readonly,表示所有索引元素均不可修改。还有些情况下,如果属性配置了getter,但没有setter也会被认为是只读的。

1
2
3
interface Foo {
readonly [x: number]: number;
}

readonlyconst的主要不同在于,前者用来修改属性,后者用于变量。

索引签名

索引即数组或键值对的索引。TypeScript中索引类型只能是stringnumber类型。这意味着,也可以使用字面量类型作为索引类型。

1
2
3
4
type Index = 'a' | 'b' | 'c';
type FromIndex = { [k in Index]?: number };
const good: FromIndex = { b: 1, c: 2 };

在一些特殊场景下,可以同时支持stringnumber类型。

1
2
3
4
interface ArrStr {
[key: string]: string | number; // 必须包括所用成员类型
[index: number]: string; // 字符串索引类型的子级
}

流动的类型

typeof可以捕获变量、类成员类型。使用typeof在捕获一个字符串字面量时,得到的类型是字面量类型。

1
2
3
4
5
6
7
8
9
10
11
12
let foo = 123;
let bar: typeof foo; // 'bar' 类型与 'foo' 类型相同(在这里是: 'number')
// 捕获字符串的类型与值
const faz = 'Hello World';
// 使用一个捕获的类型
let baz: typeof faz;
// bar 仅能被赋值 'Hello World'
baz = 'Hello World'; // ok
baz = 'anything else'; // Error

使用keyof捕获一个类型的键。

ThisType

在对象字面量方法的类型定义上声明ThisType()可以修改发放内this的类型,这常被用在this值被重新绑定的情况。

Tips

bind的隐患

lib.d.ts中,对bind的定义如下:

1
bind(thisArg: any, ...argArray: any[]): any

由于返回值是any类型,意味着bind返回的函数将失去类型检查(最新的TS 3.2已经优化了这个问题)。

柯里化函数

用一系列箭头表示。

1
2
3
4
5
6
7
8
9
10
11
// 一个柯里化函数
let add = (x: number) => (y: number) => x + y;
// 简单使用
add(123)(456);
// 部分应用
let add123 = add(123);
// fully apply the function
add123(456);

一些建议

  • 使用继承而不是as来实现泛型实例化
  • 使用as来初始化对象字面量的空对象
  • 尝试使用类组织代码
  • 小心使用setter,不要牺牲代码可读性
  • 在参数名可以很好提高可读性、入参很多时,考虑让函数接受一个对象参数

Reflect Metadata

Reflect Metadata是ES7的提案,用于在声明时添加和读取元数据。Reflect Metadata的API可以用于类或类属性上,

1
2
3
4
5
6
7
8
9
10
@Reflect.metadata('inClass', 'A')
class Test {
@Reflect.metadata('inMethod', 'B')
public hello(): string {
return 'hello world';
}
}
console.log(Reflect.getMetadata('inClass', Test)); // 'A'
console.log(Reflect.getMetadata('inMethod', new Test(), 'hello')); // 'B'

因此可以通过Reflect.getMetadata的API来获取类相关的元数据。

自定义metadatakey

可以定义自己的reflect metadata。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function classDecorator(): ClassDecorator {
return target => {
// 在类上定义元数据,key 为 `classMetaData`,value 为 `a`
Reflect.defineMetadata('classMetaData', 'a', target);
};
}
function methodDecorator(): MethodDecorator {
return (target, key, descriptor) => {
// 在类的原型属性 'someMethod' 上定义元数据,key 为 `methodMetaData`,value 为 `b`
Reflect.defineMetadata('methodMetaData', 'b', target, key);
};
}
@classDecorator()
class SomeClass {
@methodDecorator()
someMethod() {}
}
Reflect.getMetadata('classMetaData', SomeClass); // 'a'
Reflect.getMetadata('methodMetaData', new SomeClass(), 'someMethod'); // 'b'

可以借助Reflect Metadata的这个特点,实现诸如控制反转、依赖注入、装饰器等功能。

条件类型

1
T extends U ? X: Y

TypeScript 2.8的一个PR里第一次提到条件类型。条件类型主要规则如下:

  • 上式表示T如果可以赋值给U,返回类型X,否则返回Y
  • U中出现infer时,TypeScript会去推断infer后的类型变量(假设是V),如果V出在协变位置,则返回V所有可能性的联合类型,如果V出现在逆变位置,则返回V所有可能性的交叉类型(参考:协变与逆变

分布条件类型

在检查类型(extends前的类型参数)是原始类型(即没有被泛型等封装)时,称为分布条件类型(Distributive conditional types)。在实例化为实际类型时,联合类型会被拆分开。

例如,T实例化为A | B | C时,T extends U ? X : Y会被解析成(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T];
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;
type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T];
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
interface Part {
id: number;
name: string;
subparts: Part[];
updatePart(newName: string): void;
}
type T40 = FunctionPropertyNames<Part>; // "updatePart"
type T41 = NonFunctionPropertyNames<Part>; // "id" | "name" | "subparts"
type T42 = FunctionProperties<Part>; // { updatePart(newName: string): void }
type T43 = NonFunctionProperties<Part>; // { id: number, name: string, subparts: Part[] }

infer

如上文所说,infer最初出现是用来表示extends条件语句中待推断的类型。下文中若T满足(param: infer P) => any类型,则推出P类型。

1
type ParamType<T> = T extends (param: infer P) => any ? P : T;

infer有下面一些常规使用场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 获取返回值
type ReturnType<T> = T extends (...args: any[]) => infer P ? P : any;
// 获取构造函数的入参或实例类型
type Constructor = new (...args: any[]) => any;
// 获取参数类型
type ConstructorParameters<T extends new (...args: any[]) => any> = T extends new (...args: infer P) => any
? P
: never;
// 获取实例类型
type InstanceType<T extends new (...args: any[]) => any> = T extends new (...args: any[]) => infer R ? R : any;

联合infer和分布条件类型,可以实现一些骚操作,如tuple、intersection、union之间的转换。

1
2
type tupleToIntersection<T> = T[number];
type unionToIntersection<T> = (T extends any ? (k: T) => void : never) extends ((k: infer R) => void) ? R : never

如原文列的LeetCode TypeScript面试题。借助强大的条件类型和infer就能实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
interface Action<T> {
payload?: T;
type: string;
}
// 预期的类型
type Result = {
asyncMethod<T, U>(input: T): Action<U>;
syncMethod<T, U>(action: T): Action<U>;
}
interface Module {
count: number;
message: string;
asyncMethod<T, U>(input: Promise<T>): Promise<Action<U>>;
syncMethod<T, U>(action: Action<T>): Action<U>;
}
type FuncNames<T> = {
[K in keyof T]: T[K] extends Function ? K : never
}[keyof T]
type FuncProperties<T> = Pick<T, FuncNames<T>>;
type UnPackedParams<T> = T extends Promise<infer R> ? R :
T extends Action<infer R> ? R : T;
type UnPackedReturn<T> = T extends Promise<infer R> ? R : T;
type UnPackedFunction<T> = T extends (params: infer U) => infer R ? (params: UnPackedParams<U>) => UnPackedReturn<R> : never;
type Resolve<T> = {
[K in keyof T]: UnPackedFunction<T[K]>
}
// 修改 Connect 的类型,让 connected 的类型变成预期的类型
type Connect = (module: Module) => Resolve<FuncProperties<Module>>;

TypeScript编译原理

这部分内容较为简要。

编译器源文件位于src/compiler下,主要由以下部分组成:

  • 扫描器 Scanner
  • 解析器 Parser
  • 绑定器 Binder
  • 检查器 Checker
  • 发射器 Emitter

处理流程分下面几步:

  • Source --扫描器--> Token流
  • Token流 --解析器--> AST(抽象语法树)
  • AST --绑定器--> Symbols
  • AST + 符号 --检查器--> 类型验证
  • AST + 检查器 --发射器--> JavaScript代码

重要文件

  • core.ts TypeScript编译器使用的核心工具集
  • types.ts 包含整个编译器使用的关键数据结构和接口
  • system.ts 控制编译器和操作系统的所有交互

程序与抽象语法树

这里的“程序”指一个“编译上下文”。它包含SourceFile和编译选项。TypeScript有API获取SourceFile列表,每个SourceFile都是一棵抽象语法树的根节点。

节点

节点(Node)是AST的基本组成单位。Node有一些关键成员:

  • TextRange 标识节点在源文件的起止位置
  • parent?: Node 标识节点在AST中的父节点
  • 标志(flags)和修饰符(modifier)等有助于节点遍历的成员

下面有一些常用工具函数的用法:

  • ts.forEachChild 用来访问任一节点的所有子节点。这个函数会根据每个节点的node.kind判断node类型,然后再在子节点上调用cbNode。
  • ts.SyntaxKind是一个节点类型的常量枚举,用以表示不同的语法树节点
  • ts.getLeadingCommentRangests.getTrailingCommentRanges分别获取给定位置第一个换行符到token和之前的注释范围。
  • ts.getStartts.getFullStart分别获取一个token文本开始位置和上一个重要token开始扫描的位置
1
2
3
4
5
6
export const enum SyntaxKind {
Unknown,
EndOfFileToken,
SingleLineCommentTrivia,
...
}

扫描器与解析器

扫描器用于读取文本,并转换为Token流。扫描器由解析器(parser.ts)创建,为了避免创建扫描器的开销。parser.ts创建的扫描器是单例。

扫描器scanner.ts本身提供API给出扫描过程中的各种信息。尽管解析器创建的扫描器是单例,你仍可以使用createScanner创建自己的扫描器,并调用setTextsetTextPos随意扫描文件的不同位置。

解析器由程序经由CompilerHost创建,CompileHost通过getSourceFile准备好待编译文件,再交由解析器处理。解析器根据内部扫描器得到的Token构造一个SourceFile下的语法树。

解析器使用parseSourceFileWorkerparseStatements创建根节点和其余节点。具体解析每种节点的过程写在parseXxx中。

绑定器

绑定器主要职责是创建符号(Symbol)。符号将AST的声明节点和其他声明连接到相同实体上。绑定器会在检查器内被调用,检查器又被程序调用。

绑定器有几个重要函数:

  • bindSourceFile,检查file.locals是否定义,没有则交给内部函数bind处理。bindSourceFile内部还定义了许多别的内部变量,通过闭包被其他内部函数使用
  • bind处理任意节点绑定,先分配node.parent,在交给bindWorker做主要工作,之后调用bindChildren执行子节点的绑定
  • bindWorker根据节点类型,委托工作给特定的bindXXX函数完成。在bindXXX内最常用的是createSymbol函数
1
2
3
4
function createSymbol(flags: SymbolFlags, name: string): Symbol {
symbolCount++;
return new Symbol(flags, name);
}

绑定器会调用addDeclarationToSymbol绑定一个节点到符号,并把节点添加成符号的一个声明。声明就是一个有可选名字的节点。

检查器与发射器

检查器由程序初始化。在发射器中,类型检查在getDiagnostics中发生,函数被调用时会返回一个EmitResolver。这是一个createTypeChecker的本地函数集合。

TypeScript有两个发射器,emitter.ts完成TS到JavaScript,declarationEmitter.ts.ts创建声明文件(.d.ts)。

程序(Program)通过emit函数,把工作委托给emitter.tsemitFiles函数。emitFiles中借助emitJavaScript完成主要工作,

emitJavaScript中有大量内部函数,之后借给emitSourceFile发射文本,该函数设置currentSourceFile后交给本地的emit函数处理。在emitJavaScriptWorker中会根据不同符号类型调用不同发射器处理。在emitJavaScript的过程中,initializeEmitterWithSourceMaps使用带有sourceMap的版本覆盖部分本地函数,使大多数发射器代码无需考虑SourceMap。

FAQ

类型系统的行为

首先有几个需要格外说明的:

  • TypeScript使用结构化类型,即类型间的成员类型兼容即类型兼容。
  • TypeScript的类型时编译时的,在运行时并没有类型信息,无法从反射或元数据中拿到。

此外有些常见问题:

  • 没有setter的getter并没有体现出只读属性,这在TypeScript2.0+已修复
  • 更少参数的函数可以赋值给更多参数的函数;返回值更多的函数可以复制给返回值更少的函数
  • 任何类型都可以等价替代没有属性的interface
  • 类型别名只是别名而已,进行类型判断时使用的是别名对应的类型
  • 由于结构化类型,两个不同名但是结构相同的类型,实际上是相互兼容的,有个相关issue,但是尚没有结论
  • 由于TS的类型只存在于编译时,不能用运行时的typeofinstanceof判断类型。同样地,错误的TS类型转化也不会造成运行时的错误
  • 重载的最后一个声明签名对签名本身没有影响,所以为了获得重载本身的行为,需要添加额外的重载
1
2
3
4
5
function createLog(message: string): number;
function createLog(source: string, message: string): number;
function createLog(source: string, message?: string): number {
return 0;
}

一些常见的Feature Request

其他问题

空类的行为很奇怪

1
2
3
class Empty {}
var e: Empty = window;

和之前提到的一样,任何内容都可以赋值给空接口。所以一般来说,永远不要声明一个没有任何属性的类,对于子类而是如此。

如何比较类

TypeScript中,类进行结构上的比较,但是对于privateprotected属性除外。类在比较时,如果有成员是privateprotected,它们必须来自同一个声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Alpha {
x: number;
}
class Bravo {
x: number;
}
class Charlie {
private x: number;
}
class Delta {
private x: number;
}
let a = new Alpha(),
b = new Bravo(),
c = new Charlie(),
d = new Delta();
a = b; // OK
c = d; // Error

classtypeof class的区别

1
2
3
4
5
6
class MyClass {
someMethod() {}
}
var x: MyClass;
// Cannot assign 'typeof MyClass' to MyClass? Huh?
x = MyClass;

上面混用了类型名和类本身,在JavaScript中,类仅仅是一个函数而已。而在TypeScript中,类名表示类实例的类型。

子类的属性在constructor中会被父类同名属性覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base {
// Default value
myColor = 'blue';
constructor() {
console.log(this.myColor);
}
}
class Derived extends Base {
myColor = 'red';
}
// Prints "blue", expected "red"
const x = new Derived();

直接原因是在子类constructor中,父类的constructor要先执行。见Stack Overflow的解释。

interfacedeclare class的区别

  • interface用来声明一种类型,不会生成实际代码。
  • declare class用来描述一个已有类的结构

为什么我导入的模块在编译后被删除了

TypeScript默认导入的模块不包含副作用,所以会移除不用于任何表达式的模块导入。使用import 'xxx';强制导入有副作用的模块。

tsconfig.json

  • 为什么exclude中的文件仍然会被编译器选中?
    • 当exclude的文件被其他include文件依赖时,仍然会被包含进来
  • 除了include外,还有没有指定包含文件的方式
    • files指定文件列表
    • 目录中添加///<reference path="">引入