如何在项目中用好 TypeScript 🤔

47,409 阅读14分钟

1. 前言 ✍️

  • 我们都知道,JavaScript 是一门非常灵活的编程语言,这种灵活性一方面使得它成为最受欢迎的编程语言,另一方面也使得它的代码质量参差不齐,维护成本高,运行时错误多。
  • TypeScript 是添加了类型系统JavaScript适用于任何规模的项目。TypeScript类型系统在很大程度上弥补了 JavaScript 的缺点。
  • 类型系统按照「类型检查的时机」来分类,可以分为动态类型静态类型
    • 动态类型是指在运行时才会进行类型检查,这种语言的类型错误往往会导致运行时错误,我们熟悉的 JavaScript 即属于动态类型,它是一门解释型语言,没有编译阶段。
    • 静态类型是指编译阶段就能确定每个变量的类型,这种语言的类型错误往往会导致语法错误。由于 TypeScript 在运行前需要先编译为 JavaScript,而在编译阶段就会进行类型检查,所以 TypeScript 属于 静态类型
  • 也许初学者会认为使用 TypeScript 需要写额外的代码,降低开发效率。而他们可能不知道的是,TypeScript 增强了编辑器(IDE)的功能,包括代码补全、接口提示、跳转到定义、代码重构等,这在很大程度上提高了开发效率。TypeScript 的类型系统可以为大型项目带来更高的可维护性,以及更少的 bug。
  • 为了提升开发幸福感,下面将详细介绍如何在项目中用好 TypeScript

2. 在项目中的实践

2.1 善用类型注释

  • 我们可以通过 /** */ 形式的注释为给 TypeScript 类型做标记提示: image.png

  • 当鼠标悬浮在使用到该类型的地方时,编辑器会有更好的提示: image.png

2.2 善用类型扩展

  • TypeScript 中定义类型有两种方式:接口(interface)和类型别名(type alias)。在下面的例子中,除了语法不一样,定义的类型是一样的: image.png

  • 接口和类型别名均可以扩展: image.png

  • 接口和类型别名并不互斥的,也就是说,接口可以扩展类型别名,类型别名也可以扩展接口: image.png

  • TypeScript: Interfaces vs Types 中详细介绍了接口和类型别名的区别,这里不再阐述。

  • 接口和类型别名的选用时机:

    • 在定义公共 API(如编辑一个库)时使用 interface,这样可以方便使用者继承接口;
    • 在定义组件属性(Props)和状态(State)时,建议使用 type,因为 type 的约束性更强;
    • type 类型不能二次编辑,而 interface 可以随时扩展。

2.3 善用声明文件

  • 声明文件必需以 .d.ts 为后缀。一般来说,TypeScript 会解析项目中所有的 *.ts 文件,因此也包含以 .d.ts 结尾的声明文件。 image.png

  • 只要 tsconfig.json 中的配置包含了 typing.d.ts 文件,那么其他所有 *.ts 文件就都可以获得声明文件中的类型定义。

2.3.1 第三方声明文件

  • 当在 TypeScript 项目中使用第三方库时,我们需要引用它的声明文件,才能获得对应的代码补全、接口提示等功能。
  • 针对多数第三方库,社区已经帮我们定义好了它们的声明文件,我们可以直接下载下来使用。一般推荐使用 @types 统一管理第三方库的声明文件,@types 的使用方式很简单,直接用 npmyarn 安装对应的声明模块即可。以 lodash 为例:

image.png

2.3.2 自定义声明文件

  • 当一个第三方库没有提供声明文件时,我们就需要自己书写声明文件。以 antd-dayjs-webpack-plugin 为例,当在 config.ts 中使用 antd-dayjs-webpack-plugin 时,若当编辑器没有找到它的声明文件,则会发生以下报错: image.png

  • 当我们使用 yarn add @types/antd-dayjs-webpack-plugin --dev 尝试解决问题时,出现下面的错误: image.png

  • 也就是找不到该库相关的声明文件(可以在这里搜索你需要的声明文件,找不到则没有)。

  • 为了解决编辑器的报错提示,我们可以采用它提供的另一种方法:添加一个包含 declare module 'antd-dayjs-webpack-plugin'; 的新声明文件。我们也可以不用新增文件,在前面提到的 typing.d.ts 添加下面的内容即可:

image.png

全局变量

  • 当我们需要在多个 ts 文件中使用同一个 TypeScript 类型时,常见做法会在 constant.ts 文件中声明相关类型,并将其 export 出去给其他 ts 文件 import 使用,无疑会产生很多繁琐的代码。
  • 前面提到,只要 tsconfig.json 中的配置包含了我们自定义的声明文件 *.d.ts,则声明文件中的类型定义都能被项目中的 *.ts 文件获取到。因此,我们可以将多个 ts 文件都需要使用的全局类型写在声明文件中,需要使用该类型的 ts 文件不需要 import 就可以直接使用。

命名空间

  • 在代码量较大的情况下,为了避免各种变量名冲突, 可以将相同模块的函数、类、接口等都放置在命名空间内。

image.png

  • ts 文件使用: image.png
  • 由于官方文档已经详细介绍了声明文件的相关内容,这里不再进行阐述,有需要的同学可以自行阅读。

2.4 善用 TypeScript 支持的 JS 新特性

2.4.1 可选链(Optional Chaining)

  • 可选链(Optional Chaining) ?.ES11(ES2020)新增的特性,TypeScript 3.7 支持了这个特性。可选链可以让我们在查询具有多层级的对象时,不再需要进行冗余的各种前置校验:

image.png

  • 否则,直接访问 user.info.getAge() 很容易命中 Uncaught TypeError: Cannot read property...
  • 用了可选链,上面代码会变成:

image.png

  • 可选链是一种先检查属性是否存在,再尝试访问该属性的运算符。TypeScript 在尝试访问 user.info 前,会先尝试访问 user ,只有当 user 既不是 null 也不是 undefined 才会继续往下访问,如果user是 null 或者 undefined,则表达式直接返回 undefined
  • 目前,可选链支持以下语法操作:

image.png

2.4.2 空值合并运算符(Nullish coalescing Operator)

  • 空值合并运算符 ??ES12(ES2021)新增的特性,TypeScript 3.7 支持了这个特性。当左侧的操作数为 null 或者 undefined 时,返回其右侧操作数,否则返回左侧操作数。

image.png

  • 与逻辑或操作符(||) 不同,|| 会在左侧操作数为 falsy 值(例如,'' 或 0)时返回右侧操作数。也就是说,如果使用 || 来为某些变量设置默认值,可能会遇到意料之外的行为:

image.png

2.5 善用访问限定修饰符

  • TypeScript 的类定义允许使用 privateprotected 和 public 这三种访问修饰符声明成员访问限制,并在编译期进行检查:
    • public : 公有类型,在类里面、子类、类外面都可以访问到,如果不加任何修饰符,默认为此访问级别;
    • protected : 保护类型,在类里面、子类里面可以访问,在类外部不能访问;
    • private : 私有类型,只能在当前类内部访问。
  • 如果不加任何修饰符,默认为 public 访问级别:

image.png

  • 上面的代码可以拿到 TypeScript Playground 上去运行,在 JS 区,我们可以看到转义后的 Person 类定义,已经去掉了访问限定修饰符:

image.png

  • 这就意味着,转义后的代码在 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 推断出来的类型不一致,这时我们就可以使用类型断言,其语法如下:

image.png

  • tsx 语法(Reactjsx 语法的 ts 版)中必须使用前者,即 值 as 类型。同时,因为 <> 容易跟泛型语法起冲突,所以建议大家在使用类型断言时,统一使用 值 as 类型 这样的语法。
  • TypeScript 不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型中共有的属性或方法

image.png

  • 而有时候,我们确实需要在还不确定类型的时候就访问其中一个类型特有的属性或方法,如:

image.png

  • 上面的例子中,获取 animal.swim 的时候会报错。此时可以使用类型断言,将 animal 断言成 Fish 类型,就可以解决访问 animal.swim 时报错的问题:

image.png

  • 需要注意的是,类型断言只能够「欺骗」TypeScript 编译器,无法避免运行时的错误,反而滥用类型断言可能会导致运行时错误

image.png

  • TypeScript 编译器信任了我们的断言,故在调用 swim() 时没有编译错误,但由于 Cat 上并没有 swim 方法,就会导致在运行时发生错误。
  • 🏁:使用类型断言时一定要格外小心,尽量避免断言后调用方法或引用深层属性,以减少不必要的运行时错误。

2.6.2 类型守卫

  • 类型守卫主要有以下几种方式:
    • typeof:用于判断 numberstringbooleansymbol 四种类型;
    • instanceof:用于判断一个实例是否属于某个类
    • in:用于判断一个属性/方法是否属于某个对象

typeof

  • 可以利用 typeof 实现类型收窄和 never 类型的特性做全面性检查,如下面的代码所示:

image.png

  • 可以看到,在最后的 else 分支里面,我们把收窄为 neverfoo 赋值给一个显示声明的 never 变量,如果一切逻辑正确,那么这里应该能够编译通过。但是假如后来有一天你的同事修改了 Foo 的类型:

image.png

  • 然而他忘记同时修改 controlFlowAnalysisWithNever 方法中的控制流程,这时候 else 分支的 foo 类型会被收窄为 boolean 类型,导致无法赋值给 never 类型,这时就会产生一个编译错误。通过使用 never 避免出现新增了联合类型没有对应的实现,我们可以确保 controlFlowAnalysisWithNever 方法总是穷尽了 Foo 的所有可能类型,从而保证代码的安全性。

instanceof

  • 使用 instanceof 运算符收窄变量的类型:

image.png

in

  • 使用 in 做属性检查:

image.png

2.6.3 双重断言

  • 当我们要为某个值作类型断言时,我们需要确保编辑器推断出的值的类型和新类型有重叠,否则,无法简单地作类型断言,如下面例子所示:

image.png

  • 需要知道的是,任何类型都可以被断言为 any,而 any 可以被断言为任何类型。
  • 如果我们仍然想使用那个类型,可以使用双重断言:

image.png

  • TypeScript 3.0 中新增了一种 unknown 类型,它是一种更加安全的 any 的副本。所有东西都可以被标记成是 unknown 类型,但是 unkonwn 必须在进行类型判断和条件控制之后才可以被分配成其他类型,并且在类型判断和条件控制之前也不能进行任何操作。
  • 我们上面的例子的双重断言操作比较不合理,仅是为了说明双重断言的效果。
  • 🏁:除非迫不得已,千万别用双重断言。
  • 我们来看一个比较常见的使用场景:假设我们在一个 TypeScript 项目中,引入了一个 JavaScript 编写的库,这个库通过单独的声明文件提供 TypeScript 支持,那么就可能存在一种情况:

image.png

  • method() 在 .d.ts 中声明的参数类型是 SomeType,但是由于声明文件没有及时更新,它实际还接受另一种类型的参数,比如 null

image.png

  • 这种情况下就可以利用 unknown 类型来实现传入 null

image.png

  • 这样就可以正常通过编译器的类型校验了。

2.7 善用常量枚举

  • 常数枚举是使用 const enum 定义的枚举类型:

image.png

  • 常数枚举与普通枚举的区别是,前者会在编译阶段被移除,并且不能包含计算成员(即常量枚举成员初始值设定项只能包含文字值和其他计算的枚举值)。
  • 上例的编译结果是:

image.png

  • 假如包含了计算成员,则会在编译阶段报错:

image.png

  • 普通枚举的值不会在编译阶段计算,而是保留到程序的执行阶段,我们看看下面的例子:

image.png

  • 上例的编译结果是:

image.png

  • 可以看到,当我们不需要一个对象,而需要对象的值,就可以使用常数枚举,这样就可以避免在编译时生成多余的代码和间接引用。

2.8 善用高级类型

  • 除了 stringnumberboolean 这种基础类型外,我们还应该了解一些类型声明中的一些高级用法。

2.8.1 类型索引(keyof)

  • keyof 类似于 Object.keys ,用于获取一个接口中 Key 的联合类型:

image.png

2.8.2 类型约束(extends)

  • TypeScript 中的 extends 关键词不同于在 Class 后使用 extends 的继承作用,一般在泛型内使用,它主要作用是对泛型加以约束:

image.png

  • extends 经常与 keyof 一起使用,例如我们有一个 getValue 方法专门用来获取对象的值,但是这个对象并不确定,我们就可以使用 extends 和 keyof 进行约束:

image.png

  • 当传入对象没有的 key 时,编辑器则会报错。

2.8.3 类型映射(in)

  • in 关键词的作用主要是做类型的映射,遍历已有接口的 key 或者是遍历联合类型。以内置的泛型接口 Readonly 为例,它的实现如下:

image.png

  • 它的作用是将接口所有属性变为只读的:

image.png

2.8.4 条件类型(U ? X : Y)

  • 条件类型的语法规则和三元表达式一致,经常用于一些类型不确定的情况:

image.png

  • 上面的意思就是,如果 TU 的子集,就是类型 X,否则为类型 Y。以内置的泛型接口 Extract 为例,它的实现如下:

image.png

  • TypeScript 将使用 never 类型来表示不应该存在的状态。上面的意思是,如果 T 中的类型在 U 存在,则返回,否则抛弃。
  • 假设我们两个类,有三个公共的属性,可以通过 Extract 提取这三个公共属性:

image.png

2.8.5 工具范型

  • TypesScript 中内置了很多工具泛型,前面介绍过 ReadonlyExtract 这两种,内置的泛型在 TypeScript 内置的 lib.es5.d.ts 中都有定义,所以不需要任何依赖就可以直接使用。

image.png

  • 由于源码直接可以在 lib.es5.d.ts 文件中看到,这里不再进行阐述,下面介绍几种常见的工具范型的作用和使用方法。
  • Exclude 的作用与之前介绍过的 Extract 刚好相反,如果 T 中的类型在 U 不存在,则返回,否则抛弃。

image.png

  • Partial 用于将一个接口的所有属性设置为可选状态:

image.png

  • Required 的作用刚好与 Partial 相反,就是将接口中所有可选的属性改为必须的:

image.png

  • Pick 主要用于提取接口的某几个属性:

image.png

  • Omit 的作用刚好和 Pick 相反,主要用于剔除接口的某几个属性:

image.png

3. 总结 📝

  • TypeScript 十分强大,它增强了编辑器(IDE)的功能,提供了代码补全、接口提示、跳转到定义、代码重构等能力。
  • TypeScript 可以和 JavaScript 共存,这意味着 JavaScript 项目能够渐进式的迁移到 TypeScript
  • 本文介绍了 TypeScript 在项目中的几种常用的实践,希望还没接触 TypeScript 或对 TypeScript 还不太熟悉的小伙伴赶快在项目实践起来,努力提升代码可维护性和开发幸福感 💪。

4. 参考文档

  1. TypeScript 官方文档
  2. 锋利的ES6 — Class语法糖
  3. 类元素 - MDN
  4. 理解 TypeScript 类型收窄