接上回 重温 TypeScript,在之前的章节当中,我们简单的梳理一下 TypeScript
的基础内容和基本用法,本章当中我们就来看一些 TypeScript
当中的进阶内容,也算是针对于 TypeScript
做一个比较深入的学习记录吧,主要包括以下内容
- 类型别名
- 字面量类型
- 元组
- 枚举
- 类
- 类与接口
- 泛型(因为泛型涉及的相关内容较多,所以另起篇幅来进行介绍)
下面我们就先从比较简单的类型别名和字符串字面量类型开始看起
类型别名
这个很好理解,就是用来给一个类型起个新名字,方式是使用 type
来创建类型别名
1 | type Name = string; |
关于首字母大写的问题,通常来说在语法上没有限制,不过参考 TypeScript 官方 的写法,一般建议首字母大写
字面量类型
同类型别名一样,字面量类型也是使用 type
来进行定义,它的作用简单来说就是用来约束取值只能是某几个字段当中的一个,比如下面这个示例
1 | type EventNames = 'click' | 'scroll' | 'mousemove' |
在上面的示例当中,我们使用 type
定义了一个字符串字面量类型 EventNames
,它规定只能取三种事件名当中的一种,如果定义了约定以外的字段,就会报错
当然除了字符串字面量类型,数值类型也是可以的
1 | type Nums = 1 | 2 | 3 |
元组
我们在之前的章节当中介绍了数组的类型,众所周知,数组一般由同种类型的值组成,也就是合并了相同类型的对象,但有时我们需要在单个变量中存储不同类型的值,在这种情况下我们就可以使用元组,它可以理解为是合并了不同类型的对象
在 JavaScript
中是没有元组的,元组是 TypeScript
中特有的类型,其工作方式类似于数组,
元组可用于定义具有有限数量的未命名属性的类型,每个属性都有一个关联的类型,使用元组时必须提供每个属性的值
为了更直观地理解元组的概念,我们来看一个具体的例子
1 | let x: [string, number] = ['zhangsan', 18] |
在上面代码中,我们定义了一个名为 x
的变量,它的类型是一个类型数组 [string, number]
,然后我们按照正确的类型依次初始化 x
变量,与数组一样,我们可以通过下标来访问元组中的元素并且操作它们
1 | let x: [string, number] = ['zhangsan', 18] |
但是当直接对元组类型的变量进行初始化或者赋值的时候,需要提供所有元组类型中指定的项,否则会报错
1 | let x: [string, number] |
另外需要注意的是,如果当添加越界的元素时,它的类型会被限制为元组中每个类型的联合类型
1 | let x: [string, number] = ['zhangsan', 18] |
但是通常不建议超出范围,因为使用元祖可以确定元素数据类型,可以把元祖理解为固定长度,但是超出范围不能保证其类型
枚举
枚举(Enum
)类型在某些方面与我们之前介绍的元组有一些类似的地方,它主要用于取值被限定在一定范围内的场景,比如一周只能有七天,颜色限定为红绿蓝等等,使用 enum
关键字来定义
1 | enum Days { Sun, Mon, Tue, Wed, Thu, Fri, Sat } |
枚举成员会被赋值为从 0
开始递增的数字,同时也会对枚举值到枚举名进行反向映射
1 | enum Days { Sun, Mon, Tue, Wed, Thu, Fri, Sat } |
我们也可以给枚举项手动赋值
1 | enum Days { Sun = 7, Mon, Tue = 1, Wed, Thu, Fri, Sat } |
运行后可以发现,未手动赋值的枚举项会接着上一个枚举项依次递增,但是需要注意的是,如果未手动赋值的枚举项与手动赋值的重复了,TypeScript
是不会察觉到这一点的
1 | enum Days { Sun = 3, Mon = 1, Tue, Wed, Thu, Fri, Sat } |
在上面的示例当中,递增到 3
的时候与我们前面手动赋值的 Sun
的取值重复了,但是 TypeScript
并没有报错,导致 Days[3]
的值先是 'Sun'
,而后又被 'Wed'
覆盖了,但是我们在实际应用场景当中需要注意,最好不要出现这种覆盖的情况
当然,手动赋值的枚举项也可以为小数或负数,此时后续未手动赋值的项的递增步长仍为 1
1 | enum Days { Sun = 1.2, Mon, Tue, Wed, Thu, Fri, Sat } |
这里我们简单总结一下枚举成员的特点,主要有两点
- 是只读属性,无法修改
- 枚举成员值默认从
0
开始递增,可以自定义设置初始值
1 | enum Gender { |
而枚举成员值有以下特点
- 可以没有初始值
- 可以是一个对常量成员的引用
- 可以是一个常量表达式
- 也可以是一个非常量表达式
1 | enum Char { |
常量枚举与普通枚举的区别
主要有以下一些区别
- 常量枚举会在编译阶段被删除
- 枚举成员只能是常量成员
1 | const enum Colors { |
上面代码经过编译以后是下面这样
1 | 'use strict' |
- 常量枚举不能包含计算成员,如果包含了计算成员,则会在编译阶段报错
1 | // ❌ |
枚举的使用场景
我们先来看一段经常会遇见的代码风格,如下
1 | function initByRole(role) { |
上面的代码虽然可以正常运行,但是也存在着一些问题
- 可读性差,很难记住数字的含义
- 可维护性差,硬编码,后续修改的话牵一发动全身
我们可以使用枚举的方式来改善一下
1 | enum Role { |
使用数字类型作为标志
这种使用方式经常在一些第三方类库当中可以看到,因为枚举的一个很好用途是使用枚举作为标志,这些标志允许你检查一组条件中的某个条件是否为真,比如下面这个例子,我们有一组关于 animals
的属性
1 | enum AnimalFlags { |
在这里我们使用了左移的位运算符,将数字 1
的二进制向左移动位置得到数字 0001
、0010
、0100
和 1000
(换成十进制结果是 1
,2
,4
,8
),当我们在使用这种标记的时候,|
(或)、&
(和)、~
(非)等位运算符将会是很好的搭配
1 | enum AnimalFlags { |
在上面的示例当中
- 我们使用
|=
来添加一个标志 - 组合使用
&=
和~
来清理一个标志 - 使用
|
来合并标志
因为组合标志可以在枚举类型中定义方便快捷的方式,比如下面的 EndangeredFlyingClawedFishEating
1 | enum AnimalFlags { |
以上关于枚举的相关内容我们就暂时介绍到这里,因为毕竟平时涉及到的有限,如果后续工作当中有遇到相关内容的话再来进行完善,枚举的其他一些用法可以参考文档当中的 枚举
类
传统方法中,JavaScript
通过构造函数实现类的概念,通过原型链实现继承,而在 ES6
中,我们终于迎来了 class
,TypeScript
除了实现了所有 ES6
中的类的功能以外,还添加了一些新的用法,下面就让我们就来看看 TypeScript
中类的用法
TypeScript
可以使用三种访问修饰符(Access Modifiers
),分别是 public
、private
和 protected
public
,修饰的属性或方法是公有的,可以在任何地方被访问到,默认所有的属性和方法都是public
的private
,修饰的属性或方法是私有的,不能在声明它的类的外部访问protected
,修饰的属性或方法是受保护的,它和private
类似,区别是它在子类中也是允许被访问的
下面我们来看几个示例
1 | class Person { |
在上面示例当中,name
被设置为了 public
,所以直接访问实例的 name
属性是允许的,如果我们希望有的属性是无法直接存取的,这时候就可以用 private
了
1 | class Person { |
但是需要注意的是,在 TypeScript
编译之后的代码中,其实并没有限制 private
属性在外部的可访问性
1 | var Person = (function () { |
同时,使用 private
修饰的属性或方法,在子类中也是不允许访问的
1 | class Person { |
而如果是用 protected
修饰,则允许在子类中访问
1 | class Person { |
如果我们不想让该类被继承或者实例化,那么可以在构造函数上设置 private
修饰
1 | class Person { |
当构造函数修饰为 protected
时,该类只允许被继承
1 | class Person { |
同样的,上面介绍到的修饰符和下面提到的 readonly
都可以使用在构造函数的参数当中,等同于类中定义该属性的同时给该属性赋值
1 | class Person { |
readonly
只读属性关键字,表示只允许出现在属性声明或索引签名或构造函数中,比如我们可以指定一个类的属性为只读,然后在声明时或者构造函数中初始化它们
1 | class Person { |
不过需要注意的是,如果 readonly
和其他访问修饰符同时存在的话,需要写在其后面
1 | class Person { |
当然也可以在 interface
和 type
里使用 readonly
1 | type Foo = { |
甚至可以把索引签名标记为只读
1 | interface Foo { |
如果想以不变的方式使用原生 JavaScript
数组,可以使用 TypeScript
提供的 ReadonlyArray<T>
接口
1 | let foo: ReadonlyArray<number> = [1, 2, 3] |
还有一个 Readonly
的映射类型,它接收一个泛型 T
,用来把它的所有属性标记为只读类型
1 | type Foo = { |
另外在 React
当中我们也可以标记 Props
和 State
为不可变数据
1 | interface Props { |
然而我们并不需要这样操作,因为 React
的声明文件已经标记这些为 readonly
(通过传入泛型参数至一个内部包装,来把每个属性标记为 readonly
)
1 | export class Something extends React.Component<{ foo: number }, { baz: number }> { |
最后我们再来简单的了解一下 readonly
与 const
之间的区别,首先 const
用于变量,而 readonly
用于属性,比如下面这个示例
1 | const foo = 123 // 变量 |
另外 const
变量不能重新赋值给其他任何事物,而 readonly
用于别名,可以修改属性
1 | const foo: { readonly bar: number } = { |
当然如果我们在上面的 iMutateFoo
当中明确的表示参数是不可修改的,那么编译器会发出错误警告
1 | interface Foo { |
抽象类
使用 abstract
来定义抽象类和其中的抽象方法,那么什么是抽象类呢?首先抽象类是不允许被实例化的
1 | abstract class Person { |
在上面的例子当中,我们定义了一个抽象类 Person
,可以发现在实例化抽象类的时候报错了,另外如果抽象类当中存在抽象方法(指不包含具体实现的方法),如下
1 | abstract class Person { |
其中的抽象方法必须被子类实现
1 | abstract class Person { |
私有字段
在 TypeScript 3.8
版本就开始支持 ECMAScript
私有字段,使用方式如下
1 | class Person { |
可以发现,会有报错信息,提示我们属性 #name
在类 Person
外部不可访问,另外,私有字段与常规属性(甚至使用 private
修饰符声明的属性)不同,私有字段需要约定以下几点规则
- 私有字段以
#
字符开头,有时候我们称其为『私有名称』 - 每个私有字段名称都唯一地限定于其包含的类
- 不能在私有字段上使用
TypeScript
可访问性修饰符(如public
或private
) - 私有字段不能在包含的类之外访问,甚至不能被检测到
在使用私有字段的过程当中,不得不让我们想起另外一个与其十分类似的东西,那就是 private
修饰符,那么使用 #
定义的私有字段与 private
修饰符定义字段有什么区别呢?先让我们来看一个 private
的示例
1 | class Person { |
在上面代码中,我们创建了一个 Person
类,该类中使用 private
修饰符定义了一个私有属性 name
,接着使用该类创建一个 person
对象,然后通过 person.name
来访问 person
对象的私有属性,这时 TypeScript
编译器会提示我们有以下异常
1 | Property 'name' is private and only accessible within class 'Person'.(2341) |
那如何解决这个异常呢?当然我们可以使用类型断言把 person
转为 any
类型
1 | (<any>person).name |
通过这种方式虽然解决了 TypeScript
编译器的异常提示,但是在运行时我们依然可以发现还是可以访问到 Person
类内部的私有属性,至于为什么会这样,我们来看一下编译生成的 ES5
代码就知道答案了
1 | var Person = /** @class */ (function () { |
下面我们再来看看在 TypeScript 3.8
以上版本通过 #
号定义的私有字段编译后会生成什么代码,这里还是以上面的代码为例
1 | class Person { |
我们将编译的目标设置为 ES2015
,会编译生成以下代码
1 | 'use strict' |
通过观察我们可以发现,在使用 #
号定义的私有字段当中,会通过 WeakMap
对象来存储,同时编译器会生成 __classPrivateFieldSet
和 __classPrivateFieldGet
这两个方法用于设置值和获取值,这样一来我们就无法在外部进行 name
属性的访问了
类与接口
实现(implements
)是面向对象中的一个重要概念,一般来说一个类只能继承自另一个类,有时候不同类之间可以有一些共有的特性,这时候就可以把特性提取成接口(interfaces
),用 implements
关键字来实现,这个特性大大提高了面向对象的灵活性
1 | interface SayName { |
同样的,一个类可以实现多个接口
1 | interface SayName { |
但是需要注意一点,接口与接口之间也是可以是继承关系的
1 | interface SayName { |
接口继承类
最后我们再来看一种比较有趣的情况,但是也是平常使用较少的一种情况,那就是接口继承类,在常见的面向对象语言中,接口是不能继承类的,但是在 TypeScript
中却是可以的
1 | class Point { |
但是为什么 TypeScript
会支持接口继承类呢?实际上当我们在声明 class Point
的时候,除了会创建一个名为 Point
的类之外,同时也创建了一个名为 Point
的类型(实例的类型)
所以我们既可以将 Point
当做一个类来用(使用 new Point
创建它的实例)
1 | class Point { |
也可以将 Point
当做一个类型来用
1 | class Point { |
上面这个例子实际上可以等价于
1 | class Point { |
在上面示例当中我们新声明了一个 PointInstanceType
的类型,其实本质上与声明 class Point
时创建的 Point
类型是等价的,所以在回过头来看我们之前的示例,就很容易的理解为什么 TypeScript
会支持接口继承类了
1 | class Point { |
当我们声明 interface Point3d extends Point
的时候,本质上 Point3d
继承的是类 Point
的实例的类型,我们可以理解为定义了一个接口 Point3d
继承另一个接口 PointInstanceType
,所以『接口继承类』和『接口继承接口』没有什么本质的区别
但是需要注意的是,那就是 PointInstanceType
相比于 Point
缺少了 constructor
方法,这是因为声明 Point
类时创建的 Point
类型是不包含构造函数的,另外除了构造函数是不包含的,静态属性或静态方法也是不包含的(实例的类型当然不应该包括构造函数、静态属性或静态方法)
换句话说,声明 Point
类时创建的 Point
类型只包含其中的实例属性和实例方法
1 | class Point { |
上例中最后的类型 Point
和类型 PointInstanceType
本质上是等价的,所以我们可以发现,在接口继承类的时候,只会继承它的实例属性和实例方法
类型接口之间的区别
这里我们简单的总结一下几种不同的类型接口之间的区别
- 『可索引类型接口』
这个我们在上面也有所提及,它一般用来约束数组和对象,其中『数字索引』通常用来约束数组,其中的 index
可以任意取名,只要 index
的类型是 number
,那么值的类型必须是 string
1 | interface StringArray { |
而『字符串索引』通常用来约束对象,只要 index
的类型是 string
,那么值的类型必须是 string
1 | interface StringObject { |
- 『函数类型接口』
通常是对方法传入的参数和返回值进行约束,但是需要注意普通的接口与函数类型接口之间的区别
1 | // 普通的接口 |
1 | // 函数类型接口 |
另外,也可以使用类型别名
1 | type Add = (x: number, y: number) => number |
- 『类类型接口』
简单来说,就是对类的约束,让类去实现接口(可以实现多个接口),如果接口用于一个类的话,那么接口会表示『行为的抽象』,并且接口只能约束类的公有成员(实例属性或方法),而无法约束私有成员、构造函数、静态属性或方法
1 | interface Speakable { |
- 『混合类型接口』
简单来说就是一个对象可以同时做为函数和对象使用,也就是我们之前提到过的接口与接口之间的继承
1 | interface FnType { |
1 | interface Counter { |
interface 与 type 的区别
最后我们再来看看 interface
与 type
之间的区别,其实简单来说,interface
主要用于描述『数据结构』,而 type
主要用于描述『类型关系』,下面我们通过一些示例来进行区分
- 相同点一,都可以描述一个对象或者函数
1 | // interface |
- 相同点二,都允许拓展(
extends
)
interface
和 type
都可以拓展,并且两者并不是相互独立的,也就是说 interface
可以 extends type
,type
也可以 extends interface
,虽然效果差不多,但是两者语法不同
1 | // interface extends interface |
- 不同点一,
type
可以而interface
不行
type
可以声明基本类型别名,联合类型,元组等类型
1 | // 基本类型别名 |
还可以使用 typeof
获取实例的 类型进行赋值
1 | // 当我们想获取一个变量的类型时,可以使用 typeof |
另外还可以用来做一些骚操作
1 | type StringOrNumber = string | number |
- 不同点二,
interface
可以而type
不行
好像只有一点,那就是 interface
能够声明合并
1 | interface User { |
其实一般来说,如果不清楚什么时候用 interface/type
,能用 interface
实现,就用 interface
,如果不能就用 type
,并没有强行规定谁好或是谁不好
tsconfig.json
最后的最后,我们再来简单的看一下 tsconfig.json
这个配置文件和平常遇到的一些与其相关的配置问题,我们先来看看 tsconfig.json
的作用,总的来说,tsconfig.json
的作用主要有以下几点
- 用于标识
TypeScript
项目的根路径 - 用于配置
TypeScript
编译器 - 用于指定编译的文件
而其中涉及到的字段较多,我们这里只是挑选几个比较重要的简单介绍一下,如下
files
,设置要编译的文件的名称include
,设置需要进行编译的文件,支持路径模式匹配exclude
,设置无需进行编译的文件,支持路径模式匹配compilerOptions
,设置与编译流程相关的选项
这里我们重点关注一下 compilerOptions
这个字段,compilerOptions
支持很多选项,常见的有 baseUrl
、target
、baseUrl
、moduleResolution
和 lib
等,每个选项的详细说明如下所示
1 | { |
下面我们再来看看平常会遇到的一些与 tsconfig.json
相关的配置问题
如何对 .js 文件进行类型检查
- 在
tsconfig.json
中可以设置checkJs: true
来对.js
文件进行类型检查和错误提示- 通过在
.js
文件顶部添加// @ts-nocheck
注释,让编译器忽略当前文件的类型检查 - 相反可以通过不设置
checkJs: true
并在.js
文件顶部添加一个// @ts-check
注释,让编译器检查当前文件 - 也可以在
tsconfig.json
中配置include/exclude
,选择或是排除对某些文件进行类型检查 - 你还可以使用
// @ts-ignore
来忽略本行的错误
- 通过在
- 在
.js
文件里,类型可以和在.ts
文件里一样被推断出来,当类型不能被推断时,可以通过 jsdoc 来指定类型 - 另外,在
TypeScript
当中支持 jsdoc 注解
1 | /** @type {number} */ |
for-of 无法遍历 map 数据结构
比如如下示例,当我们设置 "target": "es5"
的时候,会报错误,并且无法执行 for
语句
1 | const map = new Map([ |
编辑器会提示我们有错误存在
1 | TS2569: Type 'Map<string, string>' is not an array type or a string type. Use compiler. option '- downlevellteration' to allow iterating of iterators. |
针对于这种情况,我们就需要配置 tsconfig.json
当中的 dom.iterable
和 downlevelIteration
1 | { |
当然,如果配置的是 "target": "es6"
的时候,也可以正常执行,具体原因可见 tsc CLI Options,但是在这里我们需要了解一下在配置 lib
时需要注意的问题
当我们在安装 TypeScript
时,会顺带安装 lib.d.ts
等声明文件,此文件包含 JavaScript
运行时以及 DOM
中存在各种常见的环境声明
- 它自动包含在
TypeScript
项目的编译上下文中 - 它能让你快速开始书写经过类型检查的
JavaScript
代码
而 tsconfig.json
中的 lib
选项用来指定当前项目需要注入哪些声明库文件,如果没有指定,默认注入的库文件列表为
1 | For --target ES5: DOM, ES5, ScriptHost |
如果在 TypeScript
中想要使用一些 ES6
以上版本或者特殊的语法,就需要引入相关的类库,例如 ES7
、DOM.Iterable
等
另外需要注意的一点就是,如果配置的是 "target": "es6"
,则 TSC
就会默认使用 "classic"
模块解析策略,这个策略对于 import * as abc from "@babel/types"
这种非相对路径的导入,不能正确解析,解决方法就是指定解析策略为 node
,也就是配置 "moduleResolution": "node"
关于
moduleResolution
的解析策略可见 模块解析
为什么在 exclude 列表里的模块还会被编译器使用
有时候是被 tsconfig.json
自动加入的,如果编译器识别出一个文件是模块导入目标,它就会加到编译列表里,不管它是否被排除了
因此,要从编译列表中排除一个文件,你需要在排除它的同时,还要排除所有对它进行 import
或使用了 ///
指令的文件
如何在 TypeScript 当中使用 JSX
如果想在 TypeScript
当中使用 JSX
,必须具备两个条件
- 首先给文件一个
.tsx
扩展名 - 其次启用
jsx
选项
TypeScript
具有三种 JSX
模式,即 preserve/react/react-native
,这些模式只在代码生成阶段起作用,类型检查并不受影响
preserve
模式下不会将JSX
编译成JavaScript
,生成代码中会保留JSX
,以供后续的转换操作使用(比如Babel
),另外输出文件会带有.jsx
扩展名react
模式下直接将JSX
编译成JavaScript
,会生成React.createElement
的形式,在使用前不需要再进行转换操作了,输出文件的扩展名为.js
react-native
模式下相当于preserve
,它也保留了所有的JSX
,但是输出文件的扩展名是.js
具体区别如下表所示
模式 | 输入 | 输出 | 输出文件扩展名 |
---|---|---|---|
preserve |
<div /> |
<div /> |
.jsx |
react |
<div /> |
React.createElement('div') |
.js |
react-native |
<div /> |
<div /> |
.js |
esModuleInterop 与 allowSyntheticDefaultImports
esModuleInterop
如果一个模块遵循 ES6
模块规范,当默认导出内容时(export default xx
),ES6
模块系统会自动给当前模块的顶层对象加上一个 default
属性,指向导出的内容
当一个 ES6
模块引入该模块时(import moduleName from 'xx'
),ES6
模块系统默认会自动去该模块中的顶层对象上查找 default
属性并将值赋值给 moduleName
,而如果一个非 ES6
规范的模块引入 ES6
模块直接使用时(var moduleName = require('xx')
)就会报错,需要通过 moduleName.default
来使用
TypeScript
为了兼容,引入了 esModuleInterop
选项,设置 esModuleInterop
为 true
,在编译时自动给该模块添加 default
属性,就可以通过 import moduleName from 'xx'
的形式导入非 ES6
模块,不再需要使用 import moduleName = require('xx')
的形式
allowSyntheticDefaultImports
它的作用是允许默认导入没有设置默认导出(export default xx
)的模块,可以以 import xx from 'xx'
的形式来引入模块
1 | // 配置前 |
总结
我们在 重温 TypeScript 一节当中梳理了一些 TypeScript
的基础内容,也算是回顾一下 TypeScript
的基本用法,而在本章当中则是简单的介绍了一些比较常用的姑且算是进阶的内容
但是 TypeScript
当中所涉及到的内容并不仅仅只有我们介绍到的这些,其它一些相关内容比如 函数输入的类型推论 或是 多态 等平常很少涉及到的内容,我们也就没有多做提及,不过以后如果在工作当中遇到相关内容的话会再来完善这两章的相关内容
更多相关内容可以参考 官方手册 来了解更多,中文版可见 TypeScript 中文