1. 前言 ✍️
- 我们都知道,
JavaScript
是一门非常灵活的编程语言,这种灵活性一方面使得它成为最受欢迎的编程语言,另一方面也使得它的代码质量参差不齐,维护成本高,运行时错误多。 TypeScript
是添加了类型系统的JavaScript
,适用于任何规模的项目。TypeScript
的类型系统在很大程度上弥补了JavaScript
的缺点。- 类型系统按照「类型检查的时机」来分类,可以分为动态类型和静态类型:
- 动态类型是指在运行时才会进行类型检查,这种语言的类型错误往往会导致运行时错误,我们熟悉的
JavaScript
即属于动态类型,它是一门解释型语言,没有编译阶段。 - 静态类型是指编译阶段就能确定每个变量的类型,这种语言的类型错误往往会导致语法错误。由于
TypeScript
在运行前需要先编译为JavaScript
,而在编译阶段就会进行类型检查,所以TypeScript
属于 静态类型。
- 动态类型是指在运行时才会进行类型检查,这种语言的类型错误往往会导致运行时错误,我们熟悉的
- 也许初学者会认为使用
TypeScript
需要写额外的代码,降低开发效率。而他们可能不知道的是,TypeScript
增强了编辑器(IDE)的功能,包括代码补全、接口提示、跳转到定义、代码重构等,这在很大程度上提高了开发效率。TypeScript
的类型系统可以为大型项目带来更高的可维护性,以及更少的 bug。 - 为了提升开发幸福感,下面将详细介绍如何在项目中用好
TypeScript
。
2. 在项目中的实践
2.1 善用类型注释
-
我们可以通过
/** */
形式的注释为给TypeScript
类型做标记提示: -
当鼠标悬浮在使用到该类型的地方时,编辑器会有更好的提示:
2.2 善用类型扩展
-
TypeScript
中定义类型有两种方式:接口(interface
)和类型别名(type alias
)。在下面的例子中,除了语法不一样,定义的类型是一样的: -
接口和类型别名均可以扩展:
-
接口和类型别名并不互斥的,也就是说,接口可以扩展类型别名,类型别名也可以扩展接口:
-
TypeScript: Interfaces vs Types 中详细介绍了接口和类型别名的区别,这里不再阐述。
-
接口和类型别名的选用时机:
- 在定义公共 API(如编辑一个库)时使用
interface
,这样可以方便使用者继承接口; - 在定义组件属性(
Props
)和状态(State
)时,建议使用type
,因为type
的约束性更强; type
类型不能二次编辑,而interface
可以随时扩展。
- 在定义公共 API(如编辑一个库)时使用
2.3 善用声明文件
-
声明文件必需以
.d.ts
为后缀。一般来说,TypeScript
会解析项目中所有的*.ts
文件,因此也包含以.d.ts
结尾的声明文件。 -
只要
tsconfig.json
中的配置包含了typing.d.ts
文件,那么其他所有*.ts
文件就都可以获得声明文件中的类型定义。
2.3.1 第三方声明文件
- 当在
TypeScript
项目中使用第三方库时,我们需要引用它的声明文件,才能获得对应的代码补全、接口提示等功能。 - 针对多数第三方库,社区已经帮我们定义好了它们的声明文件,我们可以直接下载下来使用。一般推荐使用
@types
统一管理第三方库的声明文件,@types
的使用方式很简单,直接用npm
或yarn
安装对应的声明模块即可。以lodash
为例:
2.3.2 自定义声明文件
-
当一个第三方库没有提供声明文件时,我们就需要自己书写声明文件。以
antd-dayjs-webpack-plugin
为例,当在config.ts
中使用antd-dayjs-webpack-plugin
时,若当编辑器没有找到它的声明文件,则会发生以下报错: -
当我们使用
yarn add @types/antd-dayjs-webpack-plugin --dev
尝试解决问题时,出现下面的错误: -
也就是找不到该库相关的声明文件(可以在这里搜索你需要的声明文件,找不到则没有)。
-
为了解决编辑器的报错提示,我们可以采用它提供的另一种方法:添加一个包含
declare module 'antd-dayjs-webpack-plugin';
的新声明文件。我们也可以不用新增文件,在前面提到的typing.d.ts
添加下面的内容即可:
全局变量
- 当我们需要在多个
ts
文件中使用同一个TypeScript
类型时,常见做法会在constant.ts
文件中声明相关类型,并将其export
出去给其他ts
文件import
使用,无疑会产生很多繁琐的代码。 - 前面提到,只要
tsconfig.json
中的配置包含了我们自定义的声明文件*.d.ts
,则声明文件中的类型定义都能被项目中的*.ts
文件获取到。因此,我们可以将多个ts
文件都需要使用的全局类型写在声明文件中,需要使用该类型的ts
文件不需要import
就可以直接使用。
命名空间
- 在代码量较大的情况下,为了避免各种变量名冲突, 可以将相同模块的函数、类、接口等都放置在命名空间内。
- 在
ts
文件使用: - 由于官方文档已经详细介绍了声明文件的相关内容,这里不再进行阐述,有需要的同学可以自行阅读。
2.4 善用 TypeScript 支持的 JS 新特性
2.4.1 可选链(Optional Chaining)
- 可选链(Optional Chaining)
?.
是ES11(ES2020)
新增的特性,TypeScript 3.7
支持了这个特性。可选链可以让我们在查询具有多层级的对象时,不再需要进行冗余的各种前置校验:
- 否则,直接访问
user.info.getAge()
很容易命中Uncaught TypeError: Cannot read property...
。 - 用了可选链,上面代码会变成:
- 可选链是一种先检查属性是否存在,再尝试访问该属性的运算符。
TypeScript
在尝试访问user.info
前,会先尝试访问user
,只有当user
既不是null
也不是undefined
才会继续往下访问,如果user
是null
或者undefined
,则表达式直接返回undefined
。 - 目前,可选链支持以下语法操作:
2.4.2 空值合并运算符(Nullish coalescing Operator)
- 空值合并运算符
??
是ES12(ES2021)
新增的特性,TypeScript 3.7
支持了这个特性。当左侧的操作数为null
或者undefined
时,返回其右侧操作数,否则返回左侧操作数。
- 与逻辑或操作符(
||
) 不同,||
会在左侧操作数为falsy
值(例如,''
或0
)时返回右侧操作数。也就是说,如果使用||
来为某些变量设置默认值,可能会遇到意料之外的行为:
2.5 善用访问限定修饰符
TypeScript
的类定义允许使用private
、protected
和public
这三种访问修饰符声明成员访问限制,并在编译期进行检查:public
: 公有类型,在类里面、子类、类外面都可以访问到,如果不加任何修饰符,默认为此访问级别;protected
: 保护类型,在类里面、子类里面可以访问,在类外部不能访问;private
: 私有类型,只能在当前类内部访问。
- 如果不加任何修饰符,默认为
public
访问级别:
- 上面的代码可以拿到 TypeScript Playground 上去运行,在
JS
区,我们可以看到转义后的Person
类定义,已经去掉了访问限定修饰符:
- 这就意味着,转义后的代码在
JS
环境中完全可以正确执行,不会受限。不过在编辑器内,我们可以看到p.name
被标记为有错,鼠标移上去可以看到具体的错误信息。 TypeScript
扩展了更为严格的语法,并借助 LSP 和编译器来帮助开发者在开发环境中尽早发现并解决存在或替在的问题。- 然而,正如上面的示例所示,TS 编译出来的 JS 库并不能限制最终用户如何使用。也就是说,如果使用
TypeScript
写一个库,使用private
或protected
来限定成员访问,在其用户同样使用TypeScript
的时候不会有问题,但当其用户使用JavaScript
的时候,却并不能受到期望的限制。
显然 ECMAScript 受到 TypeScript 启发,在 ES2015 中引入了新的类定义语法,并开始思考成员访问限制的问题,提出了基于 Symbol 和闭包私有成员定义方案,当然这个方案使用起来并不太能被接受。又经过长达 4 年思考、设计和讨论,最终在 ES2019 中发布了使用
#
号来定义私有成员的规范。Chrome 74+ 和 Node 12+ 已经实现了该私有成员定义的规范。
- 可见,即使
TypeScript
有了private
访问限定修饰符,#privateField
在仍然在TypeScript
中具有存在的意义。
2.6 善用类型收窄
TypeScript
类型收窄就是从宽类型转换成窄类型的过程,其常用于处理联合类型变量的场景。- 在
TypeScript
中,有许多方法可以收窄变量的类型:- 类型断言
- 类型守卫
- 双重断言
2.6.1 类型断言
- 类型断言可以明确地告诉
TypeScript
值的详细类型。当在某些场景下,我们非常确认某个值的类型,即使与TypeScript
推断出来的类型不一致,这时我们就可以使用类型断言,其语法如下:
- 在
tsx
语法(React
的jsx
语法的ts
版)中必须使用前者,即值 as 类型
。同时,因为<>
容易跟泛型语法起冲突,所以建议大家在使用类型断言时,统一使用值 as 类型
这样的语法。 - 当
TypeScript
不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型中共有的属性或方法。
- 而有时候,我们确实需要在还不确定类型的时候就访问其中一个类型特有的属性或方法,如:
- 上面的例子中,获取
animal.swim
的时候会报错。此时可以使用类型断言,将animal
断言成Fish
类型,就可以解决访问animal.swim
时报错的问题:
- 需要注意的是,类型断言只能够「欺骗」
TypeScript
编译器,无法避免运行时的错误,反而滥用类型断言可能会导致运行时错误:
TypeScript
编译器信任了我们的断言,故在调用swim()
时没有编译错误,但由于Cat
上并没有swim
方法,就会导致在运行时发生错误。- 🏁:使用类型断言时一定要格外小心,尽量避免断言后调用方法或引用深层属性,以减少不必要的运行时错误。
2.6.2 类型守卫
- 类型守卫主要有以下几种方式:
- typeof:用于判断
number
,string
,boolean
或symbol
四种类型; - instanceof:用于判断一个实例是否属于某个类
- in:用于判断一个属性/方法是否属于某个对象
- typeof:用于判断
typeof
- 可以利用
typeof
实现类型收窄和never
类型的特性做全面性检查,如下面的代码所示:
- 可以看到,在最后的
else
分支里面,我们把收窄为never
的foo
赋值给一个显示声明的never
变量,如果一切逻辑正确,那么这里应该能够编译通过。但是假如后来有一天你的同事修改了Foo
的类型:
- 然而他忘记同时修改
controlFlowAnalysisWithNever
方法中的控制流程,这时候else
分支的foo
类型会被收窄为boolean
类型,导致无法赋值给never
类型,这时就会产生一个编译错误。通过使用never
避免出现新增了联合类型没有对应的实现,我们可以确保controlFlowAnalysisWithNever
方法总是穷尽了Foo
的所有可能类型,从而保证代码的安全性。
instanceof
- 使用
instanceof
运算符收窄变量的类型:
in
- 使用
in
做属性检查:
2.6.3 双重断言
- 当我们要为某个值作类型断言时,我们需要确保编辑器推断出的值的类型和新类型有重叠,否则,无法简单地作类型断言,如下面例子所示:
- 需要知道的是,任何类型都可以被断言为
any
,而any
可以被断言为任何类型。 - 如果我们仍然想使用那个类型,可以使用双重断言:
TypeScript 3.0
中新增了一种unknown
类型,它是一种更加安全的any
的副本。所有东西都可以被标记成是unknown
类型,但是unkonwn
必须在进行类型判断和条件控制之后才可以被分配成其他类型,并且在类型判断和条件控制之前也不能进行任何操作。- 我们上面的例子的双重断言操作比较不合理,仅是为了说明双重断言的效果。
- 🏁:除非迫不得已,千万别用双重断言。
- 我们来看一个比较常见的使用场景:假设我们在一个
TypeScript
项目中,引入了一个JavaScript
编写的库,这个库通过单独的声明文件提供TypeScript
支持,那么就可能存在一种情况:
method()
在.d.ts
中声明的参数类型是SomeType
,但是由于声明文件没有及时更新,它实际还接受另一种类型的参数,比如null
。
- 这种情况下就可以利用
unknown
类型来实现传入null
:
- 这样就可以正常通过编译器的类型校验了。
2.7 善用常量枚举
- 常数枚举是使用
const enum
定义的枚举类型:
- 常数枚举与普通枚举的区别是,前者会在编译阶段被移除,并且不能包含计算成员(即常量枚举成员初始值设定项只能包含文字值和其他计算的枚举值)。
- 上例的编译结果是:
- 假如包含了计算成员,则会在编译阶段报错:
- 普通枚举的值不会在编译阶段计算,而是保留到程序的执行阶段,我们看看下面的例子:
- 上例的编译结果是:
- 可以看到,当我们不需要一个对象,而需要对象的值,就可以使用常数枚举,这样就可以避免在编译时生成多余的代码和间接引用。
2.8 善用高级类型
- 除了
string
、number
、boolean
这种基础类型外,我们还应该了解一些类型声明中的一些高级用法。
2.8.1 类型索引(keyof)
keyof
类似于Object.keys
,用于获取一个接口中 Key 的联合类型:
2.8.2 类型约束(extends)
TypeScript
中的extends
关键词不同于在Class
后使用extends
的继承作用,一般在泛型内使用,它主要作用是对泛型加以约束:
extends
经常与keyof
一起使用,例如我们有一个getValue
方法专门用来获取对象的值,但是这个对象并不确定,我们就可以使用extends
和keyof
进行约束:
- 当传入对象没有的
key
时,编辑器则会报错。
2.8.3 类型映射(in)
in
关键词的作用主要是做类型的映射,遍历已有接口的key
或者是遍历联合类型。以内置的泛型接口Readonly
为例,它的实现如下:
- 它的作用是将接口所有属性变为只读的:
2.8.4 条件类型(U ? X : Y)
- 条件类型的语法规则和三元表达式一致,经常用于一些类型不确定的情况:
- 上面的意思就是,如果
T
是U
的子集,就是类型X
,否则为类型Y
。以内置的泛型接口Extract
为例,它的实现如下:
TypeScript
将使用never
类型来表示不应该存在的状态。上面的意思是,如果 T 中的类型在 U 存在,则返回,否则抛弃。- 假设我们两个类,有三个公共的属性,可以通过
Extract
提取这三个公共属性:
2.8.5 工具范型
TypesScript
中内置了很多工具泛型,前面介绍过Readonly
、Extract
这两种,内置的泛型在TypeScript
内置的lib.es5.d.ts
中都有定义,所以不需要任何依赖就可以直接使用。
- 由于源码直接可以在
lib.es5.d.ts
文件中看到,这里不再进行阐述,下面介绍几种常见的工具范型的作用和使用方法。 Exclude
的作用与之前介绍过的Extract
刚好相反,如果T
中的类型在U
不存在,则返回,否则抛弃。
Partial
用于将一个接口的所有属性设置为可选状态:
Required
的作用刚好与Partial
相反,就是将接口中所有可选的属性改为必须的:
Pick
主要用于提取接口的某几个属性:
Omit
的作用刚好和 Pick 相反,主要用于剔除接口的某几个属性:
3. 总结 📝
TypeScript
十分强大,它增强了编辑器(IDE)的功能,提供了代码补全、接口提示、跳转到定义、代码重构等能力。TypeScript
可以和JavaScript
共存,这意味着JavaScript
项目能够渐进式的迁移到TypeScript
。- 本文介绍了
TypeScript
在项目中的几种常用的实践,希望还没接触TypeScript
或对TypeScript
还不太熟悉的小伙伴赶快在项目实践起来,努力提升代码可维护性和开发幸福感 💪。