Jansiel Notes

原文:TypeScript中高级应用与最佳实践 | AlloyTeam

作者:TAT.haoyue

TypeScript中高级应用与最佳实践

当我们讨论TypeScript时,我们在讨论什么?

TypeScript的定位

  • JavaScript的超集
  • 编译期行为
  • 不引入额外开销
  • 不改变运行时行为
  • 始终与 ESMAScript 语言标准一致 (stage 3语法)

TypeScript中的Decorator较为特殊,为Angular团队和TypeScript团队交易的结果,有兴趣可自行搜索相关资料。而且近期EcmaScript规范中的decorator提案内容发生了剧烈变动,建议等此语法标准完全稳定后再在生产项目中使用。

本文只讨论图中蓝色部分。

类型的本质是契约

JSDoc 也能标注类型,为什么要用 TypeScript?

  • JSDoc只是注释,其标注没有约束作用
  • TS有—checkJs选项,但不好用

TS会自动推断函数返回值类型,为什么要多此一举标注出来?

  • 契约高于实现
  • 检查返回值是否写错
  • 写return时获得提醒

开始之前

几组VSCode快捷键

  • 代码补全 control + 空格 ctrl + 空格
  • 快速修复 command + . ctrl + .
  • 重构(重命名) fn + f2 f2

一个网站

TypeScript Playground

初始化项目

自行配置

1"compilerOptions": {
2    "esModuleInterop": true,
3    "allowSyntheticDefaultImports": true,
4    "noImplicitAny": true,
5    "strictNullChecks": true,
6    "noImplicitThis": true,
7    "moduleResolution": "node"
8}
9

react项目运行 create-react-app ${项目名} —scripts-version=react-scripts-ts

小试牛刀

& 和 | 操作符

虽然在写法上,这两个操作符与位运算逻辑操作符相同。但在语义上,它们与位运算刚好相反。

位运算的表现:

11001 | 1010 = 1011    // 合并1
21001 & 1010 = 1000    // 只保留共有1
3

在TypeScript中的表现:

 1interface IA {
 2	a: string
 3	b: number
 4}
 5
 6type TB = {
 7	b: number
 8	c: number[]
 9}
10
11type TC = IA | TB;    // TC类型的变量的键只需包含ab或bc即可,当然也可以abc都有
12type TD = IA & TB;    // TD类型的变量的键必需包含abc
13

对于这种表现,可以这样理解: & 表示必须同时满足多个契约, | 表示满足任意一个契约即可。

interface 和 type 关键字

interface 和 type 两个关键字因为其功能比较接近,常常引起新手的疑问:应该在什么时候用type,什么时候用interface?
interface 的特点如下:

  • 同名interface自动聚合,也可以和已有的同名class聚合,适合做polyfill
  • 自身只能表示object/class/function的类型

建议库的开发者所提供的公共api应该尽量用interface/class,方便使用者自行扩展。举个例子,我之前在给腾讯云 Cloud Studio 在线编辑器开发插件时,因为查阅到的 monaco 文档是0.15.5版本(当时的最新版本)的,而 Cloud Studio 使用的monaco版本为0.14.3,缺失了一些我需要的API,所以需要手动polyfill一下。

 1/**
 2 * Cloud Studio使用的monaco版本较老0.14.3,和官方文档相比缺失部分功能
 3 * 另外vscode有一些特有的功能,必须适配
 4 * 故在这里手动实现作为补充
 5 */
 6declare module monaco {
 7  interface Position {
 8    delta(deltaLineNumber?: number, deltaColumn?: number): Position
 9  }
10}
11
12// monaco 0.15.5
13monaco.Position.prototype.delta = function (this: monaco.Position, deltaLineNumber = 0, deltaColumn = 0) {
14  return new monaco.Position(this.lineNumber + deltaLineNumber, this.column + deltaColumn);
15}
16

与interface相比,type的特点如下:

  • 表达功能更强大,不局限于object/class/function
  • 要扩展已有type需要创建新type,不可以重名
  • 支持更复杂的类型操作
1type Tuple = [number, string];
2const a: Tuple = [2, 'sir'];
3type Size = 'small' | 'default' | 'big' | number;
4const b: Size = 24;
5

基本上所有用interface表达的类型都有其等价的type表达。但我在实践的过程中,也发现了一种类型只能用interface表达,无法用type表达,那就是往函数上挂载属性。

1interface FuncWithAttachment {
2    (param: string): boolean;
3    someProperty: number;
4}
5
6const testFunc: FuncWithAttachment = ...;
7const result = testFunc('mike');    // 有类型提醒
8testFunc.someProperty = 3;    // 有类型提醒
9

extends 关键字

extends本意为“拓展”,也有人称其为“继承”。在TypeScript中,extends既可当作一个动词来扩展已有类型;也可当作一个形容词来对类型进行条件限定(例如用在泛型中)。在扩展已有类型时,不可以进行类型冲突的覆盖操作。例如,基类型中键a为string,在扩展出的类型中无法将其改为number。

 1type A = {
 2    a: number
 3}
 4
 5interface AB extends A {
 6    b: string
 7}
 8// 与上一种等价
 9type TAB = A & {
10    b: string
11}
12

泛型

在前文我们已经看到类型实际上可以进行一定的运算,要想写出的类型适用范围更广,不妨让它像函数一样可以接受参数。TS的泛型便是起到这样的作用,你可以把它当作类型的参数。它和函数参数一样,可以有默认值。除此之外,还可以用extends对参数本身需要满足的条件进行限制。

在定义一个函数、type、interface、class时,在名称后面加上<>表示即接受类型参数。而在实际调用时,不一定需要手动传入类型参数,TS往往能自行推断出来。在TS推断不准时,再手动传入参数来纠正。

1// 定义
2class React.Component<P = {}, S = {}, SS = any> { ... }
3interface IShowConfig<P extends IShowProps> { ... }
4// 调用
5class Modal extends React.Component<IModalProps, IModalState> { ... }
6

条件类型

除了与、或等基本逻辑,TS的类型也支持条件运算,其语法与三目运算符相同,为 T extends U ? X : Y。这里先举一个简单的例子。在后文中我们会看到很多复杂类型的实现都需要借助条件类型。

1type IsEqualType<A, B> = A extends B ? (B extends A ? true : false) : false;
2type NumberEqualsToString = IsEqualType<number, string>;   // false
3type NumberEqualsToNumber = IsEqualType<number, number>;    // true
4

环境 Ambient Modules

在实际应用开发时有一种场景,当前作用域下可以访问某个变量,但这个变量并不由开发者控制。例如通过Script标签直接引入的第三方库CDN、一些宿主环境的API等。这个时候可以利用TS的环境声明功能,来告诉TS当前作用域可以访问这些变量,以获得类型提醒。

具体有两种方式,declare和三斜线指令。

1declare const IS_MOBILE = true;    // 编译后此行消失
2const wording = IS_MOBILE ? '移动端' : 'PC端';
3

用三斜线指令可以一次性引入整个类型声明文件。

1/// <reference path="../typings/monaco.d.ts" />
2const range = new monaco.Range(2, 3, 6, 7);
3

深入类型系统

基本类型

基本类型,也可以理解为原子类型。包括number、boolean、string、null、undefined、function、array、字面量(true,false,1,2,‘a’)等。它们无法再细分。

复合类型

TypeScript的复合类型可以分为两类: setmap。set是指一个无序的、无重复元素的集合。而map则和JS中的对象一样,是一些没有重复键的键值对。

1// set
2type Size = 'small' | 'default' | 'big' | 'large';
3// map
4interface IA {
5    a: string
6    b: number
7}
8

复合类型间的转换

 1// map => set
 2type IAKeys = keyof IA;    // 'a' | 'b'
 3type IAValues = IA[keyof IA];    // string | number
 4
 5// set => map
 6type SizeMap = {
 7    [k in Size]: number
 8}
 9// 等价于
10type SizeMap2 = {
11    small: number
12    default: number
13    big: number
14    large: number
15}
16

map上的操作

 1// 索引取值
 2type SubA = IA['a'];    // string
 3
 4// 属性修饰符
 5type Person = {
 6    age: number
 7    readonly name: string    // 只读属性,初始化时必须赋值
 8    nickname?: string    // 可选属性,相当于 | undefined
 9}
10

映射类型和同态变换

在TypeScript中,有以下几种常见的映射类型。它们的共同点是只接受一个传入类型,生成的类型中key都来自于keyof传入的类型,value都是传入类型的value的变种。

1type Partial<T> = { [P in keyof T]?: T[P] }    // 将一个map所有属性变为可选的
2type Required<T> = { [P in keyof T]-?: T[P] }    // 将一个map所有属性变为必选的
3type Readonly<T> = { readonly [P in keyof T]: T[P] }    // 将一个map所有属性变为只读的
4type Mutable<T> = { -readonly [P in keyof T]: T[P] }    // ts标准库未包含,将一个map所有属性变为可写的
5

此类变换,在TS中被称为同态变换。在进行同态变换时,TS会先复制一遍传入参数的属性修饰符,再应用定义的变换。

1interface Fruit {
2    readonly name: string
3    size: number
4}
5type PF = Partial<Fruit>;    // PF.name既只读又可选,PF.size只可选
6

其他常用工具类型

由set生成map

 1type Record<K extends keyof any, T> = { [P in K]: T };
 2
 3type Size = 'small' | 'default' | 'big';
 4/*
 5{
 6    small: number
 7    default: number
 8    big: number
 9}
10 */
11type SizeMap = Record<Size, number>;
12

保留map的一部分

1type Pick<T, K extends keyof T> = { [P in K]: T[P] };
2/*
3{
4    default: number
5    big: number
6}
7 */
8type BiggerSizeMap = Pick<SizeMap, 'default' | 'big'>;
9

删除map的一部分

1type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
2/*
3{
4    default: number
5}
6 */
7type DefaultSizeMap = Omit<BiggerSizeMap, 'big'>;
8

保留set的一部分

1type Extract<T, U> = T extends U ? T : never;
2
3type Result = 1 | 2 | 3 | 'error' | 'success';
4type StringResult = Extract<Result, string>;    // 'error' | 'success
5

删除set的一部分

1type Exclude<T, U> = T extends U ? never : T;
2type NumericResult = Exclude<Result, string>;    // 1 | 2 | 3
3

获取函数返回值的类型。但要注意不要滥用这个工具类型,应该尽量多手动标注函数返回值类型。理由开篇时提过, 契约高于实现。用ReturnType是由实现反推契约,而实现往往容易变且容易出错,契约则相对稳定。另一方面,ReturnType过多也会降低代码可读性。

 1type ReturnType<T> = T extends (...args: any[]) => infer R ?  R : any;
 2
 3function f() { return { a: 3, b: 2}; }
 4/*
 5{
 6    a: number
 7    b: number
 8}
 9 */
10type FReturn = ReturnType<f>;
11

以上这些工具类型都已经包含在了TS标准库中,在应用中直接输入名字进行使用即可。另外,在这些工具类型的实现中,出现了infer、never、typeof等关键字,在后文我会详细解释它们的作用。

类型的递归

TS原生的Readonly只会限制一层写入操作,我们可以利用递归来实现深层次的Readonly。但要注意,TS对最大递归层数做了限制,最多递归5层。

 1type DeepReadony<T> = {
 2    readonly [P in keyof T]: DeepReadony<T[P]>
 3}
 4
 5interface SomeObject {
 6  a: {
 7    b: {
 8      c: number;
 9    };
10  };
11}
12
13const obj: Readonly<SomeObject> = { a: { b: { c: 2 } } };
14obj.a.b.c = 3;    // TS不会报错
15
16const obj2: DeepReadony<SomeObject> = { a: { b: { c: 2 } } };
17obj2.a.b.c = 3;    // Cannot assign to 'c' because it is a read-only property.
18

never infer typeof 关键字

never| 运算的幺元,即 x | never = x。例如之前的Exclude<Result, string>运算过程如下:

infer 的作用是让TypeScript自己推断,并将推断的结果存储到一个临时名字中,并且只能用于extends语句中。它与泛型的区别在于,泛型是声明一个“参数”,而infer是声明一个“中间变量”。infer我用得比较少,这里借用一下官方的示例。

 1type Unpacked<T> =
 2    T extends (infer U)[] ? U :
 3    T extends (...args: any[]) => infer U ? U :
 4    T extends Promise<infer U> ? U :
 5    T;
 6
 7type T0 = Unpacked<string>;  // string
 8type T1 = Unpacked<string[]>;  // string
 9type T2 = Unpacked<() => string>;  // string
10type T3 = Unpacked<Promise<string>>;  // string
11type T4 = Unpacked<Promise<string>[]>;  // Promise<string>
12type T5 = Unpacked<Unpacked<Promise<string>[]>>;  // string
13

typeof 用于获取一个“常量”的类型,这里的“常量”是指任何可以在编译期确定的东西,例如const、function、class等。它是从 实际运行代码 通向 类型系统 的单行道。理论上,任何运行时的符号名想要为类型系统所用,都要加上 typeof。但是class 比较特殊不需要加,因为 ts 的 class 出现得比 js 早,现有的为兼容性解决方案。

在使用class时, class名 表示实例类型, typeof class 表示 class本身类型。没错,这个关键字和 js 的 typeof 关键字重名了 :)。

1const config = { width: 2, height: 2 };
2function getLength(str: string) { return str.length; }
3
4type TConfig = typeof config;    // { width: number, height: number }
5type TGetLength = typeof getLength;    // (str: string) => number
6

实战演练

我在项目中遇到这样一种场景,需要获取一个类型中所有value为指定类型的key。例如,已知某个React组件的props类型,我需要“知道”(编程意义上)哪些参数是function类型。

1interface SomeProps {
2    a: string
3    b: number
4    c: (e: MouseEvent) => void
5    d: (e: TouchEvent) => void
6}
7// 如何得到 'c' | 'd' ?
8

分析一下这里的思路,我们需要从一个map得到一个set,而这个set是map的key的 子集,筛选子集的 条件 是value的类型。要构造set的子集,需要用到 never;要实现条件判断,需要用到 extends;而要实现key到value的访问,则需要索引取值。经过一些尝试后,解决方案如下。

1type GetKeyByValueType<T, Condition> = {
2    [K in keyof T]: T[K] extends Condition ? K : never
3} [keyof T];
4
5type FunctionPropNames =  GetKeyByValueType<SomeProps, Function>;    // 'c' | 'd'
6

这里的运算过程如下:

 1// 开始
 2{
 3    a: string
 4    b: number
 5    c: (e: MouseEvent) => void
 6    d: (e: TouchEvent) => void
 7}
 8// 第一步,条件映射
 9{
10    a: never
11    b: never
12    c: 'c'
13    d: 'd'
14}
15// 第二步,索引取值
16never | never | 'c' | 'd'
17// never的性质
18'c' | 'd'
19

编译提示 Compiler Hints

TypeScript只发生在编译期,因此我们可以在代码中加入一些符号,来给予编译器一些提示,使其按我们要求的方式运行。

类型转换

类型转换的语法为 <类型名> xxxxxx as 类型名。推荐始终用as语法,因为第一种语法无法在tsx文件使用,而且容易和泛型混淆。一般只有这几种场景需要使用类型转换:自动推断不准;TS报错,想不出更好的类型编写方法,手动抄近路;临时“放飞自我”。

在使用类型转换时,应该遵守几个原则:

  • 若要放松限制,只可放松到能运行的最严格类型上
  • 如果不知道一个变量的精确类型,只标注到大概类型(例如any[])也比any好
  • 任何一段“放飞自我”(完全没有类型覆盖)区代码不应超过2行,应在出现第一个可以确定类型的变量时就补上标注

在编写TS程序时,我们的目标是让类型覆盖率无限接近 100%。

! 断言

! 的作用是断言某个变量不会是null / undefined,告诉编译器停止报错。这里由用户确保断言的正确。它和刚刚进入EcmaScript语法提案stage 3的Optional Chaining特性不同。Optional Chaining特性可以保证访问的安全性,即使在undefined上访问某个键也不会抛出异常。而 ! 只是消除编译器报错,不会对运行时行为造成任何影响。

1// TypeScript
2mightBeUndefined!.a = 2
3// 编译为
4mightBeUndefined.a = 2
5

// @ts-ignore

用于忽略下一行的报错,尽量少用。

其他

我为什么不提enum

enum在TS中出现的比较早,它引入了JavaScript没有的数据结构(编译成一个双向map),入侵了运行时,与TypeScript宗旨不符。用 string literal union('small' | 'big' | 'large')可以做到相同的事,且在debug时可读性更好。如果很在意条件比较的性能,应该用二进制flag加位运算。

 1// TypeScript
 2enum Size {
 3    small = 3,
 4    big,
 5    large
 6}
 7const a:Size = Size.large;    // 5
 8
 9// 编译为
10var Size;
11(function (Size) {
12    Size[Size["small"] = 3] = "small";
13    Size[Size["big"] = 4] = "big";
14    Size[Size["large"] = 5] = "large";
15})(Size || (Size = {}));
16const a = Size.large; // 5
17

写在最后

应该以什么心态来编写TypeScript

我们应该编写有类型系统的JavaScript,而不是能编译成JavaScript的Java/C#。任何一个TypeScript程序,在手动删去类型部分,将后缀改成 .js 后,都应能够正常运行。