TypeScript笔记
Written by SJTU-XHW
Reference: MDN Doc && TypeScript Doc
本人学识有限,笔记难免有错,恳请读者能够批评指正,本人将不胜感激!
Chapter 1. 与 JS 比较:类型声明 与 作用域
TypeScript 是个静态类型语言,变量类型/对象成员/函数签名 在定义后就不能更改!
定义变量时,请使用规范的类型声明定义方式:
1
2
3
4
5
6
7/* Variable */
var foo: number;
var bar: string = "Hello, TypeScript!";
/* Function */
function toString(num: number): string {
return String(num);
}天大的福音:只声明变量、不赋值就使用会报错!这下不用担心讨厌的
undefined
了;1
2let x: number;
console.log(x); // TypeError而且开启了编译选项
strictNullChecks
后,undefined
也是个独立的类型,不能赋给除了undefined
类型以外的其他类型!不允许给变量中途赋予不同类型的值;
此外,比 JavaScript 更加暖心的操作是:TypeScript 全面支持 块级作用域!定义在块级作用域内的变量、类型等等不再能被外界读到了!
Chapter 2. TypeScript 编译
TypeScript 不提供运行环境,全部交给 JavaScript,自己只提供转换为 JavaScript 的编译器 tsc
,甚至这个编译器也是 JavaScript 的一个库,可以用 npm
安装。
编译时,会将类型声明和类型相关的代码全部删除,只留下能运行的 JavaScript 代码,并且不会改变 JavaScript 的运行结果。
因此,TypeScript 的类型检查只是编译时的类型检查,而不是运行时的类型检查。一旦代码编译为 JavaScript,运行时就不再检查类型了。
TypeScript 的编译器 tsc
的简单使用如下:
安装:
npm install -g typescript
;编译单个 TS 文件,或多个没有层次依赖关系的 TS 文件:
tsc a.ts[, b.ts, ...]
;分别生成
a.js, b.js, ...
;--outFile
编译为一个指定的 JavaScript:tsc a.ts[, b.ts, ...] --outFile XXX.js
;--outDir
指定输出目录;--target
指定编译 JavaScript 标准(建议es2015
及以上);
如果项目更复杂一点,就需要 tsconfig.json
管理编译过程(考虑 Java 的 Gradle 和 C++ 的 CMakeLists)。其简单结构如下:
1 | { |
这时候直接运行 tsc
等价于原来的 tsc file1.ts file2.ts --outFile dist/app.js
;
更复杂的使用以后介绍。
Chapter 3. TypeScript 类型系统
3.1 新的辅助类型
和 JavaScript 不一样的是,TypeScript 提供了 3 种特殊类型:any
、unknown
、never
,它们是为了配合静态类型系统更好的发挥功能而创造出来的。
3.1.1 Any 类型
当给变量声明 any
类型时,TypeScript 会关闭这个变量的类型检查,这个变量单独变为 “动态类型”。
1 | let x: any = "hello"; |
如你所见,肯定要避免使用 any
类型,不然你为什么不用 JavaScript?
频繁使用 any
会导致 TypeScript 丧失静态类型的优势,具体来说有几个坏处:
干扰类型推断和编译前错误检查:
1
2
3
4
5function add(x: any, y: any) {
return x + y;
}
add(1, [1, 2, 3]); // OK静态类型污染:
1
2
3
4
5
6
7let x: any = "hello";
let y: number;
y = x; // 不报错
y * 123; // 不报错
y.toFixed(); // 不报错
即使你不使用 any
类型,也要注意编译器可能自动推断 any
类型,这通常是因为开发者不良开发习惯所导致的 —— 声明、定义变量 / 定义函数 时,不进行类型标注:
1 | var x; |
以上变量、函数声明全部会被编译器推断为 any
,从而干扰类型检查!
总之,对于 TypeScript 的 any
类型,请敬而远之!自己不写 any
,也别让编译器推断出 any
。这是编译器实在没法进行类型检查时候的下下策。
还是那句话,不然你为什么不直接用 JavaScript?
3.1.2 Unknown 类型
为了防止 any
的类型污染等问题,人们定义了一种比 any
类型严格的辅助类型 unknown
,规则如下:
- 允许给
unknown
类型变量赋予任何类型的值; - 不允许将
unknown
类型变量赋予其他确定类型(即除了any
和unknown
)的值; - 不允许使用
unknown
类型的方法、属性; - 只能对
unknown
类型进行有限的运算:逻辑运算、判断相等运算、typeof
、instanceof
,其他运算均不可以;
违反以上规则,编译器会抛出错误。
但是 unknown
允许类型缩窄(比如一开始没法确定这个数据的类型,但是后面要处理时确定了,这种情况就不需要使用 any
了),如下:
1 | let s: unknown = "hello"; |
在作用域中,s
类型被缩窄为 string
,变成了确定类型,就可以使用确定类型的一切方法和属性了。
总之,某些逻辑下,实在无法确定类型,应该优先使用 unknown
类型,避免 any
出现。
3.1.3 Never 类型
从集合论的角度,人们定义了这个类型,含义是 空类型,可以赋给任何类型的变量。
你可能会好奇,那被赋予 never
类型的变量内部的值怎么办?
问出这个问题说明你还没有明白 never
的使用场景:在函数中,它标识控制流永远无法到达函数返回的时候;在变量中,它标识永远都不会用到该变量。
例如:
1 | function f(): never { |
就是错误的,因为函数能够执行到最后,返回的是 undefined
类型的对象,而不是空。
如果你只是想标识函数不返回值,请使用
undefined
/void
作为返回类型:
1
2
3 function f(): void {
console.log("Hello, TypeScript!"); // Correct
}
这样才是正确使用方式:
1 | function f(): never { |
3.2 新的基本类型 和 引用类型
复习一下 JavaScript 中有几种基本类型和引用类型:
boolean
、string
、number
、bigint
、symbol
、object
(狭义对象类型也是个基本类型,和广义对象类型 Object
不同,仅包含所有引用类型,如 Array 等内置引用类型)、undefined
、null
;
补充 JavaScript 不常用的类型使用方法:
bigint
:在 JavaScript 中,使用bigint
需要数字尾缀n
,例如123n
;
3.2.1 TypeScript 的对象
这里得说明一下,对象(引用类型)的定义和 JavaScript 显著不同。
因为 TypeScript 作为一种静态类型语言,不允许定以后更改数据成员。因此你这么写,编译器会报错:
1 | var o1: Object = { foo: 0 }; |
因为你定义的 o1/o2
类型是原生的 object/Object
,都只有原生的属性和方法,foo
属性在赋值时就被抛弃了。
要想自定义对象的属性和方法,要么定义对象类型,要么定义 class
(TypeScript 的类,后面讲);
3.2.2 TypeScript 值类型
TypeScript 规定,单个值也是一种类型,称为“值类型”,不常用,它常常用在联合 / 交叉类型中。
1 | var name: "Hello"; // 只能被赋予 "Hello",其他内容都会报错。 |
3.2.3 TypeScript 常量类型
所有使用 const
关键字修饰的 TypeScript 的变量都是常量,它们不能被更改。
你可以把这个变量理解为 以初次赋予的值为值类型的变量。
3.2.4 TypeScript 对象类型
3.2.4.1 使用方法
而 “对象类型” 则可以很方便地(无需定义接口 interface、类型 class)定义对象并指定临时类型,如下:
1 | var obj: { |
3.2.4.2 对象类型的特性
一旦声明某个对象的对象类型后,赋值一定要分毫不差地按照类型来,否则编译器不会接受,即不允许以下行为:
- 增添对象类型中不存在的字段、删除对象类型中已有的字段;
- 定义+赋值时,少给一个成员赋值,或者给不存在的成员赋值;
但是它也允许一些特性以在合理的范围内支持灵活性:
允许数据域添加 可选修饰符
?
:1
2
3
4
5
6
7
8
9type User = {
firstName: string;
lastName?: string;
};
// 等同于
type User = {
firstName: string;
lastName: string | undefined; // 注意进行类型缩窄
};允许数据域添加 只读修饰符
readonly
:1
2
3
4
5
6
7
8type Point = {
readonly x: number;
readonly y: number;
readonly resident: { // 只读数据域如果是对象,则允许更改数据域,不允许更改对象引用
name: string;
id: number;
};
};只读数据域和普通数据域间传递,与 C 的
const
修饰类型和普通类型的方法一样,不再赘述。允许 数据域索引:
1
2
3type TestObjT = {
[property: string]: string;
};TestObjT
的含义是,不管这个对象有多少属性,只要属性名为字符串,且属性值也是字符串,就符合这个类型声明,比如可以这么定义:1
2
3
4
5var tobj: TestObjT = {
foo: "1",
bar: "2",
baz: "3"
};此外,对象类型的数据域索引中 property 类型允许
string
、number
、symbol
;不过这个特性不建议使用,因为对类型的约束过于宽泛。
3.2.4.3 解构赋值 与 对象类型
和 python 的解构语法类似,
1 | fig, axe = matplotlib.pyplot.subplots() |
TypeScript 的解构要求指定 对象类型,其实有两种语法:
1 | // 将 obj **按序**解构到指定的变量,必须声明成对象的对象类型(type of obj) |
第一种语法很好理解,比如:
1 | const { |
但我们发现 “自定义解构顺序” 的写法非常迷惑,因为大括号内的冒号不再指类型,而是指数据域的映射关系,例如很多新手会这么写:
1 | type Shape = { |
最后一句话的含义是什么?如果你认为这是第一种语法,按序把 obj
的 shape
和 xpos
属性赋给了外部的 shape
、xpos
变量,那么就大错特错了!
因为要牢记:解构语法中的冒号不再作为类型尾缀,而是作为数据域映射的含义,在这里,解构语法一旦出现冒号,就一定是第二条语法。
上面的最后一条语句的真正含义是,将 obj
的 shape
数据域赋给外围以 “Shape” 为变量名的变量,它的类型名也是 Shape
!(number 同理)
3.2.4.4 对象类型的结构类型原则(Structual Typing Principle)
只要对象 B 满足 对象 A 的结构特征,TypeScript 就认为对象 B 兼容对象 A 的类型,这称为“结构类型”原则(structual typing)。
例如:
1 | var A: { |
对象 B 满足 A 的结构特征,说明 B 的对象类型 兼容 A 的对象类型,因此 B 可以直接赋给 A,但反过来不行。
3.2.4.5 对象类型的最小可选属性规则
如果一个对象类型所有属性都由 可选修饰符 修饰,按语义理解的话,所有对象都会符合这个定义。
TypeScript 为了防止类型模糊,规定:
当一个对象类型所有属性都由 可选修饰符 修饰,那么要定义一个该类型的对象,至少要含有一个可选属性。
1 | type Options = { |
3.2.5 TypeScript 的联合类型 与 交叉类型
联合类型 var x: A | B
表示 x
既可以是 A
类型,又可以是 B
类型;
这其实是暂时无法判断类型的最正确的处理方法。
它仅允许使用二者类型共同的属性或方法,否则编译器会抛出错误。必须使用 缩窄类型 才可以针对性地使用属性和方法。
交叉类型 var y: A & B
表示 x
必须既是 A
类型,又是 B
类型,最常见用作对象属性的临时合成:
1 | let x: number & string; // 编译器认为无法取得,是 never 类型 |
3.2.6 TypeScript 类型别名
TypeScript 中的关键字 type
,现在完全可以理解为 C 的 typedef
,就是定义一个类型(值类型、对象类型、联合类型、交叉类型,等等)的别名,例如:
1 | type TA = { |
注意:
别名不允许重名!
别名的有效范围是当前的块级作用域(例如定义在大括号内、函数内,在外面就没有效用了);
别名支持使用表达式定义,例如:
1
2type World = "world";
type Greeting = `hello ${World}`;别名是类型相关代码,因此在编译为 JavaScript 后会被完全清除。
3.2.7 TypeScript typeof
运算符
JavaScript 的 typeof
返回的是对应类型的字符串;
实际上,在 TypeScript 中保留了 JavaScript 的 typeof
,又增添了一个新的 typeof
运算符,返回的是 TypeScript 类型(比如基本类型、值类型、对象类型等等),由于是类型,因此这种运算符不能用于值运算,只能放在类型推导中。
举个例子:
1 | let a = 1; |
第一个 typeof
是 TypeScript 新增的返回类型的 typeof
,只能用于类型推导,参数不能是表达式(前面的类型别名可以)。在编译后会被完全清除;
第二个 typeof
是 JavaScript 原生的 typeof
,返回的是字符串,可以用于值运算。
3.3 TypeScript 中的数组:基本类型和内置引用类型
3.3.1 定义和使用
这里和 JavaScript 动态类型语言不像,而是更接近 Java 的静态类型。
TypeScript 数组所有成员的类型必须相同,但是成员数量是不确定的,不论是基本类型数组 type[]
还是 内置类型 Array<Type>
。
1 | var arr: number[] = [1, 2, 3]; |
内置类型 Array 被改成了更像 Java 的泛型,方法和 JavaScript 原生都是一样的:
1 | var arr: Array<string> = new Array(1, 2, 3); |
和 JavaScript 一样,可以通过修改 length
属性直接增减成员。
由于长度不定,因此 TypeScript 的数组越界也不会报错,只会返回 undefined
;
Array
内置类型和 JavaScript 原生用法几乎一致,方法不再赘述。
3.3.2 只读数组
不仅是 JavaScript,大多数语言都认为数组及其他形式的对象都是 可变数据类型,因此把数组本身定义为 const
是不会阻止数组被修改的。我们以 C++ 为例:
1 | class Test { |
setter
函数使用 const
修饰,也没能阻止数组被修改。
为了限制可变数据类型的修改问题,C++ 采用的是常量指针的方法,声明不允许修改:
1 | /* 上面的 arr 声明改成: */ |
这样数组就不再可以被修改,上面的 setter
也会让编译器报错。
那么 TypeScript 是怎么做的?答案是加 readonly
关键字:
1 | const arr: readonly number[] = [0, 1]; |
这样无论是使用删除、修改、新增数组成员的方法都会报错。我们可以将 readonly number[]
和 number[]
视为两个类型。
为此,TypeScript 设计了泛型 Readonly<T>
和 ReadonlyArray<T>
,效果类似。
3.3.3 多维数组
和 Java 定义很像,例如:
1 | var multi: number[][] = [ |
Java 写法:
1
2
3
4
5
6 int[][] multi = {
[1, 2, 3],
[23, 24, 25]
};
// Or
int[][] multi = new int[2][3];
3.4 TypeScript 元组
和 Python 的思想一样,允许在一个组内放入不同类型的数据。
一般情况下,元组的长度是确定的、每个元素的类型必须明确指定,因此越界访问、不按定义的类型来赋值都会报错。
其类型定义的方法和数组不一样:
1 | const a: number[] = [1, 2, 3]; // 数组 |
有时候不能省略类型定义,特别是只有一个元素的时候,因为可能会被编译器误判为数组:
1 | var a = [1]; // 编译器认为是数组 |
不过 “元组长度是确定的”,而不是 “固定的”,是因为有 2 个例外:
可选类型修饰符
?
(只能位于元组类型列表尾部):1
let a: [number, number?] = [1];
扩展运算符(又称 REST 运算符),表示不限制数量的同类元素,可以不位于类型列表尾部,在一个类型声明中只能使用一次:
1
2
3type t1 = [string, number, ...boolean[]]; // 注意,这不是数组。数组请用 ...<T>[][]
type t2 = [string, ...boolean[], number];
type t3 = [...boolean[], string, number];
如果没有使用扩展运算符,那么元组的长度就能确定,可以使用 length
进行长度推断:
如果使用了扩展运算符,length
推断就会被看作数组。
3.5 TypeScript 函数
3.5.1 定义与使用
定义方法和 JavaScript 一样,但是类型声明是有讲究的。
普通定义方法这么加类型声明:
1 | function hello(txt: string): void { /* ... */ } |
赋值函数表达式这么加:
1 | var hello: (txt: string) => void = function(txt) { /* ... */ }; |
技巧:想要套用其他函数的签名,就用
typeof
运算符,如下
1
2
3
4
5
6
7 function add(x: number, y: number) {
return x + y;
}
const myAdd: typeof add = function (x, y) {
return x + y;
};
箭头函数这么加:
1 | var hello = (txt: string): void => { /* ... */ }; |
由于函数也是一个对象,所以我们可以这么自定义函数的类型(在想要给函数加属性的时候有用):
1 | function f(x: number, y: number): number { |
3.5.2 可选参数 与 默认值
TypeScript 在静态类型的同时允许参数可选、默认值(它们肯定不能同时使用):
1 | let myFunc: (a: number, b?: number) => number; |
注意,传入 undefined
参数就能触发默认值(如果有的话)。
3.5.3 参数解构 (解包)
JavaScript 原生支持参数解构。在 TypeScript 中则需要声明类型才能使用。
要解构的参数大多数情况下,要么是对象,要么是数组 / 元组,例如:
1 | function f([x, y]: [number, number]) { |
使用方法请类比 “对象类型” 一节的解构赋值,也要注意两类语法的区别。
3.5.4 剩余参数 (args)
使用之前在元组一节提到的 REST 运算符即可:
1 | // rest 参数被包装成数组 |
如果 rest 参数被包装成元组,其中的元素也可以是可选参数。
3.5.5 只读参数
和 C++ 的 const
修饰参数一模一样。不多赘述:
1 | function arraySum(arr: readonly number[]) { |
3.5.6 函数重载
TypeScript 因为有类型,所以支持了 JavaScript 所不支持的 重载。
使用方法很简单,要注意的点和其他静态类型语言一样(避免签名模糊等问题)。
不过 TypeScript 中,如果能用联合类型避免重载,就用联合类型。
3.6 TypeScript 的类
TypeScript 作为真正的静态类型语言,和 JavaScript 相比,最大的差异是引入了真正的开发者可定义的类。
3.6.1 定义
下面的示例概括了 TypeScript 类基本的使用方法:
1 | class TestClass { |
3.6.2 类的可见性修饰符
TypeScript 的类更像 C++ 的结构体,不加可见性修饰符,默认 public
。
使用方法同其他面向对象的静态类型语言,略。
3.6.3 类的访问器
如果你学过 Python,那么恭喜,TypeScript 的访问器 set
、get
可以完全按照 Python 的装饰器 @setter
、@getter
理解。
定义方法如下:
1 | class T { |
根据它们的用途,可以很显然地知道:
get
访问器不能有参数;- 如果一个属性不存在
set
访问器,那么这个参数只读,等价于被readonly
修饰;
3.6.4 类的静态成员
和 Java、C++ 一样,使用 static
关键字修饰静态变量。
但是!和其他语言不太一样,TypeScript 的静态成员(属性和方法)不能由实例调用,只能由类名调用:
1 | class MyClass { |
3.6.5 类的继承
和 Java 一样,TypeScript 使用 extend
关键字进行类的继承。
还是和 Java 一样,TypeScript 也有 super
关键字,可以 用来调用父类构造函数、调用父类中已被重写的方法等等。
类继承时,不同可见性修饰符下对应数据域的继承原则与 Java、C++ 类似,但是有差别:
父类 protected 的数据成员,子类 可以转为 public,但不能转为 private;
子类可以定义与父类同名不同类型的属性,但是如果希望它们的赋值关联,则需要
declare
关键字修饰;
3.6.6 抽象类
和 C++ / Java 一样,TypeScript 中的抽象类关键字也是 abstract
、也不允许实例化、不允许 private
修饰、抽象成员不允许有实现。
同时,TypeScript 允许使用 abstract
修饰抽象类的数据成员(Java 和 C++ 做不到)、成员函数,强制要求子类(非抽象类)实现。
值得注意的是,一个类最多只能继承于一个抽象类(再多就应该用接口 interface 了)。
3.6.7 FAQ: 类中的 this
在 TypeScript 里究竟是什么
我们知道,在 JavaScript 中,this
含义丰富:
在方法中,this 表示该方法所属的对象;
如果单独使用,this 表示全局对象;
在函数中,this 表示全局对象(严格模式下为
undefined
);
那么在 TypeScript 的类中,含义就和其他面向对象语言类似,是指 “该方法所在类的当前对象”。
实际上,这里还有几个问题没有解决:
- 在 JavaScript 中允许定义一个原型,充当面向对象编程的工具。它允许将一个对象的方法赋给另一个变量,赋予后,
this
将跟随新的变量脱离原来的作用域。在 TypeScript 中,这种行为是怎样的? - 如果在 JavaScript 的原型中使用闭包/箭头函数,那么在其中的
this
将不再指代当前对象,除非手动传递this
的值。在 TypeScript 中的行为是怎样的?
针对第一个问题,TypeScript 为了防止改变函数语义(本来的 this
是类中对象,如果将函数赋给其他变量后 this
就成为了新变量作用域中的 this
),允许类中方法第一参数声明 this
类型(就像 Python 的 self
):
1 | class Test { |
无论在哪里,只要 typescript 检测到第一个参数是 this,就会自动将当前环境 this 填充进去,并且不会在 JavaScript 中展现出来。
这样,如果赋给其他变量后,一旦涉及改变了 this
的函数调用,编译器会指出错误:
1 | var b = Test.test; |
该参数可以省略(但省略后会回到 JavaScript 的行为)。
当然,你可以在构造函数中,使用 JavaScript 原生方法 bind
将方法与类的 this
环境绑定,这样无论怎么赋值,都是那个对象对应的方法了 —— 这也是最稳妥的做法。
对第二个问题,TypeScript 也需要担心语义问题,因此如果你打开了 noImplicitThis
编译选项,那么 TypeScript 编译器会直接报错。否则其行为和 JavaScript 一致。
最后补充一个 TypeScript 的小特性:
TypeScript 中的类能作为类型使用,例如:
1 | class Box { |
3.7 TypeScript 的接口
3.7.1 定义和使用
TypeScript 的接口和 Java 非常类似:接口和抽象类一样抽象,方法全是抽象方法(不能有实现)、可省略所有的 abstract
关键字。但是属性不是常量属性(只读)而是抽象属性(不允许赋值),因为在 TypeScript 中 abstract
可以修饰数据成员。
如下由于接口算是个抽象类,因此接口间可以继承,和 Java 的接口继承一样,要注意冲突问题。
此外,接口还可以继承于 type
(类型别名,它和接口的区别是什么?接下来再讨论)。
甚至接口还能继承于普通的 class
。此时 class
中所有的属性、方法全部保持类型,变成对应的抽象属性、抽象方法。这里我们可以知道,被接口继承的类不能有 private
/protected
修饰的成员,因为这样会导致 “无法实现的抽象成员” 的出现。
3.7.2 接口合并
这只是个特性,并不希望你为了使用它而使用,只是在必须的时候才使用。因为这样会降低你的代码可读性。
情况 1:如果你重复定义了接口,那么这两次定义的内容会合并:
- 同名函数 -> 重载,同名属性 -> 联合类型,不同名的直接并列;
- 如果有不能联合/重载的部分,编译器会抛出错误;
情况 2:如果你对某几个不同名的接口使用了类型联合运算符,如下:
1
declare const s: Inter1 | Inter2;
这就相当于显示将不同名的接口合并为一个接口
s
,合并规则和同名接口合并相同;
3.7.3 接口 (Interface) 和类型别名 (type) 的异同
相同点:都能创建一个类型、定义方式极其类似。
不同点:
type
可以定义非对象类型,而interface
只能定义对象类型;interface
可以继承、合并,而type
不行,重复定义同名type
会报错;type
内部不能使用this
指代当前对象,interface
则可以;
总结:除去一些复杂的类型运算,其他情况优先使用 interface
;
3.8 TypeScript 的泛型
TypeScript 的泛型定义、使用方法与 Java 相同,可以用在类、函数、接口、类型别名、对象上,并且可以指定默认类型,如下:
1 | class C<NumType = number> { |
TypeScript 中,泛型的另一种常用方式是:作为类型参数的约束条件。
如下:
1 | function comp<T extends { length: number }>(a: T, b: T) { |
这样约束了 传入的类型必须是具有 length: number
字段的对象,减少了代码犯错的概率。
我们可以总结为:
1 | <TypeParam extends ConstraintType> |
表示类型参数必须是 ConstraintType
的子类型。
不过为了代码可读性,还是提出以下建议:
- 尽量少用泛型:降低了代码可读性;
- 泛型的类型参数越少越好;
3.9 TypeScript Enum 类型
3.9.1 定义与使用
和其他大多数静态语言一样,TypeScript 也有枚举类型。它的枚举类型更像 C++ 11 的 enum Class
,定义如下:
1 | enum Color { |
- 值从 0 递增,但值不重要;
- 是个类型也是值(变量不能和枚举类型同名),因此可以
Color.Red
这样调用,增强代码可读性; - 枚举值只读,可以是自身类型(
Color
)也可以是number
(因此枚举类型做参数时,传入一切number
都不报错);
通常建议枚举类型前加 const
修饰,这样可以帮助编译器优化代码,提升性能:const enum
;
3.9.2 特性
同名枚举类型会像同名接口的行为一样合并;
允许字符串做枚举类型的值,要设置,则全员都设置,例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14const enum MediaTypes {
JSON = "application/json",
XML = "application/xml",
}
const url = "localhost";
fetch(url, {
headers: {
Accept: MediaTypes.JSON,
},
}).then((response) => {
// ...
});字符串枚举可以由联合类型代表:
function move(where: "Up" | "Down" | "Left" | "Right");
在 C++ 的枚举类型中,有很多人抱怨不方便通过枚举值打印出枚举成员的字符串名。在 TypeScript 中就有方法:反向映射,我们调用
EnumClass[n]
就能得到索引为n
的枚举成员的字符串名。这与 TypeScript 编译器将
enum
翻译成 JavaScript 的方式有关:1
2
3
4enum Test {
Apple,
Banana
}会被编译为:
1
2
3
4
5
6var Test;
(function (Test) {
// 相当于两句赋值:Test["Apple"] = 0, Test[0] = "Apple";
Test[Test["Apple"] = 0] = "Apple";
Test[Test["Banana"] = 1] = "Banana";
})(Test || Test = {});
3.9.3 keyof
关键字与枚举类型
在 TypeScript 中,keyof
关键字有点类似 Python 的 __dict__
属性,针对对象 / 类,返回对应类型的所有属性、方法名,不过 TypeScript 的返回是 “属性/方法名字符串组成的联合类型”(就是个字符串枚举类型!),而 Python 返回的是对象数组。
还有一点值得注意,TypeScript 枚举类型想要使用 keyof
获取成员名字符串联合类型,必须加 typeof
:
1 | enum MyEnum { |
否则会被编译器当作 number
类型:
1 | // "toString" | "toFixed" | "toExponential" | |
3.10 TypeScript 的类型断言
类型断言的存在,允许开发者在代码中“断言”某个值的类型,告诉编译器此处的值是什么类型(而不是改变这个值的类型)。TypeScript 一旦发现存在类型断言,并且 原类型 兼容 断言的类型,就不再对该值进行类型推断,而是直接采用断言给出的类型。
再次强调:断言的前提是 原类型 兼容 断言的类型!
由于 TypeScript 新版本支持了 React 的 JSX 语法,因此如果你要使用 JSX,则不能用旧语法(<assertType>value
,这里的尖括号是真的尖括号),应该使用新语法:value as assertType
;
那么你可能会问,TypeScript 编译器的类型推断不是已经足够了吗?虽然它增强了语言灵活性,但让人来控制类型推断不会导致出错可能性增加?
确实!它不能乱用,否则会出大问题。
但是只要在适当的场合使用就能发挥它的优势。以下是常用场景:
3.10.1 代替明确的类型缩窄
早在 3.2.5 节中就提到,对于一些联合类型,我们必须使用类型缩窄,才能使用某个特定类型的方法,以此避免运行时的类型错误,如下:
1 | function doSomething(): string | null { |
现在,假设 我们根据代码逻辑,明确知道 test
肯定不会为 null
,但是编译器不知道,还以为可能是 null
。
这个时候我们可以明确告诉编译器,它不可能为 null
,于是这么用就无需使用类型缩窄了:
1 | var test: string | null = doSomething(); |
3.10.2 直接量的常量断言
基本类型直接量
我们知道,TypeScript 的常量是不能被改变的量,那么常量声明语法 as const
就可以将基本类型直接量变为对应的值类型,例如:
1 | var s = "TypeScript" as const; |
这样在编译时,TypeScript 编译器会将 s
看作 "TypeScript"
值类型。这样有什么用呢?对 直接量 的常量断言,是看作对应的值类型。例如:
1 | let s = "JavaScript"; |
这里 Lang
类型是等价于枚举类型,如果传入普通字符串,即便值和枚举量一致,但编译器没法保证类型正确性。
所以我们可以这么做:
1 | var s = "JavaScript" as const; |
这样编译器会将 s
看作 "JavaScript"
值类型,保证以后也不会更改,因此修复了这个问题。当然也与直接用 const
等价:
1 | const s = "JavaScript"; |
不过值得注意的是:常量断言不能用于 已被赋值(定义)的变量!因为 TypeScript 不允许中途更改变量的读写性质。
对象直接量
如果将 as const
类型断言修饰对象,那么和修饰基本类型直接量的行为就有些不同了。
也就是说,这个时候 const
修饰符和 as const
断言就有区别了!
const
修饰对象直接量时,只是不允许更改对象引用,但其中数据可更改(因为是可变数据类型);as const
断言对象直接量时,其中所有数据域 / 成员都改为只读属性。
例如:
数组 / 元组直接量 -> 只读元组(同时确定了长度、类型 和值);
1
2
3
4
5// a1 的类型推断为 number[]
const a1 = [1, 2, 3];
// a2 的类型推断为 readonly [1, 2, 3] (元组)
const a2 = [1, 2, 3] as const;对象直接量 -> 数据域全部只读;
1
2
3
4
5
6
7
8
9
10
11
12
13
14const v1 = {
x: 1,
y: 2,
}; // 类型是 { x: number; y: number; },普通对象常量
const v2 = {
x: 1 as const,
y: 2,
}; // 类型是 { x: 1; y: number; },数据域直接量常量断言 的 对象常量
const v3 = {
x: 1,
y: 2,
} as const; // 类型是 { readonly x: 1; readonly y: 2; },常量断言 的 对象常量
那么这样做有什么用?举个例子:
1 | function add(x: number, y: number) { |
如果我想将已知长度的数组解构传给函数,但 const
修饰,甚至是 readonly
修饰,也并不能说明这是个定长度为 2、定内容的数组,只能说明这个数组对象引用不会变,所以编译器认为不行。
这个时候使用 as const
断言就能提示编译器这个数组是只读元组,并且长度为 2,每个位置上的类型都是值类型,能作为参数传递而不出问题。
3.10.3 非空断言
和 “明确缩窄类型” 的作用很像,但非空断言只针对 “空类型的类型缩窄”。就是根据代码逻辑,明确知道某些对象的类型不可能为 null / undefined
,在节省不必要的判断的同时,让编译器不报错。
但请注意!一定要确定 “肯定非空” 的逻辑!否则会出现运行时问题。
如果不能确保,就请通过手动检查来缩窄类型!
使用非空断言的语法就是前面提到的:尾缀非空断言运算符 !
;
举个例子:
1 | const root = document.getElementById("root")!; |
表示 document
中肯定有 ID 为 root
的元素,不用考虑空的情况了。
还有种场景是在之前提到过的,如果类中有些属性要在类外、构造函数外初始化,需要给该属性加非空断言,不然编译器认为没有初始化实例属性(如果你的属性定义没有 null
类型的话)而报错:
1 | class Point { |
3.10.4 类型断言函数
一类函数,如果它的作用是:“保证参数符合某种类型,不符合就抛出错误,符合就什么都不做”,那么就将这类函数称为 “类型断言函数”。
由于断言函数要么不返回(void
),要么抛出错误(never
),因此为了断言函数的语义清晰性,TypeScript 3.7 引入了断言函数声明写法,以判断字符串类型为例:
1 | function isString(value: unknown): asserts value is string { |
这样如果断言函数返回结果(不符合断言函数标准的行为),也会报错。
如果是判断真值(不为 false
、undefined
、null
)的话,断言函数还能更简单地书写:
1 | function assert(condition: unknown, message: string): asserts condition { |
这样这个函数就和 Python 的 assert
关键字行为类似了。
补充:TypeScript 的 is
关键字
TypeScript 中,is
除了充当类型断言函数的尾缀关系,还能作为类型判断函数(用于判断参数类型,返回值一定为 boolean
类型)的尾缀关系,例如:
1 | function isFish(pet: Fish | Bird): pet is Fish { |
但 is
关键字也仅限于这两类判断函数的尾缀,以此更清晰地表示这类函数的逻辑。
Chapter 4. TypeScript 模块与命名空间
4.1 模块
4.1.1 定义
TypeScript 文件有 2 类:
- 一种是全局的脚本文件。类似
shell
脚本,其中的变量、函数等等内容可以直接被其他接下来调用的所有文件访问到; - 另一种是 TypeScript 模块。模块内部的变量、函数、类只在内部可见,对于模块外部是不可见的,这就相当于 C++ 这类语言的普通文件。
Q: 这两种文件如何区分?
A: 任何包含 import 或 export 语句的文件,就是一个模块(module)。相应地,如果文件不包含 export 语句,就是一个全局的脚本文件。
Q: 既然模块对外部具有封装性,那么它们间如何相互调用?
A: 答案很简单,模块暴露给外部的接口,必须用 export
命令声明;如果其他文件要使用模块的接口,必须用 import
命令来输入。
4.1.2 特性
export {};
语句不会进行任何操作,但是这个文件的内部变量对外不再可见;- TypeScript 允许导入导出
type
(类型别名),需要export type <typename>
; - TypeScript 也允许 默认导出、命名导出,用法和 JavaScript 类似;
- 但是导入类型别名时,需要加
type
关键字前缀; - 导入接口、类时,和 JavaScript 普通对象的导入方法相同;
- 但是导入类型别名时,需要加
4.1.3 CommonJS 模块支持
众所周知,NodeJS 有专门的模块格式,与 ECMAScript 原生脚本不兼容。但是 TypeScript 兼容了 CommonJS 模块的导入:
import <name> = require("<moduleName>");
export = <exportObj>
;
4.1.4 编译时模块定位 (Module Resolution)
几乎所有要编译的语言,其源文件的编译都要考虑一个问题:如何组织模块间相互位置关系、如何找模块 / 库的位置。
C++ 中,要么你把源文件全部放在固定目录中,然后用相对路径手动引用。缺点是一改文件位置就要重写,非常麻烦;另一种方法是 使用 CMake/Makefile 之类的项目管理工具指定编译操作的过程。
在 TypeScript 中,也有两种方法。
引用时手动指定模块路径
我们在引用模块时可能会这么写:
1 | import { TypeA } from "./a"; // 省略后缀名,则可查找 *.ts / *.d.ts / *.tsx |
前两种利用相对路径指定模块位置的方法是 相对模块表示法;
后两种不带有路径信息地指定模块地方法是 非相对模块表示法。
那么 TypeScript 如何查找以上手动指定的模块?由于 NodeJS 引入的 CommonJS 模块,因此有两种查找方式:一种称为 Classic 方法,另一种称为 Node 方法。可以使用编译参数 moduleResolution
,指定使用哪一种方法。
对于 Classic 方法(原生 JavaScript、TypeScript 默认的方法),步骤就非常简单:
对于相对模块表示法,按照当前模块的位置为基准路径计算相对路径;
例如
import { t } from "../t"
就从当前脚本,找上个目录里的t.ts
;对于非相对模块表示法,会以当前模块位置为起点,层层向上查找目录;
对于 Node 方法就有些复杂:
- 对于相对模块表示法,例如
let x = require("./b")
,会进行以下查找步骤:- 找当前目录的
b.ts
、b.tsx
、b.d.ts
; - 找当前目录的子目录是否有
package.json
,这个文件中有无types
字段,如果有则递归查找; - 找当前目录的 名为
b
的子目录是否包括index.ts/.tsx/.d.ts
;
- 找当前目录的
- 对于非相对模块表示法,例如
let x = require("b")
,会进行以下查找步骤:- 子目录
node_modules
是否包含b.ts/.tsx/.d.ts
; - 子目录
node_modules
中是否存在package.json
,是否有types
字段,如果有则递归查找; - 子目录
node_modules
中是否存在子目录@types
,如有,则查找其中的b.d.ts
; - 子目录
node_modules
中是否存在子目录b
,其中是否包括index.ts/.tsx/.d.ts
; - 进入上层目录,充分上述步骤,直至找到。
- 子目录
tsconfig.json
配置模块路径
TypeScript 允许开发者在tsconfig.json
文件里面,手动指定脚本模块的路径,这样做在一些大型的、依赖关系复杂的项目中就比较方便。
有如下配置字段可以设置编译器的查找过程:
compilerOptions.baseUrl
:指定脚本模块的基准目录(该项目的所有源文件的基准位置都被设置在此)。例如:1
2
3
4
5{
"compilerOptions": {
"baseUrl": "."
}
}表示将
tsconfig.json
所在的目录为基准目录;compilerOptions.paths
:指定非相对路径表示法的模块与实际路径的映射。例如:1
2
3
4
5
6
7
8{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"jquery": ["node_modules/jquery/dist/jquery"]
}
}
}上例的
jquery
属性的值是一个数组,可以指定多个路径。如果第一个脚本路径不存在,那么就加载第二个路径,以此类推。compilerOptions.rootDirs
:指定无论查找什么模块,必须要额外查找的其他目录。例如:1
2
3
4
5{
"compilerOptions": {
"rootDirs": ["src/zh", "src/de", "src/#{locale}"]
}
}
此外,如果你发现自己指定了一些路径,编译器就是找不到。那么你可以使用 --traceResolution
编译器选项,让编译器在命令行中打印搜索的路径,相当于一种调试。
4.2 命名空间
评价是和 C++ 的 namespace
很类似。使用也很像:
1 | namespace Utils { |
哪怕在一个文件中,也必须 export
才能被外部使用:
1 | namespace Utility { |
命名空间本身能被 export
、也允许合并(行为类似 interface);
Chapter 5. TypeScript 装饰器
TypeScript 曾有旧式语法,这里不再作介绍。本章只介绍 TypeScript 5.0 以后的新式装饰器。
如果你想使用旧式语法,请向编译器传递
--experimentalDecorators
参数。
5.1 概念
TypeScript 的装饰器的定义 和 Python 的装饰器的定义一致,和 Java 的装饰器类似。或者说,“装饰器” 的概念是跨语言的、抽象概念。主要内容是:
- 前缀是
@
,后面必须是一种特殊表达式。这个表达式要求:要么就是个函数名,要么表达式执行后返回一个函数; - 这个通过后面表达式所得到的函数称为装饰器函数。装饰器函数可以接受所修饰对象的一些相关值作为参数,并且要么不返回值(装饰过程),要么返回新对象用来替换原来的目标对象(装饰对象)。
装饰器的作用是,通过类似 “外部注入” 的方式,改变被装饰对象的行为。
比如:
1 |
|
这样使用类 A
的时候,其行为就会被装饰器 Injectable
所改变。
5.2 源码定义
我们从概念中可知,要了解 TypeScript 装饰器怎么用、机制是什么,最主要看装饰器函数。它在 TypeScript 中的定义如下:
1 | type Decorator = ( |
可以看到,Decorator
是一个函数类型,它接收两个参数:
value
:所装饰的对象,在使用@Decorator
时,相当于语法糖,自动将修饰对象传递到此参数;context
:上下文对象,看起来有很多内容,实际上是为了适应多种装饰器的类别而定义的(很多可选参数,只需要记住几种常用的装饰器类别就行);在 TypeScript 中,定义了一个原生接口
ClassXXXDecoratorContext
,描述的就是typeof Decorator.context
;kind
:装饰器类别,只有 6 种,分别对应了 6 种装饰器:"class"
(类装饰器)、"method"
(方法装饰器)、"getter"
(读装饰器)、"setter"
(写装饰器)、"field"
(属性装饰器)、"accessor"
(访问装饰器);name
:所装饰对象的名称(例如类名、函数名、属性名等等);addInitializer()
:添加被修饰对象初始化后的逻辑;private
:所装饰对象是不是一个类的私有成员;static
:所装饰对象是不是一个类的静态成员;access
:包含所修饰对象的访问器get
和set
;
5.2.1 类装饰器
类装饰器是 context.kind
字段为 "class"
的装饰器,用来装饰 TypeScript 类。
它只需定义装饰器 context
可选成员的 addInitializer()
,用在类上就是 类完全定义后、构造函数前执行的 initializer
函数。
如下:
1 | type ClassDecorator = ( |
常见的使用场景有:
向类中添加一个外部方法;
1
2
3
4
5
6
7
8
9
10
11
12
13function Greeter(value, context) {
if (context.kind === "class") {
value.prototype.greet = function() {
console.log("Hello!");
};
}
}
class User {}
var u = new User();
u.greet(); // Hello!作用原理是,向传入其中的类调用
prototype
获取类的原型,添加方法greet
;替换被修饰类的构造函数(例如添加一些 side effects)/ 直接替换被修饰的类;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20function countInstances(value: any, context: any) {
let instanceCount = 0;
const wrapper = function (...args: any[]) {
instanceCount++;
const instance = new value(...args);
instance.count = instanceCount;
return instance;
} as unknown as typeof MyClass;
wrapper.prototype = value.prototype; // A
return wrapper;
}
class MyClass {}
const inst1 = new MyClass();
inst1 instanceof MyClass; // true
inst1.count; // 1解释一下,因为 JavaScript 和 TypeScript 中的类型
class
,只要是个构造函数就能用class
声明。借助这个特性,我们能更换被修饰类的构造函数。在装饰器内定义一个闭包(新的构造函数,
new
调用了原来构造函数),最后将函数的prototype
属性统一,再返回就是新的构造函数了。这个时候,返回的构造函数就替换了原来的构造函数(也就是替换了类)。按这种思想,如果你不想用
prototype
这种原生 JavaScript 的内容,你还可以直接返回一个临时子类,来实现同样效果:1
2
3
4
5
6
7
8
9
10
11function countInstances(value: any, context: any) {
let instanceCount = 0;
return class extends value {
constructor(...args: any[]) {
super(...args);
instanceCount++;
this.count = instanceCount;
}
};
}更改被修饰类的创建行为(修改
new
的行为);我们注意到,JavaScript 的构造函数中,有
new
对象可供使用,new.target
表示使用new
调用这个构造函数的目标对象。我们可以借此控制new
的行为,例如禁止new
新建实例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21function functionCallable(
value as any, {kind} as any
) {
if (kind === 'class') {
return function (...args) {
if (new.target !== undefined) {
throw new TypeError('This function can’t be new-invoked');
}
return new value(...args);
}
}
}
class Person {
constructor(name) {
this.name = name;
}
}
const robin = Person('Robin');
robin.name // 'Robin'在类的定义后、类实例初始化前追加行为;
这里就需要使用到装饰器
addInitializer
,这个函数将在 类的定义结束后(不是实例的定义结束后!) 执行,例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16function addInitializeWrapper(name: string): Function {
function wrapper(value: any, context: ClassDecoratorContext) {
context.addInitializer(() => {
console.log(name);
});
}
return wrapper;
}
"Hello") (
class Test {
};
var test = new Test;
var t2 = new Test;会在
Test
定义结束后打印一次Hello
;
5.2.2 方法装饰器
方法装饰器是 context.kind
字段为 "method"
的装饰器,用来装饰 TypeScript 类中的方法。
需要指定 static
(该方法是否在类中为静态方法)、private
(该方法是否在类中为私有方法)、access.get
(该方法的读访问器)、addInitializer
(该方法定义后的初始化逻辑),如下:
1 | type ClassMethodDecorator = ( |
常见使用场景有:
直接替换被修饰的方法;
这种使用方法很简单,就是装饰器函数返回一个新的函数/方法就行,这就算替换了原来的方法,例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18function replaceWithHello() {
return function() {
console.log("Hello!");
}
}
class Test {
name: string;
constructor(n: string) {
this.name = n;
}
echoName(): void {
console.log(this.name);
}
}给被修饰的方法添加额外逻辑(例如打印日志、延时执行、计时、绑定
this
环境等等);这里就举这 3 个例子。
1
2
3
4
5
6
7
8
9
10
11
12
13
14// 给被装饰方法添加日志
function addLog(originMethod: Function, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
function wrapper(this: any, ...args: any[]) {
console.log(`[DEBUG] Entering method: ${methodName}`);
// 在原有作用域 this 下执行
const result = originMethod.call(this, ...args);
console.log(`[DEBUG] Exiting method: ${methodName}`);
return result;
}
return wrapper;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 给被装饰方法延迟执行
function delay(millisecond: number = 0) {
// 无论在哪里,只要 typescript 检测到第一个参数是 this,就会自动将当前环境 this 填充进去,不在 JavaScript 中展现出来。
function wrapper(this: any, value: Function, context: ClassMethodDecoratorContext) {
if (context.kind === 'method') {
return (...args: any[]) => {
setTimeout(() => {
// 和 call 一样,在指定作用域下执行
value.apply(this, args);
}, millisecond);
};
}
}
return wrapper;
}1
2
3
4
5
6
7
8
9
10// 给被装饰方法自动绑定 this 环境,可以解决 “实例方法被赋给其他变量后语义改变 / 报错” 的问题
function boundThis(value: Function, context: ClassMethodDecoratorContext) {
const methodName = context.name;
if (context.private) {
throw new Error(`不能绑定私有方法 ${methodName as string}`);
}
context.addInitializer(function (this: any) {
this[methodName] = this[methodName].bind(this);
});
}
5.2.3 属性装饰器
属性装饰器是 context.kind
字段为 "field"
的装饰器,用来装饰定义在类中的属性。
它需要指定 static
、private
、addInitializer
、access.get
和 access.set
(比方法装饰器多了写访问器);
另外它还有一个与其他装饰器不同点是,它要么不返回值,要么返回一个函数,该函数会自动执行,用来对所装饰属性进行初始化。该函数的参数是所装饰属性的初始值,该函数的返回值是该属性的最终值。
所以,这下你能明白属性装饰器的作用了吗?在初始化该属性时会触发一次属性装饰器。定义如下:
1 | type ClassFieldDecorator = ( |
我们还注意到,value
是 undefined
类型,这意味着 属性装饰器不会自动将属性传给内部(因为没有必要,考虑赋给该属性的值 initialValue
即可)。
常见的使用场景和类的访问器类似,不再赘述,只是介绍一个样例:
1 | // 初始化 |
5.2.4 getter 装饰器、setter 装饰器
它们是 context.kind
字段为 "getter"/"setter"
的装饰器,是专门用来装饰类访问器的装饰器。
描述如下:
1 | type ClassGetterDecorator = ( |
这两个装饰器要么不返回值,要么返回一个函数,取代原来的取值器或存值器。
为什么要对访问器进行装饰?比如懒加载的特性:
1 | class C { |
还有一个 accessor
装饰器不常用,在这里不再赘述,有兴趣请查看官方文档。