TypeScript 基础
简介
介绍
Typescript 是一个强类型的 JavaScript 超集,支持 ES6 语法,支持面向对象编程的概念,如类、接口、继承、泛型等。Typescript 并不直接在浏览器上运行,需要编译器编译成纯 Javascript 来运行。
使用 TypeScript 的优势
Typescript 对javascript
一个主要的拓充就是类型系统。
类型系统有利于提高代码的质量和可维护性,因为:
- 类型有利于代码的重构,它有利于编译器在编译时而不是运行时捕获错误。
- 类型是出色的文档形式之一,函数签名是一个定义,而函数体是具体的实现。
- 类型有利于更好的 IDE 提示
安装 TypeScript
Node.js
环境下安装:
1 | npm install -g typescript |
数据类型
变量的定义
变量定义的基本格式:
1 | var [变量名]: [类型] = 值; |
省略初值时,变量的值为 undefined
:
1 | var [变量名]: [类型]; |
省略类型时,变量的类型根据变量的初值来确定:
1 | var message = "tese"; //message is string |
编译器确定变量类型的具体规则,请参考类型推断。
省略初值和类型时,变量的类型为 any
,初值为 undefined
:
1 | var message; //message is any |
变量的类型一旦确定,则不可更改:
1 | var message = "tese"; |
数据类型
数据类型 | 关键字 | 示例 | 描述 |
---|---|---|---|
数字类型 | number | let x: number = 1; | 用来表示整数和浮点数 |
大整数类型 | bigint | let x: bigint = 1; | 用来表示大整数 |
字符串类型 | string | let x: string = "hello world"; | 使用单引号' 或双引号" 来表示字符串类型。反引号```来定义多行文本和模板字符串 |
布尔类型 | boolean | let x: boolean = false; | 表示 true 和 false 的类型 |
数组类型 | 无 | let x: number[] = [1,2,3]; let x: any[] = [1,false,3]; let x: Array<number> = [1,2,3]; | 表示一组同类型的数据 |
元组类型 | 无 | let x: [number,boolean] = [1,false]; | 表示已知元素数量和类型的一组数据 |
枚举类型 | enum | enum Sex {Male,Female};let x: Sex = Sex.Male; | 表示一组数据 |
对象 | 无 | let x: Object = {a: 1} | 表示一组键值对 |
void 类型 | void | function func() : void {} | 表示函数没有返回值 |
null | null | let num: number = null; | 表示变量不指向任何对象,是其他类型的子类型 |
undefined | undefined | let num: number = undefined; | 表示变量未初始化,是其他类型的子类型 |
未知类型 | unknown | let x: unknown; | 表示无法预知的类型,在包含 typeof 或 === 的语句中,编译器可以推断出类型。在推断出类型之前无法使用 |
never 类型 | never | let x: never; | 不可能出现的值,通常表示函数抛出了异常、无限循环,是其他类型的子类型 |
任意类型 | any | let x: any = 1; | 表示变量的类型是任意的,可以赋任何类型的值进去 |
字面量类型
字符串字面量类型可以约束一个字符串只能是一个值:
1 | type NetworkLoadingState = { |
也可以约束其只能是某些字符串中的一个:
1 | type EventNames = "click" | "scroll" | "mousemove"; |
也可以约束其为整数中的某一个:
1 | type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; |
枚举类型
我们可以使用 enum
关键字来定义一个枚举类型,其中 Success
等成为枚举成员:
1 | enum Status { |
当我们定义完枚举类型后,便可以定义一个枚举类型的变量。使用 枚举类型名.成员名
便可以将一个枚举成员赋值给枚举类型:
1 | let networkStatus: Status = Status.Success; |
实际上,每一个枚举类型都是有一个特定的整数值的,默认情况下,第一个成员的值是 0
,第二个成员是 1
,以此类推:
1 | console.log(Status.Success); // 0 |
我们也可以给他们指定一个值:
1 | enum Status { |
在这种情况下,第一个成员的值便是 1
,第二个成员是 2
,以此类推。
如果我们在中间赋值,那么这种规律则从赋值的点开始生效:
1 | enum Status { |
需要注意的是,如果枚举成员的值是个变量(或函数),那么它的后面就必须初始化,因为它是无法获得初始值的:
1 | enum Status { |
枚举成员的值也可以是字符串,但是字符串后面的枚举成员也需要赋值:
1 | enum Status { |
常量枚举
常量枚举则是在 enum
前面加上 const
:
1 | const enum Tristate { |
普通枚举类型编译后会生成一个 JavaScript 对象:
1 | var Tristate; |
而对于 let test = Tristate.False;
这样的语句,编译前后并不会发生什么变化。
常量枚举编译后不会生成枚举类型的代码。对于 let test = Tristate.False;
这样的语句,编译时直接将 Tristate.False
的值嵌入编译结果,let test = 0;
类型推断
最佳通用类型
当我们定义变量时,若省略类型,则变量的类型会根据变量的初值来确定:
1 | var message = "tese"; //message is string |
若它的值复杂一点,像这样:
1 | let v = [3, 6, 9, null]; |
为了推断出 v
的类型,编译器想办法兼容上面的所有类型,而上面有 number
和 null
,最终得到 (number | null) []
。
如果这几个类型具有子类型关系,那么最终得到的是父类型:
1 | class Base { |
如果去掉 Base
,尽管我们希望它仍能得到 Base
,但 Base
未出现在值中,不作为候选的类型:
1 | let x = [new User(), new Article()]; // (User | Article)[] |
上下文推断
当给一个变量赋值函数类型时,这个变量的类型是已知的,则函数默认采用变量的类型:
1 | let add: (x: number, y: number) => number = function (x, y) { |
类型别名
类型定义可以为某个类型定义另一个名字:
1 | type Name = string; |
也可以通过 type
关键字定义一个类型:
1 | type NetworkLoadingState = { |
类型断言
类型断言(Type Assertion)相当于其他语言的类型转换。与其他编程语言不同的是,类型断言只发生在编译期,而不是运行期。
类型断言的两种写法:
1 | 表达式 as string; |
类型断言是按照子类型规则来的,也就是说,类型断言只能在有子类型的关系的两个类型中相互断言:
1 | let myNumber: string = "2333"; |
对于联合类型,可以将其断言为其中一个类型:
1 | let myNumber: string | number; |
不要忘了,any
和任何一个类型都是兼容的,所以,我们可以通过 any
来进行间接的类型断言:
1 | let myNumber: string = "2333"; |
不建议使用这种断言方式。 尽管通过 any
可以绕过类型限制,进行任意两个类型的断言。但这很大概率会导致运行时错误。
函数
函数的类型
在 javascript
中,函数定义分为具名和匿名两种:
1 | //具名函数 |
那下面那个 add
是什么类型呐,怎么写出它的类型?
一个函数的类型由它的参数和返回值组成,像这样:
1 | let add: (x: number, y: number) => number = function ( |
当我们在变量的属性中指明类型后,可以省略后面函数定义中的类型:
1 | let add: (x: number, y: number) => number = function (x, y) { |
函数的参数
严格一致
typescript
中,函数的参数和 javascript
中函数的参数要求不一样。typescript
要求函数的参数调用时需要和定义时严格一致。
下面这个代码就会报错:
1 | function add(x, y) { |
可选参数
有时候,我们希望有些参数可以不传,这时候,我们可以使用 ?
来表示一个可选参数:
1 | function getDefaultNickName(id: number, name?: string): string { |
默认参数
默认情况下,如果没有传可选参数,那么它的值为 undefined
,我们还可以为它指定默认参数:
1 | function getDefaultNickName(id: number, name: string = ""): string { |
默认参数也是可选的。需要注意一点,默认参数必须在普通参数的后面。
在类型定义中,默认参数值往往被省略,留下的是可选参数。例如,上面的例子的类型是 (id: number, name?: string) => string
。
剩余参数
和 js
一样,我们也可以使用 ...
来定义剩余参数:
1 | function add(x: number, y: number, ...nums: number[]) { |
剩余参数必须定义在参数的末尾。
当一个函数有了剩余参数时,它可以传入无数个参数。
this
在 javascript
中,this
是一个很灵活的东西。这使得 typescrcript
难以检测 this
的具体类型。我们可以显式指定一个 this
参数,来让 typescript
知道 this
的类型:
this
需要是第一个参数,这个参数只是为了识别类型用的。对 bind
、apply
等是没有影响的。
1 | interface User { |
重载
有时候,一个函数传入的参数的类型是有很多种的:
1 | // 同类型的数据相加 |
我们可以使用联合类型来简化一下:
1 | function add(a: string | number, b: string | number): number | string { |
但是这多了很多种情况,需要手动对不需要的类型情况进行排除。
这个时候,函数重载便其作用了。我们可以给一个函数进行多次声明,然后进行定义:
1 | function add(a: string, b: string): string; |
ts
的重载和其他语言不一样, ts
的重载只能有一个函数定义,且函数定义不在重载解析列表里面。
也就是说,function add(a: string | number, b: string | number): number | string
是不算入重载解析依据的。
接口
介绍
接口(Interfaces) 用来定义对象的类型。
像 java
等传统的面向对象的语言中,接口是对对象行为的抽象,需要类去实现 (implement)。对于 typescript
,接口不仅可以抽象对象的行为,也可以抽象对象的数据。
使用 interface
关键字来定义一个接口:
1 | interface Student { |
接着就可以使用这个接口:
1 | let lisa: Student = { |
使用接口定义对象时,必须和接口定义的属性保持一致,多了少了都会发生错误:
1 | let lisa: Student = { |
可选属性
如果希望某些属性既可以添加有可以不添加,则可以在属性名后面加上一个 ?
,这称为可选属性(Optional Properties):
1 | interface Student { |
只读属性
如果希望这个属性无法修改,则可以定义为只读属性 (Readonly Properties):
1 | interface Student { |
对于数组,将其设置为 readonly
只会避免该属性被修改,而数组的值仍然是可变的:
1 | interface Student { |
如果希望数组的内容不可变,可以使用 ReadonlyArray<type>
来定义一个数组
属性检查
有时候希望一个接口可以定义任意多的属性,这时候可以定义一个 [propName: string]
属性用来表示任意的属性:
1 | interface Student { |
不过需要注意的是,这种特殊的属性会检查所有的属性:
1 | interface Student { |
使用接口定义数组
我们可以通过定义一个 [propName: number]
属性用来表示数组下标的值:
1 | interface StringArray { |
我们甚至可以同时定义支持整数属性和字符串属性的接口:
1 | interface StringArray { |
不过,需要注意一点,整数下标值的类型必须是字符串下标值的子类型:
这是因为 javascript
在访问整数下标时,会自动转换成相应的字符串下标。
1 | interface StringArray { |
使用接口定义函数
接口不仅可以定义对象的模板,也可以定义函数的模板。我们可以通过圆括号来定义一个函数:
1 | interface Sorter { |
编译器只会检查参数的个数、位置和类型以及返回值的类型是否正确,参数名称不要求完全一样。
使用接口定义的函数,可以省去参数和返回值类型,因为在接口中已定义过:
1 | let asc: Sorter = function (a, b) { |
索引类型
有时候我们想限制一个变量的类型只能是一个接口具有的一些索引,这时候我们可以使用 keyof
运算符,来获取一个接口的索引类型:
1 | interface Car { |
如果规定了字符串的属性检查,则它的索引类型自动变为 string | number
:
1 | interface Student { |
如果仅规定了数字的属性检查,则它的索引类型自动变为 number
:
1 | interface Arr { |
类和对象
属性和方法
介绍
javascript
是一个基于原型的对象系统,通常通过一个构造函数来创建一个对象,通过原型和原型链来实现继承。随着ES6
的出台, javascript
中面向对象的语法得到了拓充,我们可以使用其他面向对象语言中的一些语法来编写 javascript
中的类。
我们可以使用 class
语法来定义一个简单的类:
1 | class Student { |
其中,constructor
称为 构造函数,当构造对象时,便会调用这个方法:
1 | let tom: Student = new Student(1, "Tom"); |
访问限制
typescript
提供了三个限制符用于限制成员的访问:
关键字 | 谁可以访问 |
---|---|
public | 任何人 |
private | 类自身 |
protected | 类自身和派生类 |
1 | class Student { |
不过,其他语言不同的是,如果不给属性和方法指定任何限制符,则它们默认是 public
:
javascript
的类语法中,成员默认都是公有的,typescript
也继承了这一点
1 | class Student { |
我们也可以给成员名前面加上 #
来将成员设置成私有:
1 | class Student { |
只读属性
属性和变量相似,可以为他们加上 readonly
,也可以为他们赋初值:
实际上,类中的 readonly
属性是可以在 constructor
中赋值的。
readonly
属性需要写在限制符的后面。
1 | class Student { |
参数属性
一个类中的很多属性,需要在对象创建的时候赋初值。
这个时候,我们便可以给参数加上限制符或者readonly
,让一个构造函数的参数成为一个属性:
1 | class Student { |
当对象初始化的时候,id
和 name
便自动成了对象的属性。相比于之前的写法,这种写法就显得十分简洁。
静态成员
typescript
中的静态属性相当于 python
中的类属性。
有时候,我们并不需要在实例上添加属性和方法,而是在类上添加。这个时候,我们可以使用 static
关键字来定义这种属性和方法:
1 | class Point { |
这个时候,便可以直接通过类访问这个属性:
1 | console.log(Point.origin); |
但需要注意一点,实例是不可以访问这个属性的:
1 | console.log(new Point(2, 3).origin); //Property 'origin' is a static member of type 'Point'. |
访问器属性
typescript
中的对象,有两种属性,一种是 数据属性 ,另外一种便是 访问器属性 。
访问器属性 实际上是获取和设置值的函数,但看上去像普通属性。其中:
get
获取数据x.xx
set
修改数据x.xx = xxx
我们通过给函数加上 get
和 set
来定义一个访问器属性:
1 | class Student { |
这样,我们可以直接访问和修改 id
:
1 | let tom: Student = new Student(2333); |
get
和 set
函数都是可选的,当缺少相应的函数时,相应的功能不发挥作用。
例如,只定义 get
函数时,这个属性是只读的:
1 | class Student { |
继承
介绍
继承这种现象是非常普遍的。比如,我们都喜欢基于现成的软件,修改一下,让这种软件符合我们的口味。这样做,就避免了为开发符合口味的软件而重新开发所有东西,从而减少了人力。
在 typescript
中,一个类只能继承另一个类。
在 typescript
中,我们可以使用 extends
来轻松的实现类的继承:
1 | class Animal { |
super
对于派生类,如果拥有构造函数,则需要调用 super
函数来调用基类的构造函数。而且,在使用 this
之前必须调用 super
函数。如果没有构造函数,则使用基类的构造函数。
重写
我们可以在派生类中定义与基类相同的方法。此时,派生类的方法会覆盖基类的方法,无论参数和返回值是否相同:
1 | class Animal { |
多态
介绍
多态让不同的对象,同一个行为具有不同的表现。
就像家里的插座一样,给空调通电,就可以给屋子带来凉气;给电饭煲通电,就可以给饭加热。
但插座(排开功率)是不认哪个电器的,插座的生产商只需要这个电器实现了相同的插孔,就可以让电器运行起来,而不必考虑这是哪个电器。
我们把这个例子用typescript
写出来康康:
1 | class Equipment { |
上面的 equipment
是家用电器,具有 run
方法,相当于上面的把插头插上去。在 RiceCooker
和 AirConditioner
中,分别实现了插上电源时的不同操作。而且, equipment
是不考虑接受的具体是哪个电器。
我们稍微抽象一下,多态,就是让派生类重写基类的方法,然后创建一个对象赋值给基类对象,再通过基类的对象来调用这个方法,而实际上,调用的是派生类的方法,从而产生不同的结果。
那我们为什么称它为多态呢?多态的字面含义是多种状态,而基类变量不仅仅传入基类的对象,也可以传入派生类的对象。这样,我们便可说,基类变量可以有多种状态。
在 typescript
中,想要实现多态,则需要有三个条件:
- 继承类
- 重写相应的函数
- 将派生类赋值给基类变量
在其他面向对象的语言中,我们通常把 将派生类赋值给基类变量 称为 向上转型 。但是基类变量只能访问基类拥有的属性和方法。
不过,别忘了 typescript
是按照结构来判断继承关系的,所以 extends
实际上是可以删掉的,typescript
仍会认为RiceCooker
继承了 Equipment
:
1 | class Equipment { |
有向上转型自然也有向下转型,看下面的代码:
1 | let equipment: Equipment = new RiceCooker(); |
不过,向下转型得有个要求,就是接受对象的实际类型必须是变量的子类型(或者是相同的类型)。例如,上面 equipment
实际类型是 RiceCooker
,就自然可以赋值给 RiceCooker
的对象。
抽象类
有时候,有些东西并不是给用户使用的,而是希望用户在这个基础上添加一些东西,再使用。这就是抽象类。
在 typescript
中,抽象类用于定义一些"模板",它本身无需实现,而是交给派生类去实现。这些模板便是抽象方法。
在 typescript
中,定义一个抽象类是很简单的:
1 | abstract class Equipment { |
抽象类本身是不可以创建对象的。
这个时候,run
的实现就交给派生类了:
1 | class RiceCooker extends Equipment { |
需要注意一点,非抽象的派生类需要实现全部的抽象方法。否则会报错。
类和接口
类实现接口
类可以通过 implements
关键字来实现接口。
实现接口和继承不一样,实现接口需要在类中重新定义相应的数据,而且一个类可以实现多个接口:
1 | interface UserInfo { |
接口继承接口和类
接口和类一样,也可以通过 extends
实现继承,而且接口可以继承多个接口的:
1 | interface UserInfo { |
接口甚至可以继承多个类:
1 | class UserInfo { |
构造函数
当声明一个类时,实际上同时声明了很多东西。
当类名作为类型时,它表示实例的类型;
当类名作为值时,它表示构造函数:
1 | class Student { |
当 typeof
用于类型的语境中,它用于获取一个变量的类型。
而构造函数的类型和普通函数的类型有些区别,构造函数类型有一个 new
前缀,而且通常省略返回值类型:
1 | interface StudentConstructor { |
有时我们可能会好奇的将 Student
继承 StudentConstructor
,但这会报错:
1 | interface StudentConstructor { |
一个类是分为它的静态类型和实例类型的,静态类型实际上就是构造函数的类型,而实例类型则是类本身。
而接口本身应该要反映在实例对象的,所以在 implements
中,我们需要的是实例类型,而不是静态类型。
那如何验证构造函数的类型呢?前文提到,当类名作为值时,它表示构造函数。所以,我们可以使用类表达式:
1 | interface StudentConstructor { |
类和接口的声明合并
- 多个同名的接口定义会自动合并
1 | interface Person { |
- 同名的类和接口,定义自动合并
1 | interface Person { |
泛型
介绍
有时候我们定义一个函数、接口或者类的时候,可能并不需要非常具体的属性或参数,而是保证属性或参数是相同的,或者具有某种关系。
比如说,尽管我们可以直接使用 Array<any>
,但数组的元素就可以是任意类型了。有时候这并不是想要的,有时希望一个数组的元素只能是一个类型,但对每个类型都定义一种数组,又显得不太灵活。因为每增加一种类型,就要重新写很多代码。那有没有办法,只定义一种数组,但不指定数组元素的类型,等到需要的时候再来指定? 泛型就是这个问题的答案。
泛型便是在定义函数、接口和类的时候,定义一种特殊类型,这种类型是事先不确定的,直到使用的时候才能确定。
函数与泛型
我们可以给函数名后面增加一对尖括号和类型参数,来表示泛型:
1 | function identity<T>(arg: T): T { |
我们还可以定义多个类型参数:
1 | function identity<T, U>(arg1: T, arg2: U): [T, U] { |
我们可以像原来的方式调用函数,这个时候编译器会推断参数的类型:
1 | console.log(identity("myString")); // 自动指定,判断出的类型是 "myString" |
我们也可以手动指定类型参数:
1 | console.log(identity<string>("myString")); // 手动指定 |
类型参数
当我们使用类型参数时,这个参数在使用前是不确定的,所以我们不能直接使用它的属性和方法。
1 | function identity<T>(arg: T): number { |
但我们可以为泛型添加一个约束,让他必须有 length
属性:
1 | interface HasLength { |
有时候编译器无法推断出泛型参数的值,而且用户也未指定时,我们可以定义一个默认的类型参数:
1 | interface HasLength { |
类、接口和泛型
泛型不仅仅适用于函数,也适用于接口和类。
类、接口与函数不同,在创建对象时需要手动指定泛型参数的值。
使用类:
1 | class MyArray<T> { |
使用接口:
1 | interface MyArray<T> { |
装饰器
介绍
装饰器是一项试验性特性,未来版本可能会发生改变
要启用装饰器,需要开启 experimentalDecorators
编译选项
命令行:
1 | tsc --target ES5 --experimentalDecorators |
tsconfig.json:
1 | { |
装饰器是一种附加到类、方法、访问器、属性或者参数的一种声明。
装饰器通常使用 @expression
的形式,且 expression
需要求值为函数,在运行的时候被调用。
多个装饰器即可以写在一行上,也可以写在多行上:
1 | @f |
在运行的时候,这就相当于复合函数求值: f(g(x))
类装饰器
类装饰器在类声明之前声明,它应用于构造函数,可以修改类的定义。
类装饰器的参数是构造函数。如果有返回值,则返回值会被用于构造函数。
例如,我们可以使用类装饰器来修改构造函数,使得每创建一个实例,id
自动加一:
1 | function autoIncrease<T extends { new (...args: any[]): {} }>(constructor: T) { |
方法装饰器
方法装饰器在方法声明之前声明,它应用于方法的属性描述符上,可以修改方法的定义。
方法装饰器具有三个参数:
- 构造函数(静态方法)、原型对象(实例方法)
- 成员的名字
- 成员的属性描述符
如果方法装饰器有返回值,则返回值作为方法的属性描述符。
javascript
对象的属性描述符一共有 6 种:
value
:属性的值get
:获取器set
:修改器writable
:如果为 true,则值可以被修改,否则它是只可读的。enumerable
: 如果为 true,则会被在 for-in 循环和 Object.keys 中列出,否则不会被列出。configurable
: 如果为 true,则其他属性描述符不可修改且不可删除
例如,我们可以使用修饰器将一个函数变为 getter
:
1 | function getter(target: any, prop: string, descriptor: PropertyDescriptor) { |
访问器修饰器
访问器修饰器在访问器声明之前声明,它应用于访问器的属性描述符上,可以修改访问器的定义。
需要注意一点,访问器修饰器不可以同时装饰一个成员的 get
和 set
,而应该装饰在定义的第一个访问器上。
访问器修饰器的参数和返回值同 方法装饰器。
属性装饰器
属性装饰器 声明在一个属性声明之前(紧靠着属性声明)。
属性装饰器具有两个个参数:
- 构造函数(静态方法)、原型对象(实例方法)
- 成员的名字
属性修饰器的返回值会被忽略。
属性在类中是无法通过属性描述符描述的,因为只有创建对象时,这些属性才会存在。所以目前无法在定义类时描述一个实例属性,也没办法监视和修改一个属性的初始化方法。
参数修饰器
参数装饰器 声明在一个参数声明之前(紧靠着参数声明),用于类的构造函数或方法声明。
参数装饰器表达式会在运行时当作函数被调用,传入下列 3 个参数:
- 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
- 成员的名字。
- 参数在函数参数列表中的索引。
参数修饰器的返回值会被忽略。
模块
介绍
模块是 ES6
引入的一个概念。 ts
和 es
一样,只要文件包含 import
或 export
,就会被当做一个模块,否则该文件是全局可见的。
ES6 风格的导入导出
导出声明
任何变量、函数、类、类型、接口、命名空间的声明都可以在前面加上 export
关键字来实现导出:
1 | export class UserInfo { |
其中,export
关键字只是表示这个声明被导出了,这些声明依然可以在当前文件中使用。
除了给声明加上 export
关键字,也可以使用 export
语句来导出一个内容。不过,export
关键字只能导出已有的声明。
1 | class UserInfo { |
导出语句
export
语句可以有多个:
1 | export { UserInfo, UserAuth, User }; |
利用 export
语句,还可以给声明重命名:
1 | export { count, user as defaultUser }; |
这样就导出了 count
和 defaultUser
两个声明。
重新导出
可以使用 export from
语句来从其他模块中导入数据,并重新导出:
1 | export { count, user as defaultUser } from "./user"; |
如果需要导出全部的内容,可以使用 *
:
1 | export * from "./user"; |
导入
我们可以使用 import from
语句来导入部分内容,也可以使用 as
来重命名:
1 | import { count, user as defaultUser } from "./user"; |
同样的,我们也可以使用 *
来导入全部内容,但是我们需要给导入的内容起个名字:
1 | import * as user from "./user"; |
导入的数据是不可以直接赋值的。
1 | import { user, User } from "./app"; |
有时候我们导入模块只是想运行一些代码,而不需要模块中的内容,这个时候就可以只写 import
:
1 | import "./app"; |
默认导出
有时候,用户在模块中只有一个输出,这个时候便可以使用 export default
来导出单个数据,也可以导出一个值:
1 | export default User; |
这个时候,import
语句便无需括号,而且可以使用任意的名字:
1 | import myUser from "./user"; |
CommonJS 风格的导入导出
typescript
也提供了这种风格的导入导出。
但需要注意一点,这种风格的导入和导出是配套使用的。不能和上面的写法混用。
导出
我们可以使用 export =
语法来定义一个模块的导出对象:
1 | export = 2333; |
导入
我们可以使用import
和 require
来导入模块:
1 | import user = require("./user"); |
这里的 require
只是使用了括号的语法糖,它并不是函数。
动态导入
我们可以使用 require()
来动态加载一个模块,不过需要先声明 require
函数:
1 | declare function require(moduleName: string): any; |
外部模块
介绍
typescript
默认情况下,只能识别 .ts
和 .tsx
结尾的模块,但项目实战中,往往会出现其他类型的模块。
例如,在一个典型的 Vue
项目中,会出现很多 .vue
文件。在 javascript
等弱类型语言中,这些特殊的模块往往是通过 Webpack
的 loader
来实现。
但是 typescript
加上了类型,如果直接引入,就会出现类型错误,因为 typescript
不认得 .vue
文件,也就不知道导入后文件的类型了:
1 | import App from "./App.vue"; // Cannot find module './App.vue' or its corresponding type declarations. |
此外,很多库都不是使用 typescript
编写的,如果用 typescript
导入,也不能识别类型。
为了解决这个问题,便出现了 外部模块 。外部模块是通过声明文件来完成的,它是一个 .d.ts
文件。声明文件相当于 C 和 C++中的 .h
文件,它只用来声明类型,而不去具体实现这些类型。
声明模块
我们在这个文件中使用 declare module
关键字来声明一个模块:
1 | declare module "url" { |
接下来,我们可以使用三斜杠语法引入文件,就可以使用了:
1 | /// <reference path="node.d.ts"/> |
简写
如果不想编写具体声明,也可以简写:
1 | declare module "url"; |
但导入后,它的类型会被识别为 any
:
1 | import { parse } from "url"; // parse 是 any 类型 |
通配符
上面的情况只解决了其中一种问题,但未解决 .vue
等模块的问题。
typescript
还提供了一种通配符模块,这样就可以让 typescript
识别.vue
等模块了:
1 | declare module "*.vue" { |
导出到全局变量
有些模块往往是支持多个模块加载器,也支持通过全局变量访问。对于这种模块,我们可以将其导出到全局变量:
1 | export function isEmail(x: string): boolean; |
在模块中,我们可以通过 import
来导入:
1 | import { isEmail } from validator; |
在模块之外,我们可以直接当成全局变量使用:
1 | validator.isEmail("a@example.com"); |
命名空间
介绍
和其他语言一样,命名空间是用来解决全局作用域中重名问题,避免全局污染。
typescript
中的命名空间相当于成一个对象,通过这个对象,我们可以访问命名空间作用域下的内容。
定义命名空间
我们可以使用 namespace
关键字声明一个命名空间:
1 | namespace Validator { |
需要注意一点,如果需要命名空间外可以访问里面的内容,需要加上 export
关键字:
1 | namespace Validator { |
然后通过访问对象的方法访问命名空间:
1 | Validator.validateString("test"); |
如果命名空间在单独的一个文件,我们可以使用一个三斜杠语法来引入一个命名空间:
1 | /// <reference path = "validators.ts" /> |
命名空间别名
有时候命名空间是嵌套的,像这样:
1 | namespace Validator { |
这个时候我们可以使用 import
关键字来为他们导入数据:
1 | import BaseValidator = Validator.BaseValidator; |
需要注意,这里没有 require
关键字。
命名空间和模块
在早期的 typescript
中,具备了两套模块系统,分别是内部模块和外部模块。其中,外部模块就是把一个文件当成一个模块,类似于 AMD
和 ESM
,。而内部模块,则与文件划分无关,只是单纯的隔离的作用域。
随着 ES6
的发布,ts
弃用了自己的模块系统,转而使用 ES6
的模块系统,这时外部模块改名叫模块;而内部模块则称为命名空间。
实际上,命名空间缺少很多模块的写法,难以识别它们的依赖关系。而模块则解决了这些问题,但使用模块需要有模块加载器,也增加了一些胶水代码。
对于比较简单的场景,使用模块可能会使代码过于冗余,使用命名空间可能会较好;而对于复杂的场景,使用模块便会有更好的可维护性。
如果选择使用模块,不要在里面使用命名空间。因为模块是一个单独的作用域,在里面使用命名空间不会有任何价值,而且会带来不必要的麻烦。
高级类型
联合类型
联合类型 (Union Types) 表示取值可以为多种类型中的一种。在变量定义时,类型使用 |
分开:
1 | let myNumber: string | number; |
当编译器无法确定变量的具体类型时,只能访问此联合类型中,所有类型里共有的属性或方法:
1 | let myNumber: string | number; |
在赋值时,它的具体类型就会被确定,就可以使用具体类型的方法:
1 | let myNumber: string | number = "2333"; |
共有的属性,也会跟着发生联合:
1 | type NetworkLoadingState = { |
类型和它的子类型联合得到类型本身,不过前提是这两个类型有一个是原始类型:
1 | type A = number | null; // number |
类型守卫
介绍
类型守卫,用于判断一个类型是否是用户要求的类型。
说它是守卫,这是因为它像极了看城门的守卫。这些看城门的任务是让符合要求的人进来,不符合要求的拒之门外,或者让他们干别的事情。
而类型守卫,就是让 typescript
判断一个值是一些类型(通常是个联合类型)中的哪一个。
定义类型守卫
对于联合类型,类型判断是有点麻烦的。在确定它的类型之前,我们只能访问共有的属性。要访问某一个类型的属性,就要确定它是其中的一个类型。
我们只需要简单的定义一个函数,它的返回值是一个类型谓词:
1 | function isStudent(people: Student | Teacher): people is Student { |
这个函数便是类型守卫。当我们需要判断类型的时候,使用这个函数,typescript
便会自动认定类型:
1 | if (isStudent(people)) { |
除了使用类型断言来判断类型,也可以使用 in
操作符:
1 | function isStudent(people: Student | Teacher): people is Student { |
typeof 类型守卫
对于原始类型,可以通过 typeof
来判断出它的类型,就不必要让用户去自定义类型守卫。
typeof
类型守卫只有两种形式能被识别:typeof v === "typename"
和typeof v !== "typename"
,而且 "typename"
必须是"number"
,"string"
,"boolean"
或"symbol"
。 但是TypeScript
并不会阻止用户与其它字符串比较,语言不会把那些表达式识别为类型守卫。
instanceof 类型守卫
对于类类型,可以通过 instanceof
来判断出它的类型,也没必要让用户去自定义类型守卫。
交叉类型
使用 &
把多个类型合并成一个类型,得到的类型称为交叉类型 (Intersection Types):
1 | interface UserInfo { |
交叉类型拥有所有类型中都有的属性:
1 | let tom: User = { |
同名属性的类型同样会发生交叉:
1 | interface UserInfo { |
两个原始类型从语义上是无法交叉的( string
和 number
能交叉吗?),将其交叉将得到 never
类型:
1 | interface UserInfo { |
运算规律
此处官方文档并未说明,只是将数学中的某些概念引入以辅助理解
幂等律
幂等律 的含义就是对一个数的重复次运算等于它本身。
在交叉类型和联合类型中,自己和自己交叉(联合) 得到自身:
1 | type A = number | number; |
零律
零律 就是满足 bx=xb=b 的式子,即一个数和一个常数的某个运算得到常数。
在交叉类型和联合类型中,类型和 any
交叉(联合) 得到 any
:
1 | type A = number | any; // any |
映射类型
有时候,我们希望需要一个现有的对象的只读版本或者必须版本,这个时候我们可以创建一个映射类型。 在映射类型里,新类型以相同的形式去转换旧类型里每个属性:
1 | type Readonly<T> = { |
需要注意一点,映射类型里不可以添加新的成员:
1 | type Readonly<T> = { |
如果需要新的成员,则需要使用 交叉类型:
1 | type Readonly<T> = { |
有条件类型
介绍
在类型定义语句中,我们可以通过三目运算符来定义一个带条件的类型:
1 | T extends U ? X : Y |
它得到的类型要么是 X
,要么是 Y
,如果无法判断条件,将会延迟解析。
例如:
1 | type TypeName<T> = T extends string |
未确定的有条件类型可以赋值给联合类型:
1 | function g<T>(arg: T) { |
分布式的有条件类型
如果有条件类型中的类型参数是个联合类型,则联合类型中的每一个类型都会分别进行类型推断,最终得到一个联合类型。这称为 分布式的有条件类型。
例如:
1 | type Type = TypeName<string, number>; |
它将展开成:
1 | type Type = (string extends string ? "string" : |
通过有条件类型,我们可以实现对字面量类型的过滤:
1 | type Diff<T, U> = T extends U ? never : T; |
含类型推断的有条件类型
有时候 extend
子句中出现的是函数,但是函数的返回值不确定,这个时候我们可以为 extends
子句添加类型推断:
1 | type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any; |
这个类型就实现了获取任意函数返回值类型。
对于如果extend
子句是数组,也可以进行类型推断:
1 | type UnpackArr<T> = T extends (infer R)[] ? R : any; |
这个类型就实现了对数组类型的解包。
类型兼容性
子类型的概念
当类型A
的性质(或属性)比类型 B
的性质(或属性)更多时,便称 A
是 B
的子类型(subtype)。
结构子类型
TypeScript 是一个基于结构化的类型系统,只要结构一致,即使没有在语义上声明子类型关系,编译器也认为这是赋值兼容的。
1 | interface IPeople { |
上面的这个例子中,People
没有显式继承 IPeople
,但仍然可以将People
对象赋值给 IPeople
。
在 TypeScript 中,这称为 结构子类型。而 java
等语言则是 名义子类型 。
内置类型的子类型关系
下面的图形展示了 typescript
中各种常见类型的子类型关系。其中 A-->B
记作 A
为 B
的子类型,此时A
类型的数据赋值可以赋值给 B
。
graph LR Tuple-->Array Array-->object Enum-->object never-->undefined undefined-->null null-->undefined null-->number null-->bigint null-->string null-->boolean null-->Tuple null-->Enum null-->void
变体
在由父类 和子类 组合的复杂类型中,存在着复杂的类型兼容性,这些兼容性存在着四种情况:
- 协变(Covariant):只在同一个方向(向下);
例如,A 有子类 B,B 有子类 C。在协变的情况下,输入B的位置,可以输入C,但不能输入A。
- 逆变(Contravariant):只在相反的方向(向上);
例如,A 有子类 B,B 有子类 C。在逆变的情况下,输入B的位置,可以输入A,但不能输入C。
- 双向协变(Bivariant):包括同一个方向和不同方向(向上、向下均可);
例如,A 有子类 B,B 有子类 C。在双向协变的情况下,输入B的位置,可以输入A,也可以输入C。
- 不变(Invariant):如果类型不完全相同,则它们是不兼容的。
例如,A、B和C没有继承关系。在不变的情况下,输入B的位置,不能输入A和C。
比较字面量类型
例如下面的例子中,Method1
的内容比 Method2
更具体,内容更少,所以 Method1
是 Method2
的子类型。
这和一般基于集合的理解是相反的。
1 | type Son = 'GET' | 'POST' |
比较两个函数
函数的返回值
函数的返回值属于 协变,因为返回的内容必须不少于定义的内容。
1 | interface Point1D { |
函数参数的个数
对于函数的参数,更少的参数是被期望的。
1 | let handler = (fn: (data: any, msg: string) => void) => {}; |
handler 定义时,传入函数需要两个参数,这两个参数是保证的,少传一个参数也不会出现问题。但多一个参数,那么这个多出来的参数就会产生问题。
同样,可选参数和剩余参数也是支持的:
1 | let handler = (fn: (data: any, msg: string) => void) => {}; |
函数参数的类型
当 strictFunctionTypes
编译器选项关闭时,函数参数的个数属于 双向协变,否则属于 逆变。
1 | interface Point1D { |
双向协变 最初是为了方便事件的传递,而设计出来的。定义时,这个函数提供的是一个泛化的父类型(Event),但调用处理函数可能需要一个特殊化的子类型(如MouseEvent)。这种应用很常见,但是很少出错。
1 | function listenEvent(eventType: EventType, handler: (n: Event) => void) { |
比较枚举类型
数字和枚举类型是赋值兼容的。也就是说,数字和枚举类型可以相互赋值。
用不同的枚举类型定义的变量,被认为是不同的。
比较两个类
- 只比较实例部分 仅仅只有实例成员和方法会相比较,构造函数和静态成员不会被检查
1 | class People { |
- 私有的和受保护的成员必须来自于相同的类
1 | class Animal { |
比较泛型
- 对于泛型类和泛型接口,如果泛型没有被用作成员,那么泛型参数对于类型没有任何影响。
1 | interface Empty<T> { } |
- 对于泛型函数,若泛型参数未被实例化,则作为
any
处理。
1 | let fn1 = function<T>(x: T): T { |
工具类型
适合接口
Partial<T>
用于将一个类型的所有属性设置为可选的。
1 | class UserInfo { |
Required<T>
用于将一个类型的所有属性设置为必须的。
1 | class UserInfo { |
Readonly<T>
用于将一个类型的所有属性设置为只读的。
1 | class UserInfo { |
Record<K,T>
构造一个类型,属性名的类型是 K
,属性值的类型为 T
。当 K
为字面量类型时,每一个类型必须出现一次。
1 | class Article { |
Omit<T,K>
从类型 T
中获取所有属性,然后从中剔除属性K
。
1 | class Article { |
适合字面量类型和联合类型
Exclude<T,U>
从 T
中剔除所有可以赋值给 U
的属性。
1 | type TargetId = Exclude<0 | 1 | 2 | 3 | 4, 0>; // 1 | 2 | 3 | 4 |
NotNullable<T>
从类型 T
中剔除 null
和 undefined
,然后构造一个类型。
1 | type TargetId = NotNullable<string | null>; // string |
其他
ReturnType<T>
用函数的返回值构造一个类型。
1 | type T1 = ReturnType<() => number>; // number |
InstanceType<T>
用构造函数类型 T
的实例类型构造一个类型。
1 | class Point { |
参考资料
- TypeScript 入门教程:https://ts.xcatliu.com/
- TypeScript 官方文档:https://www.typescriptlang.org/docs
- typescript 已经有模块系统了,为什么还需要 namespace:https://www.zhihu.com/question/65676593/answer/242519413
- 深入理解 TypeScript: https://jkchao.github.io/typescript-book-chinese/
- 标题: TypeScript 基础
- 作者: ObjectKaz
- 创建于: 2021-08-24 01:26:00
- 更新于: 2023-05-25 17:18:19
- 链接: https://www.objectkaz.cn/7a53113f1995.html
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。