跳到主要内容

TS 概述

备注

TS 是基于 JS 之上的编程语言,解决了 JS 自有类型系统的不足。通过使用 TS 可以帮我们提高代码的可靠程度。

语言类别
  • 通过类型安全的维度区分编程语言,分为强类型和弱类型
    1. 强类型:
      1. 语言层面限制函数的实参类型必须与形参类型相同
      2. 有更强的类型约束
      3. 强类型语言中不允许任意的隐式类型转换
    2. 弱类型:
      1. 弱类型语言层面不会限制实参的类型
      2. 几乎没有什么约束
      3. 弱类型语言中允许任意数据的隐式类型转换
    这种强弱类型之分不是某一个权威机构的定义。强类型与弱类型是从语言的语法层面做出的限制,如果传入类型不同,弱类型会在 编译 阶段进行限制;强类型会在 语言 阶段进行限制
  • 通过类型检查的维度区分编程语言,分为静态类型和动态类型
    1. 静态类型:
      1. 一个变量生命是它的类型就是明确的
      2. 声明过后,它的类型就不允许再修改
    2. 动态类型:
      1. 运行阶段才能够明确变量类型
      2. 变量的类型随时可以改变
      3. 动态类型语言中的变量没有类型,变量中存放的值是有类型的
JS自有类型系统的问题

JavaScript 是一门 弱类型 且 动态类型的语言,缺失了类型系统的可靠性

弱类型的问题

const obj = {}
obj.foo()

如上述代码,我们在书写代码阶段不会存在任何问题,直到代码运行,才会发现问题,若 foo 方法放置在隐晦的地方,无遗给我们的项目埋下了一颗定时炸弹💣

function sum(a, b) {
return a + b
}
sum(100, '100') // 会得到意料之外的结果

强类型的优势

  • 错误更早暴露
  • 代码更智能,编码更准确
  • 重构更牢靠
  • 减少不必要的类型判断
TS 本质

TypeScript 与 JavaScript 本质并无区别,可以将 TypeScipt 理解为是一个添加了类型注解的 JavaScript,比如 const num = 1,它同时符合 TypeScript 和 JavaScript 的语法。

此外,TypeScript 是一门中间语言,最终它还需要转译为纯 JavaScript,再交给各种终端解释、执行。不过,TypeScript 并不会破坏 JavaScript 既有的知识体系,因为它并未创造迥异于 JavaScript 的新语法。

TS 特点

TypeScript 更加可靠

在业务应用中引入 TypeScript 后,当我们收到 Sentry(一款开源的前端错误监控系统)告警,关于“'undefined' is not a function”“Cannot read property 'xx' of null|undefined” 之类的低级错误统计信息基本没有。而这正得益于 TypeScript 的静态类型检测,让至少 10% 的 JavaScript 错误(主要是一些低级错误)能在开发阶段就被发现并解决。

我们也可以这么理解: 在所有操作符之前,TypeScript 都能检测到接收的类型(在代码运行时,操作符接收的是实际数据;静态检测时,操作符接收的则是类型)是否被当前操作符所支持。

当 TypeScript 类型检测能力覆盖到整个文件、整个项目代码后,任意破坏约定的改动都能被自动检测出来(即便跨越多个文件、很多次传递),并提出类型错误。因此,你可以放心地修改、重构业务逻辑,而不用过分担忧因为考虑不周而犯下低级错误。

接手复杂的大型应用时,TypeScript 能让应用易于维护、迭代,且稳定可靠。

面向接口编程

编写 TypeScript 类型注解,本质就是接口设计。

以下是使用 TypeScript 设计的一个展示用户信息 React 组件示例,从中我们一眼就能了解组件接收数据的结构和类型,并清楚地知道如何在组件内部编写安全稳定的 JSX 代码。

interface IUserInfo {
/** 用户 id */
id: number;
/** 用户名 */
name: string;
/** 头像 */
avatar?: string;
}

function UserInfo(props: IUserInfo) {
// ...
}

TypeScript 极大可能改变你的思维方式,从而逐渐养成一个好习惯。比如,编写具体的逻辑之前,我们需要设计好数据结构、编写类型注解,并按照这接口约定实现业务逻辑。这显然可以减少不必要的代码重构,从而大大提升编码效率。

同时,你会更明白接口约定的重要性,也会约束自己/他人设计接口、编写注解、遵守约定,乐此不疲。

TypeScript 已经成为主流

相比竞争对手 Facebook 的 Flow 而言,TypeScript 更具备类型编程的优势,而且还有 Microsoft、Google 这两家国际大厂做背书。

另外,越来越多的主流框架(例如 React、Vue 3、Angular、Deno、Nest.js 等)要么选用 TypeScript 编写源码,要么为 TypeScript 提供了完美的支持。

随着 TypeScript 的普及,TypeScript 在国内(国内滞后国外)成了一个主流的技术方向,国内各大互联网公司和中小型团队都开始尝试使用 TypeScript 开发项目,且越来越多的人正在学习和使用它。

在 TypeScript 中,我们不仅可以轻易复用 JavaScript 的代码、最新特性,还能使用可选的静态类型进行检查报错,使得编写的代码更健壮、更易于维护。比如在开发阶段,我们通过 TypeScript 代码转译器就能快速消除很多低级错误(如 typo、类型等)。

IDE for TypeScript

VS Code 中内置了特定版本的 TypeScript 语言服务,所以它天然支持 TypeScript 语法解析和类型检测,且这个内置的服务与手动安装的 TypeScript 完全隔离。因此,VS Code 支持在内置和手动安装版本之间动态切换语言服务,从而实现对不同版本的 TypeScript 的支持。

如果当前应用目录中安装了与内置服务不同版本的 TypeScript,我们就可以点击 VS Code 底部工具栏的版本号信息,从而实现 “use VS Code's Version” 和 “use Workspace's Version” 两者之间的随意切换。

tsVscode版本切换示意

我们也可以在当前应用目录下的 “.VS Code/settings.json” 内添加命令(如下所示)配置 VS Code 默认使用应用目录下安装的 TypeScript 版本,以便提供语法解析和类型检测服务。

{
"typescript.tsdk": "node_modules/typescript/lib"
}

在实际编写 TypeScript 代码时,我们可以使用“Shift + Command + M”快捷键打开问题面板查看所有的类型错误信息概览,如下图所示:

查看所有的类型错误信息概览图

这里请注意:不同操作系统、不同 VS Code 版本的默认快捷键可能不一致,我们可以点击菜单栏中的“视图(View)| 问题(Problems)” 查看具体快捷键。

当然,VS Code 也基于 TypeScript 语言服务提供了准确的代码自动补全功能,并显示详细的类型定义信息,如下图所示:

自动智能补全功能效果图

除了类型定义之外,TypeScript 语言服务还能将使用 JSDoc 语法编写的结构化注释信息提供给 VS Code,而这些信息将在对应的变量或者类型中通过 hover 展示出来,极大地提升了代码的可读性和开发效率,如下图所示:

JSDoc 信息提示图

我们还可以通过 “Ctrl + `” 快捷键打开 VS Code 内置的命令行工具,以便在当前应用路径下执行各种操作

📢 特别需要注意的是,VS Code 默认使用自身内置的 TypeScript 语言服务版本,而在应用构建过程中,构建工具使用的却是应用路径下 node_modules/typescript 里的 TypeScript 版本。如果两个版本之间存在不兼容的特性,就会造成开发阶段和构建阶段静态类型检测结论不一致的情况,因此,我们务必将 VS Code 语言服务配置成使用当前工作区的 TypeScript 版本


在 Mac 电脑上,如果你习惯使用命令行,可以将 VS Code bin 目录添加到环境变量 PATH 中,以便更方便地唤起它,如下代码所示:

export PATH="$PATH:/Applications/Visual Studio Code.app/Contents/Resources/app/bin"

然后,在 Mac 命令行工具中,我们使用 Vim 编辑“source ~/.bash_profile”即可让配置的环境变量生效。

source ~/.bash_profile

Vim 保存退出后,输入code 应用路径,我们就可以快速打开和编辑指定路径下的应用了。

静态类型检测​

在编译时期,静态类型的编程语言即可准确地发现类型错误,这就是静态类型检测的优势。

在编译(转译)时期,TypeScript 编译器将通过对比检测变量接收值的类型与我们显示注解的类型,从而检测类型是否存在错误。如果两个类型完全一致,显示检测通过;如果两个类型不一致,它就会抛出一个编译期错误,告知我们编码错误,具体示例如下代码所示:

const trueNum: number = 42;
const fakeNum: number = "42"; // ts(2322) Type 'string' is not assignable to type 'number'.

在以上示例中,首先我们声明了一个数字类型的变量trueNum,通过编译器检测后,发现接收值是 42,且它的类型是number,可见两者类型完全一致。此时,TypeScript 编译器就会显示检测通过。

而如果我们声明了一个string类型的变量fakeNum,通过编译器检测后,发现接收值为 "42",且它的类型是number,可见两者类型不一致 。此时,TypeScript 编译器就会抛出一个字符串值不能为数字类型变量赋值的ts(2322) 错误,也就是说检测不通过。

TS 快速开始​

# TS 安装 - 全局安装
npm i -g typescript
# TS 安装 - 项目安装
yarn add typescript --dev
# 查看 TS 版本
tsc -v
# 可以通过安装在 Terminal 命令行中直接支持运行 TypeScript 代码(Node.js 侧代码)的 ts-node 来获得较好的开发体验
npm i -g ts-node
EACCES: permission denied 错误解决

如果你是 Mac 或者 Linux 用户,就极有可能在 npm i -g typescript 中遭遇 “EACCES: permission denied” 错误,此时我们可以通过以下 4 种办法进行解决:

  • 使用 nvm 重新安装 npm
  • 修改 npm 默认安装目录
  • 执行 sudo npm i -g xx
  • 执行 sudo chown -R [user]:[user] /usr/local/lib/node_modules

你可以点击这里了解更多相关建议

Demo 练习

新建一个文件,在文件夹下使用 tsc --init 命令创建一个tsconfig.json 文件,或者在 VS Code 应用窗口新建一个空的 tsconfg.json配置 TypeScript 的行为

为了让 TypeScript 的行为更加严格、简单易懂,要求我们在 tsconfig.json 中开启如下所示设置,该设置将决定了 VS Code 语言服务如何对当前应用下的 TypeScript 代码进行类型检测。

{
"compilerOptions": {
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
"strictFunctionTypes": true, /* Enable strict checking of function types. */
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"alwaysStrict": false, /* Parse in strict mode and emit "use strict" for each source file. */
}
}

然后,我们输入如下所示代码即可新建一个 HelloWorld.ts 文件:

function say(word: string) {
console.log(word);
}

say('Hello, World');

在以上代码中,word 函数参数后边多出来的 “: string” 注解直观地告诉我们,这个变量的类型就是 string。如果你之前使用过其他强类型的语言(比如 Java),就能快速理解 TypeScript 语法。

当然,在当前目录下,我们也可以通过如下代码创建一个同名的 HelloWorld.js 文件,而这个文件中抹掉了类型注解的 TypeScript 代码。

function say(word) {
console.log(word);
}

say('Hello, World');

这里我们可以看到,TypeScript 代码和我们熟悉的 JavaScript 相比,并没有明显的差异。

.ts 文件创建完成后,我们就可以使用 tsc(TypeScript Compiler) 命令将 .ts 文件转译为 .js 文件。

使用 tsc 命令后做的事情:
1. 检查代码中的类型使用异常
2. 移除掉类型注解扩展名 & 转换 EcmaScript 新特性
3. 编译为 js 文件

注意:指定转译的目标文件后,tsc 将忽略当前应用路径下的 tsconfig.json 配置,因此我们需要通过显式设定如下所示的参数,让 tsc 以严格模式检测并转译 TypeScript 代码。

tsc HelloWorld.ts --strict --alwaysStrict false
# 安装在项目中的话,可以使用如下命令
# yarn tsc HelloWorld.ts --strict --alwaysStrict false

同时,我们可以给 tsc 设定一个 watch 参数监听文件内容变更,实时进行类型检测和代码转译,如下代码所示:

tsc HelloWorld.ts --strict --alwaysStrict false --watch

我们也可以直接使用 ts-node 运行 HelloWorld.ts,如下代码所示:

ts-node HelloWorld.ts

运行成功后,ts-node 就会输出如下所示内容:

Hello, World

当然,我们也可以唤起“直接运行”(本质上是先自动进行转译,再运行)TypeScript 的 ts-node 命令行来编写代码,这就跟我们在 Node.js 命令行或者浏览器中调试工具一样。

然后,我们再回车立即执行如下所示代码:

> ts-node
> function say(word: string) {
>  console.log(word);
> }
> say('Hello, World');
Hello, World
undefined

这里请注意:TypeScript 的类型注解旨在约束函数或者变量,在上面的例子中,我们就是通过约束一个示例函数来接收一个字符串类型(string)的参数。

基本语法​

备注

在语法层面,缺省类型注解的 TypeScript 与 JavaScript 完全一致。因此,我们可以把 TypeScript 代码的编写看作是为 JavaScript 代码添加类型注解。

在 TypeScript 语法中,类型的标注主要通过类型后置语法来实现。

🌰 Demo

let num = 1;

Demo 中的语法同时符合 JavaScript 语法和 TypeScript 语法。

而 TypeScript 语法与 JavaScript 语法的区别在于,我们可以在 TypeScript 中显式声明变量num仅仅是数字类型,也就是说只需在变量num后添加: number类型注解即可,如下代码所示:

let num: number = 1;

特殊说明:number表示数字类型,:用来分割变量和类型的分隔符。

同理,我们也可以把:后的number换成其他的类型(比如 JavaScript 原始类型:number、string、boolean、null、undefined、symbol 等),此时,num 变量也就拥有了 TypeScript 同名的原始类型定义。

关于 JavaScript 原始数据类型到 TypeScript 类型的映射关系如下表所示:

JS 原始类型TS类型
stringstring
numbernumber
booleanboolean
nullnull
undefinedundefined
symbolsymbol

TS 应用​

tsconfig.json 配置​

tsconfig.json 是 TypeScript 项目的配置文件。如果一个目录下存在一个 tsconfig.json 文件,那么往往意味着这个目录就是 TypeScript 项目的根目录。

tsconfig.json 包含 TypeScript 编译的相关配置,通过更改编译配置项,我们可以让 TypeScript 编译出 ES6、ES5、node 的代码。

接下来介绍一下 tsconfig.json 中的相关配置选项,并对比较重要的编译选项进行着重介绍

compilerOptions​

编译选项是 TypeScript 配置的核心部分,compilerOptions 内的配置根据功能可以分为 6 个部分。

项目选项​

这些选项用于配置项目的运行时期望、转译 JavaScript 的输出方式和位置,以及与现有 JavaScript 代码的集成级别。

target​

target 选项用来指定 TypeScript 编译代码的目标,不同的目标将影响代码中使用的特性是否会被降级。

target 的可选值包括ES3、ES5、ES6、ES7、ES2017、ES2018、ES2019、ES2020、ESNext这几种。

一般情况下,target 的默认值为ES3,如果不配置选项的话,代码中使用的ES6特性,比如箭头函数会被转换成等价的函数表达式。

module​

module 选项可以用来设置 TypeScript 代码所使用的模块系统。

如果 target 的值设置为 ES3、ES5 ,那么 module 的默认值则为 CommonJS;如果 target 的值为 ES6 或者更高,那么 module 的默认值则为 ES6。

另外,module 还支持 ES2020、UMD、AMD、System、ESNext、None 的选项。

jsx​

jsx 选项用来控制 jsx 文件转译成 JavaScript 的输出方式。该选项只影响.tsx文件的 JS 文件输出,并且没有默认值选项。

  • react: 将 jsx 改为等价的对 React.createElement 的调用,并生成 .js 文件。

  • react-jsx: 改为 __jsx 调用,并生成 .js 文件。

  • react-jsxdev: 改为 __jsx 调用,并生成 .js 文件。

  • preserve: 不对 jsx 进行改变,并生成 .jsx 文件。

  • react-native: 不对 jsx 进行改变,并生成 .js 文件。

incremental​

incremental 选项用来表示是否启动增量编译。incremental 为true时,则会将上次编译的工程图信息保存到磁盘上的文件中。

declaration​

declaration 选项用来表示是否为项目中的 TypeScript 或 JavaScript 文件生成 .d.ts 文件,这些 .d.ts 文件描述了模块导出的 API 类型。

具体的行为可以在Playground中编写代码,并在右侧的 .D.TS 观察输出。

sourceMap​

sourceMap 选项用来表示是否生成sourcemap 文件,这些文件允许调试器和其他工具在使用实际生成的 JavaScript 文件时,显示原始的 TypeScript 代码。

Source map 文件以 .js.map (或 .jsx.map)文件的形式被生成到与 .js 文件相对应的同一个目录下。

lib​

在 ts进阶中我们介绍过,安装 TypeScript 时会顺带安装一个 lib.d.ts 声明文件,并且默认包含了 ES5、DOM、WebWorker、ScriptHost 的库定义。

lib 配置项允许我们更细粒度地控制代码运行时的库定义文件,比如说 Node.js 程序,由于并不依赖浏览器环境,因此不需要包含 DOM 类型定义;而如果需要使用一些最新的、高级 ES 特性,则需要包含 ESNext 类型。

具体的详情你可以在TypeScript 源码中查看完整的列表,并且自定义编译需要的lib类型定义。

严格模式​

TypeScript 兼容 JavaScript 的代码,默认选项允许相当大的灵活性来适应这些模式。

在迁移 JavaScript 代码时,你可以先暂时关闭一些严格模式的设置。在正式的 TypeScript 项目中,推荐开启 strict 设置启用更严格的类型检查,以减少错误的发生。

strict​

开启 strict 选项时,一般我们会同时开启一系列的类型检查选项,以便更好地保证程序的正确性。

strict 为 true 时,一般我们会开启以下编译配置。

  • alwaysStrict:保证编译出的文件是 ECMAScript 的严格模式,并且每个文件的头部会添加 'use strict'。

  • strictNullChecks:更严格地检查 null 和 undefined 类型,比如数组的 find 方法的返回类型将是更严格的 T | undefined。

  • strictBindCallApply:更严格地检查 call、bind、apply 函数的调用,比如会检查参数的类型与函数类型是否一致。

  • strictFunctionTypes:更严格地检查函数参数类型和类型兼容性。

  • strictPropertyInitialization:更严格地检查类属性初始化,如果类的属性没有初始化,则会提示错误。

  • noImplicitAny:禁止隐式 any 类型,需要显式指定类型。TypeScript 在不能根据上下文推断出类型时,会回退到 any 类型。

  • noImplicitThis:禁止隐式 this 类型,需要显示指定 this 的类型。

额外检查​

TypeScript 支持一些额外的代码检查,在某种程度上介于编译器与静态分析工具之间。如果你想要更多的代码检查,可能更适合使用 ESLint 这类工具。

  • noImplicitReturns:禁止隐式返回。如果代码的逻辑分支中有返回,则所有的逻辑分支都应该有返回。

  • noUnusedLocals:禁止未使用的本地变量。如果一个本地变量声明未被使用,则会抛出错误。

  • noUnusedParameters:禁止未使用的函数参数。如果函数的参数未被使用,则会抛出错误。

  • noFallthroughCasesInSwitch:禁止 switch 语句中的穿透的情况。开启 noFallthroughCasesInSwitch 后,如果 switch 语句的流程分支中没有 break 或 return ,则会抛出错误,从而避免了意外的 swtich 判断穿透导致的问题。

模块解析​

模块解析部分的编译配置会影响代码中模块导入以及编译相关的配置。

moduleResolution​

moduleResolution 用来指定模块解析策略。

module 配置值为 AMD、UMD、System、ES6 时,moduleResolution 默认为 classic,否则为 node。在目前的新代码中,我们一般都是使用 node,而不使用classic。

具体的模块解析策略,你可以查看模块解析策略。

baseUrl​

baseUrl 指的是基准目录,用来设置解析非绝对路径模块名时的基准目录。比如设置 baseUrl 为 './' 时,TypeScript 将会从 tsconfig.json 所在的目录开始查找文件。

paths​

paths 指的是路径设置,用来将模块路径重新映射到相对于 baseUrl 定位的其他路径配置。这里我们可以将 paths 理解为 webpack 的 alias 别名配置。

看一个具体的示例:

{
"compilerOptions": {
"paths": {
"@src/*": ["src/*"],
"@utils/*": ["src/utils/*"]
}
}
}

在上面的例子中,TypeScript 模块解析支持以一些自定义前缀来寻找模块,避免在代码中出现过长的相对路径。

注意:因为 paths 中配置的别名仅在类型检测时生效,所以在使用 tsc 转译或者 webpack 构建 TypeScript 代码时,我们需要引入额外的插件将源码中的别名替换成正确的相对路径。

rootDirs​

rootDirs 可以指定多个目录作为根目录。这将允许编译器在这些“虚拟”目录中解析相对应的模块导入,就像它们被合并到同一目录中一样。

typeRoots​

typeRoots 用来指定类型文件的根目录。

在默认情况下,所有 node_modules/@types 中的任何包都被认为是可见的。如果手动指定了 typeRoots ,则仅会从指定的目录里查找类型文件。

types​

在默认情况下,所有的 typeRoots 包都将被包含在编译过程中。

手动指定 types 时,只有列出的包才会被包含在全局范围内,如下示例:

{
"compilerOptions": {
"types": ["node", "jest", "express"]
}
}

在上述示例中可以看到,手动指定 types 时 ,仅包含了 node、jest、express 三个 node 模块的类型包。

allowSyntheticDefaultImports​

allowSyntheticDefaultImports****允许合成默认导出。

当 allowSyntheticDefaultImports 设置为 true,即使一个模块没有默认导出(export default),我们也可以在其他模块中像导入包含默认导出模块一样的方式导入这个模块,如下示例:

// allowSyntheticDefaultImports: true 可以使用
import React from 'react';
// allowSyntheticDefaultImports: false
import * as React from 'react';

在上面的示例中,对于没有默认导出的模块 react,如果设置了 allowSyntheticDefaultImports 为 true,则可以直接通过 import 导入 react;但如果设置 allowSyntheticDefaultImports 为 false,则需要通过 import * as 导入 react。

esModuleInterop​

esModuleInterop 指的是 ES 模块的互操作性。

在默认情况下,TypeScript 像 ES6 模块一样对待 CommonJS / AMD / UMD,但是此时的 TypeScript 代码转移会导致不符合 ES6 模块规范。不过,开启 esModuleInterop 后,这些问题都将得到修复。

一般情况下,在启用 esModuleInterop 时,我们将同时启用 allowSyntheticDefaultImports。

Source Maps​

为了支持丰富的调试工具,并为开发人员提供有意义的崩溃报告,TypeScript 支持生成符合 JavaScript Source Map 标准的附加文件(即 .map 文件)。

sourceRoot​

sourceRoot 用来指定调试器需要定位的 TypeScript 文件位置,而不是相对于源文件的路径。

sourceRoot的取值可以是路径或者 URL。

mapRoot​

mapRoot 用来指定调试器需要定位的 source map 文件的位置,而不是生成的文件位置。

inlineSourceMap​

开启 inlineSourceMap 选项时,将不会生成 .js.map 文件,而是将 source map 文件内容生成内联字符串写入对应的 .js 文件中。虽然这样会生成较大的 JS 文件,但是在不支持 .map 调试的环境下将会很方便。

inlineSources​

开启 inlineSources 选项时,将会把源文件的所有内容生成内联字符串并写入 source map 中。这个选项的用途和 inlineSourceMap 是一样的。

实验选项​

TypeScript 支持一些尚未在 JavaScript 提案中稳定的语言特性,因此在 TypeScript 中实验选项是作为实验特性存在的。

experimentalDecorators​

experimentalDecorators 选项会开启装饰器提案的特性。

目前,装饰器提案在 stage 2 仍未完全批准到 JavaScript 规范中,且 TypeScript 实现的装饰器版本可能和 JavaScript 有所不同。

emitDecoratorMetadata​

emitDecoratorMetadata 选项允许装饰器使用反射数据的特性。

高级选项​

skipLibCheck​

开启 skipLibCheck选项,表示可以跳过检查声明文件。

如果我们开启了这个选项,则可以节省编译期的时间,但可能会牺牲类型系统的准确性。在设置该选项时,推荐值为true**。**

forceConsistentCasingInFileNames​

TypeScript 对文件的大小写是敏感的。如果有一部分的开发人员在大小写敏感的系统开发,而另一部分的开发人员在大小写不敏感的系统开发,则可能会出现问题。

开启此选项后,如果开发人员正在使用和系统不一致的大小写规则,则会抛出错误。

include​

include 用来指定需要包括在 TypeScript 项目中的文件或者文件匹配路径。如果我们指定了 files 配置项,则 include 的 默认值为 [],否则 include 默认值为 ["**/*"] ,即包含了目录下的所有文件。

如果 glob 匹配的文件中没有包含文件的扩展名,则只有 files 支持的扩展名会被包含。

一般来说,include 的默认值为.ts、.tsx 和 .d.ts。如果我们开启了 allowJs 选项,还包括 .js 和 .jsx 文件。

exclude​

exclude 用来指定解析 include 配置中需要跳过的文件或者文件匹配路径。一般来说,exclude 的默认值为 ["node_modules", "bower_components", "jspm_packages"]。

需要注意:exclude配置项只会改变include配置项中的结果。

files​

files 选项用来指定 TypeScript 项目中需要包含的文件列表。

如果项目非常小,那么我们可以使用 files指定项目的文件,否则更适合使用include指定项目文件。

extends​

extends 配置项的值是一个字符串,用来声明当前配置需要继承的另外一个配置的路径,这个路径使用 Node.js 风格的解析模式。TypeScript 首先会加载 extends 的配置文件,然后使用当前的 tsconfig.json 文件里的配置覆盖继承的文件里的配置。

TypeScript 会基于当前 tsconfig.json 配置文件的路径解析所继承的配置文件中出现的相对路径。

常见 TypeScript 错误汇总分析​

常见错误​

TypeScript 错误信息由错误码和详细信息组成。其中,错误码是以“TS”开头 + 数字(一般是 4 位数字)结尾这样的格式组成的字符串,用来作为特定类型错误的专属代号。如果你想查看所有的错误信息和错误码,可以点击TypeScript 源码仓库。当然,随着 TypeScript 版本的更新,也会逐渐增加更多新的类型错误。

下面我们看一下那些常见但在官方文档甚少提及的类型错误。

TS2456​

首先是由于类型别名循环引用了自身造成的 TS2456 类型错误,如下示例:

// TS2456: Type alias 'T' circularly references itself.
type T = Readonly<T>;

在上述示例中,对于 T 这个类型别名,如果 TypeScript 编译器想知道 T 类型是什么,就需要展开类型别名赋值的 Readonly<T>。而为了确定 Readonly<T> 的类型,TypeScript 编译器需要继续判断类型入参 T 的类型,这就形成了一个循环引用。类似函数循环调用自己,如果没有正确的终止条件,就会一直处于无限循环的状态。

当然,如果在类型别名的定义中设定了正确的终止条件,我们就可以使用循环引用的特殊数据结构,如下示例:

type JSON = string | number | boolean | null | JSON[] | { [key: string]: JSON };
const json1: JSON = 'json';
const json2: JSON = ['str', 1, true, null];
const json3: JSON = { key: 'value' };

📢 注意:第 2 个例子只能在 TypeScript 3.7 以上的版本使用,如果版本小于 3.7 仍会提示 TS2456 错误。

TS2554​

形参和实参个数不匹配

function toString(x: number | undefined): string {
 if (x === undefined) {
   return '';
}
 return x.toString();
}

toString(); // TS2554: Expected 1 arguments, but got 0.
toString(undefined);
toString(1);

上面例子报错的原因是,在 TypeScript 中,undefined 是一个特殊的类型。由于类型为 undefined,并不代表可缺省,因此示例中的第 8 行提示了 TS2554 错误。

而可选参数是一种特殊的类型,虽然在代码执行层面上,最终参数类型是 undefined 和参数可选的函数,接收到的入参的值都可以是 undefined,但是在 TypeScript 的代码检查中,undefined 类型的参数和可选参数都会被当作不同的类型来对待,如下示例:

function toString(x?: number): string {
 if (x === undefined) {
   return '';
}
 return x.toString();
}

function toString(x = ''): string {
 return x.toString();
}

因此,如果在编程的过程中函数的参数是可选的,我们最好使用可选参数的语法,这样就可以避免手动传入 undefined 的值,并顺利通过 TypeScript 的检查。

值得一提的是,在 TypeScript 4.1 大版本的更新中,Promise 构造的 resolve 参数不再是默认可选的了,所以如以下示例第 2 行所示,在未指定入参的情况下,调用 resolve 会提示类型错误 (注意:为了以示区分,官方使用了 TS2794 错误码指代这个错误)。

new Promise((resolve) => {
 resolve(); // TS2794: Expected 1 arguments, but got 0. Did you forget to include 'void' in your type argument to 'Promise'?
});

如果我们不需要参数,只需要给 Promise 的泛型参数传入 void 即可,如下示例:

new Promise<void>((resolve) => {
 resolve();
});

在上述示例中,因为我们在第 1 行给泛型类 Promise 指定了 void 类型入参(注意是 void 而不是 undefined),所以在第 3 行调用 resolve 时无须指定入参。

TS1169​

在接口类型定义中由于使用了非字面量或者非唯一 symbol 类型作为属性名

interface Obj {
[key in 'id' | 'name']: any; // TS1169: A computed property name in an interface must refer to an expression whose type is a literal type or a 'unique symbol' type.
};

在上述示例中,因为interface 类型的属性必须是字面量类型(string、number) 或者是 unique symbol 类型,所以在第 2 行提示了 TS1169 错误。

关于接口类型支持的用法如下示例:

const symbol: unique symbol = Symbol();

interface Obj {
[key: string]: any;
[key: number]: any;
[symbol]: any;
}

在上述示例中的第 4~6 行,我们使用了 string、number 和 symbol 作为接口属性,所以不会提示类型错误。

但是,在 type 关键字声明的类型别名中,我们却可以使用映射类型定义属性,如下示例:

type Obj = {
[key in 'id' | 'name']: any;
};

在示例中的第 2 行,我们定义了一个包含 id 和 name 属性的类型别名 Obj。

TS2345​

在传参时由于类型不兼容

enum A {
 x = 'x',
 y = 'y',
 z = 'z',
}
enum B {
 x = 'x',
 y = 'y',
 z = 'z',
}

function fn(val: A) {}
fn(B.x); // TS2345: Argument of type 'B.x' is not assignable to parameter of type 'A'.

如上面的例子所示,函数 fn 参数的 val 类型是枚举 A,在 13 行我们传入了与枚举 A 类似的枚举 B 的值,此时 TypeScript 提示了类型不匹配的错误。这是因为枚举是在运行时真正存在的对象,因此 TypeScript 并不会判断两个枚举是否可以互相兼容。

此时解决这个错误的方式也很简单,我们只需要让这两个枚举类型互相兼容就行,比如使用类型断言绕过 TypeScript 的类型检查,如下示例:

function fn(val: A) {}
fn((B.x as unknown) as A);

在示例中的第 2 行,我们使用了 as 双重类型断言让枚举 B.x 兼容枚举类型 A,从而不再提示类型错误。

TS2589​

泛型实例化递归嵌套过深

type RepeatX<N extends number, T extends any[] = []> = T['length'] extends N
 ? T
: RepeatX<N, [...T, 'X']>;
type T1 = RepeatX<5>; // => ["X", "X", "X", "X", "X"]
// TS2589: Type instantiation is excessively deep and possibly infinite.
type T2 = RepeatX<50>; // => any

在上面的例子中,因为第 1 行的泛型 RepeatX 接收了一个数字类型入参 N,并返回了一个长度为 N、元素都是 'X' 的数组类型,所以第 4 行的类型 T1 包含了 5 个 "X" 的数组类型;但是第 6 行的类型 T2 的类型却是 any,并且提示了 TS2589 类型错误。这是因为 TypeScript 在处理递归类型的时候,最多实例化 50 层,如果超出了递归层数的限制,TypeScript 便不会继续实例化,并且类型会变为 top 类型 any。

对于上面的错误,我们使用 @ts-ignore 注释忽略即可。

TS2322​

一个常见的字符串字面量类型的 TS2322 错误

interface CSSProperties {
display: 'block' | 'flex' | 'grid';
}
const style = {
display: 'flex',
};
// TS2322: Type '{ display: string; }' is not assignable to type 'CSSProperties'.
// Types of property 'display' are incompatible.
// Type 'string' is not assignable to type '"block" | "flex" | "grid"'.
const cssStyle: CSSProperties = style;

如下提供了两种解决这个错误的方法。

// 方法 1
const style: CSSProperties = {
display: 'flex',
};
// 方法 2
const style = {
display: 'flex' as 'flex',
};
// typeof style = { display: 'flex' }

在方法 1 中,显式声明了 style 类型为 CSSProperties,因此变量 style 类型与 cssStyle 期望的类型兼容。

TS2352​

类型收缩特性的 TS2352 类型错误

let x: string | undefined;
if (x) {
x.trim();
setTimeout(() => {
x.trim(); // TS2532: Object is possibly 'undefined'.
});
}
class Person {
greet() {}
}
let person: Person | string;
if (person instanceof Person) {
person.greet();
const innerFn = () => {
person.greet(); // TS2532: Object is possibly 'undefined'.
};
}

在上述示例中的第 1 行,变量 x 的类型是 sting | undefined。在第 3 行的 if 语句中,变量 x 的类型按照之前讲的类型收缩特性应该是 string,可以看到第 4 行的代码可以通过类型检查,而第 6 行的代码报错 x 类型可能是 undefined(因为 setTimeout 的类型守卫失效,所以 x 的类型不会缩小为 string)。

同样,对于第 10 行的变量 person ,我们可以使用 instanceof 将它的类型收缩为 Person,因此第 16 行的代码通过了类型检查,而第 18 行则提示了 TS2352 错误。这是因为函数中对捕获的变量不会使用类型收缩的结果,因为编译器不知道回调函数什么时候被执行,也就无法使用之前类型收缩的结果。

针对这种错误的处理方式也很简单,将类型收缩的代码放入函数体内部即可,如下示例:

let x: string | undefined;
setTimeout(() => {
if (x) {
x.trim(); // OK
}
});
class Person {
greet() {}
}
let person: Person | undefined;
const innerFn = () => {
if (person instanceof Person) {
person.greet(); // Ok
}
};

单元测试​

在单元测试中,我们需要测试的是函数的输出与预计的输出是否相等。在 TypeScript 的类型测试中,我们需要测试的是编写的工具函数转换后的类型与预计的类型是否一致。

我们知道当赋值、传参的类型与预期不一致,TypeScript 就会抛出类型错误,如下示例:

const x: string = 1; // TS2322: Type 'number' is not assignable to type 'string'

在上述示例中可以看到,把数字字面量 1 赋值给 string 类型变量 x 时,会提示 TS2322 错误。

因此,我们可以通过泛型限定需要测试的类型。只有需要测试的类型与预期类型一致时,才可以通过 TypeScript 编译器的检查,如下示例:

type ExpectTrue<T extends true> = T;
type T1 = ExpectTrue<true>;
type T2 = ExpectTrue<null>; // TS2344: Type 'null' does not satisfy the constraint 'true'.

在上面 ExpectTrue 的测试方法中,因为第 1 行预期的类型是 true,所以第 2 行的入参为 true 时不会出现错误提示。但是,因为第 3 行的入参是 null ,所以会提示类型错误。

自 TS 3.9 版本起,官方支持了与 @ts-ignore 注释相反功能的 @ts-expect-error 注释。使用 @ts-expect-error 注释,我们可以标记代码中应该有类型错误的部分。

与 ts-ignore 不同的是,如果下一行代码中没有错误,则会提示 TS2578 的错误,如下示例:

// @ts-expect-error
const x: number = '42';
// TS2578: Unused '@ts-expect-error' directive.
// @ts-expect-error
const y: number = 42;

在上述示例的第 2 行代码处并不会提示类型不兼容的错误,这是因为 @ts-expect-error 注释命令表示下一行应当有类型错误,符合预期。而第 6 行的代码会提示 TS2578 未使用的 @ts-expect-error 命令,这是因为第 6 行的代码没有类型错误。

@ts-expect-error注释命令在编写预期失败的单元测试中很有用处

TS 应用实战

在实际业务中,经常需要使用 Node.js 的场景包括重量级后端应用以及各种 CLI 模块。因此,这一讲我们将引入 TypeScript 开发一个可以指定端口、文件目录、缓存设置等参数的 HTTP 静态文件服务 http-serve CLI NPM 模块。

开发 NPM 模块​

在开发阶段,我们使用 ts-node 直接运行 TypeScript 源码就行。构建时,我们使用官方转译工具 tsc 将 TypeScript 源码转译为 JavaScript,并使用 TypeScript + Jest 做单元测试。

初始化模块​

# 新建文件夹
mkdir http-serve
cd http-serve
# 初始化 package.json
npm init --y
# 创建放 TypeScript 源码的目录
mkdir src
# CLI 命令入口文件
touch src/cli.ts
# CLI 命令入口文件
touch src/http-serve.ts
# 转译工具自动创建放 JavaScript 代码的目录
mkdir lib
# 单元测试文件目录
mkdir __tests__
# 安装 基础 依赖
npm install typescript ts-node jest@24 ts-jest@24 @types/jest -D

这里是 TypeScript 开发模块的一个经典目录结构,极力推荐你使用。

安装完依赖后,我们需要把模块的 main/bin 等参数、start/build/test 等命令写入 package.json 中,如下代码所示:

{
...
"bin": "lib/bin.js",
"main": "lib/http-serve.js",
"files": ["lib"],
"scripts": {
"build": "tsc -p tsconfig.prod.json",
"start": "ts-node src/cli.ts",
"test": "jest --all"
},
...
}

在上述示例第 3 行 bin 参数指定了 CLI 命令可执行文件指向的是转译后的 lib/cli.js;第 4 行 main 参数则指定了模块的主文件是转译后的 lib/http-serve.js;第 5 行指定了发布到 NPM 时包含的文件列表;第 7 行 build 命令则指定了使用 tsc 命令可以基于 tsconfig.prod.json 配置来转译 TypeScript 源码;第 8 行 start 命令则指定了使用 ts-node 可以直接运行 TypeScript 源码;第 9 行 test 命令则表示使用 Jest 可以执行所有单测。

如此配置之后,我们就可以通过以下命令进行构建、开发、单测了。

npm start; // 开发
npm run build; // 构建
npm test; // 单测

初始化 tsconfig​

如果我们已经安装了全局的 TypeScript,那么就可以直接使用全局的 tsc 命令初始化。

当然,我们也可以直接使用当前模块目录下安装的 TypeScript 来初始化 tsconfig 配置。这里我推荐全局安装 npx,可以更方便地调用安装在当前目录下的各种 CLI 工具,如下代码所示:

# 使用全局
tsc --init
# 安装 npx
npm install npx -g
# 或者使用 npx 调用当前目录下 node_modules 目录里安装的 tsc 版本
npx tsc --init

以上命令会在当前目录下创建一个 tsconfig.json 文件用来定制 TypeScript 的行为。

一般来说,我们需要将 declaration、sourceMap 这两个配置设置为 true,这样构建时就会生成类型声明和源码映射文件。此时,即便模块在转译之后被其他项目引用,也能对 TypeScript 类型化和运行环境源码提供调试支持。

此外,一般我们会把 target 参数设置为 es5,module 参数设置为 commonjs,这样转译后模块的代码和格式就可以兼容较低版本的 Node.js 了。

然后,我们需要把 tsc 转译代码的目标目录 outDir 指定为 "./lib"。

除了构建行为相关的配置之外,我们还需要按照如下命令将 esModuleInterop 配置为 true,以便在类型检测层面兼容 CommonJS 和 ES 模块的引用关系,最终适用于 Node.js 开发的 tsconfig。

{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"outDir": "./lib",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

下面我们需要手动创建一个 tsconfig.prod.json,告诉 tsc 在转译源码时忽略 tests 目录。当然,我们也可以根据实际情况把其他文件、目录添加到 exclude 配置中,如下代码所示:

{
"extends": "./tsconfig.json",
"exclude": ["__tests__", "lib"]
}

出于统一和可控性考虑,我们可以将通用的 tsconfig 配置抽离为单独的 NPM 或直接使用第三方封装的配置,再通过 extends 参数进行复用,比如可以安装https://www.npmjs.com/package/@tsconfig/node10等,如下代码所示:

npm install @tsconfig/node10 -D;

在当前模块的 tsconfig.json 中,我们只需保留路径相关的配置即可,其他配置可以继承自 node_modules 中安装的 tsconfig 模块,如下代码所示:

{
"extends": "@tsconfig/node10",
"compilerOptions": {
"baseUrl": ".",
"outDir": "./lib"
}
} 

接下来,我们需要使用 Node.js 内置的 http 模块和第三方 ecstatic、commander 模块实现 http-serve 静态文件服务器

接口设计和编码实现​

安装以下相关依赖

# 会把 Node.js 内置模块类型声明文件作为开发依赖安装
npm install @types/node -D
# commander: CLI 需要用到; ecstatic: 用来处理静态文件请求
npm install commander ecstatic -S

ecstatic 并不是一个对 TypeScript 友好的模块,因为它没有内置类型声明文件,也没有第三方贡献的 @types/ecstatic 类型声明模块。因此,我们需要在项目根目录下新建一个 types.d.ts 用来补齐缺失的类型声明,如下代码所示:

// types.d.ts
declare module 'ecstatic' {
export default (options?: {
root?: string;
baseDir?: string;
autoIndex?: boolean;
showDir?: boolean;
showDotfiles?: boolean;
humanReadable?: boolean;
hidePermissions?: boolean;
si?: boolean;
cache?: string | number;
cors?: boolean;
gzip?: boolean;
brotli?: boolean;
defaultExt?: 'html' | string & {};
handleError?: boolean;
serverHeader?: boolean;
contentType?: 'application/octet-stream' | string & {};
weakEtags?: boolean;
weakCompare?: boolean;
handleOptionsMethod?: boolean;
}) => any;
}

在上述示例中,我们通过 declare module 补齐了 ecstatic 类型声明,这样在引入 ecstatic 的时候就不会再提示一个 ts(2307) 的错误了。同时,IDE 还能自动补全。

很多时候因为类型声明补全的成本较高,所以我们也可以通过一行 “declare module 'ecstatic';”快速绕过 ts(2307) 错误提示。

接下来,我们在src/http-serve.ts中实现主逻辑。

首先,我们约定模块接收的参数及需要对外暴露的接口,如下示例:

export interface IHttpServerOptions {
/** 静态文件目录,默认是当前目录 */
root?: string;
/** 缓存时间 */
cache?: number;
}
/** 对外暴露的方法 */
export interface IHttpServer {
/** 启动服务 */
listen(port: number): void;
/** 关闭服务 */
close(): void;
}

因为这里仅仅需要支持设置文件目录、缓存时间这两个配置项,所以示例第 1~6 行中我们定义的接口类型 IHttpServerOptions 即可满足需求。然后,在第 9~14 行,我们约定了实例对外暴露接收端口参数的 listen 和没有参数的 close 两个方法。

以上定义的接口都可以通过 export 关键字对外导出,并基于接口约定实现主逻辑类 HttpServer,如下代码所示:

export default class HttpServer implements IHttpServer {
private server: http.Server;
constructor(options: IHttpServerOptions) {
const root = options.root || process.cwd();
this.server = http.createServer(ecstatic({
root,
cache: options.cache === undefined ? 3600 : options.cache,
showDir: true,
defaultExt: 'html',
gzip: true,
contentType: 'application/octet-stream',
}));
}
public listen(port: number) {
this.server.listen(port);
}
public close() {
this.server.close();
};
}

在示例中的第 1 行,我们定义了 HttpServer 类,它实现了 IHttpServer 接口约定。在第 15~21 行,我们实现了公共开放的 listen 和 close 方法。在第 2 行,因为 HttpServer 的 server 属性是 http.Server 的实例,并且我们希望它对外不可见,所以被标注为成了 private 属性。

在第 3~13 行,HttpServer 类的构造器函数接收了 IHttpServerOptions 接口约定的参数,并调用 Node.js 原生 http 模块创建了 Server 实例,再赋值给 server 属性。

最后,为了让 TypeScript 代码可以在 ts-node 中顺利跑起来,我们可以在 src/http-serve.ts 引入模块依赖之前,显式地引入手动补齐的缺失的类型声明文件,如下代码所示:

/// <reference path="../types.d.ts" />
import http from 'http';
import ecstatic from 'ecstatic';

在示例中的第 1 行,我们通过相对路径引入了前面定义的 types.d.ts 类型声明。

接下来,我们基于上边实现的 http-serve.ts 和 commander 模块编码实现 src/cli.ts,具体示例如下:

import { program } from 'commander';
import HttpServer, { IHttpServerOptions } from './http-serve';
program
.option('--cache, <cache>', '设置缓存时间,秒数')
.option('--root, <root>', '静态文件目录')
.option('-p, --port, <port>', '监听端口', '3000')
.action((options: Omit<IHttpServerOptions, 'cache'> & { cache?: string; port: string }) => {
const { root, cache, port } = options;
const server = new HttpServer({
root,
cache: cache && parseInt(cache)
});
server.listen(+port);
console.log(`监听 ${port}`);
});
program.parse(process.argv);

在示例中的第 5~7 行,首先我们指定了 CLI 支持的参数(commander 的更多用法可以查看其官方文档)。然后,在第 8 行我们通过 Omit 工具类型剔除了 IHttpServerOptions 接口中的 cache 属性,并重新构造 options 参数的类型。最后,在第 10~14 行我们创建了 HttpServer 的实例,并在指定端口启动了服务侦听。

接下来我们可以通过 npm start 直接运行 src/cli.ts 或通过 npm run build 将 TypeScript 代码转译为 JavaScript 代码,并运行 node lib/cli.js 启动静态服务,浏览器访问服务效果图如下:

ts实现nodejs效果

在实际的开发过程中,我们肯定会碰到各种错误,不可能那么顺利。因此,在定位错误时,我们除了可以结合之前介绍的 TypeScript 常见错误等实用技能之外,还可以通过 VS Code 免转译直接调试源码。

下面我们一起看看如何使用 VS Code 调试源码。

使用 VS Code 调试​

首先,我们需要给当前项目创建一个配置文件,具体操作方法为通过 VS Code 左侧或者顶部菜单 Run 选项添加或在 .vscode 目录中手动添加 launch.json,如图例所示:

vscode添加launch方法

然后,我们将以下配置添加到 launch.json 文件中。

{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "http-serve/cli",
"runtimeArgs": ["-r", "ts-node/register"],
"args": ["${workspaceFolder}/src/cli.ts"]
}
]
}

在上述配置中,我们唤起了 node 服务,并通过预载 ts-node/register 模块让 node 可以解析执行 TypeScript 文件(转译过程对使用者完全透明)。

此时,我们可以在源文件中添加断点,并点击 Run 运行调试,如图例所示:

http调试

TypeScript 并不是万能的,虽然它可以帮助我们减少低级错误,但是并不能取代单元测试。因此,我们有必要介绍一个单元测试的内容。

单元测试​

在项目的根目录下通过如下代码新建一个 jest.config.js 配置。

module.exports = {
collectCoverageFrom: ['src/**/*.{ts}'],
setupFiles: ['<rootDir>/__tests__/setup.ts'],
testMatch: ['<rootDir>/__tests__/**/?(*.)(spec|test).ts'],
testEnvironment: 'node',
testURL: 'http://localhost:4444',
transform: {
'^.+\\.ts$': 'ts-jest'
},
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|ts|tsx)$',
],
moduleNameMapper: {},
moduleFileExtensions: ['js', 'ts'],
globals: {
'ts-jest': {
tsConfig: require('path').join(process.cwd(), 'tsconfig.test.json'),
},
},
};

在配置文件中的第 3 行,我们指定了 setupFiles(需要手动创建 _tests_/setup.ts)初始化单元测试运行环境、加载 polyfill 模块等。在第 4 行,我们指定了查找单测文件的规则。在第 8 行,我们指定了使用 ts-jest 转译 *.ts 文件。在第 16~18 行,我们配置了 ts-jest 基于项目目录下的 tsconfig.test.json 转译为 TypeScript。

一般来说,运行 Node.js 端的模块转译单测代码使用的 tsconfig.test.json 配置和转译生成代码使用的 tsconfig.prod.json 配置完全一样,因此我们可以直接将 tsconfig.prod.json 复制到 tsconfig.test.json。

注意:以上配置文件依赖 jest@24、ts-jest@24 版本。

配置好 Jest 后,我们就可以把 http-serve 模块单元测试编入 __tests__/http-serve.test.ts 中,具体示例如下(更多的 Jest 使用说明,请查看官方文档):

import http from 'http';
import HttpServer from "../src/http-serve";
describe('http-serve', () => {
let server: HttpServer;
beforeEach(() => {
server = new HttpServer({});
server.listen(8099);
});
afterEach(() => {
server.close();
});
it('should listen port', (done) => {
http.request({
method: 'GET',
hostname: 'localhost',
port: 8099,
}).end(() => {
done();
})
});
});

在示例中的第 6~9 行,我们定义了每个 it 单测开始之前,需要先创建一个 HttpServer 实例,并监听 8099 端口。在第 10~12 行,我们定义了每个 it 单测结束后,需要关闭 HttpServer 实例。在第 13~21 行,我们定义了一个单测,它可以通过发起 HTTP 请求来验证 http-serve 模块功能是否符合预期。

注意:源码中使用的路径别名,比如用“@/module”代替“src/sub-directory/module”,这样可以缩短引用路径,这就需要我们调整相应的配置。

处理路径别名​

首先,我们需要在 tsconfig.json 中添加如下所示 paths 配置,这样 TypeScript 就可以解析别名模块。

{
"compilerOptions": {
...,
"baseUrl": "./",
"paths": {
"@/*": ["src/sub-directory/*"]
},
...
}
}

注意:需要显式设置 baseUrl,不然会提示一个无法解析相对路径的错误。

接下来我们在 jest.config.js 中通过如下代码配置相应的规则,告知 Jest 如何解析别名模块。

module.exports = {
...,
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/sub-directory/$1'
},
...
}

因为 tsc 在转译代码的时候不会把别名替换成真实的路径,所以我们引入额外的工具处理别名。此时我们可以按照如下命令安装 tsc-alias 和 tsconfig-paths 分别供 tsc 和 ts-node 处理别名。

npm install tsc-alias tsconfig-paths -D

最后,我们需要修改 package.json scripts 配置,如下代码所示:

{
...,
"scripts": {
"build": "tsc -p tsconfig.prod.json && tsc-alias -p tsconfig.prod.json",
"start": "node -r tsconfig-paths/register -r ts-node/register src/cli.ts",
...
},
...
}

tsc 构建转译之后,第 4 行的 build 命令会使用 tsc-alias 将别名替换成相对路径。在载入 ts-node/register 模块之前,第 5 行会预载 tsconfig-paths/register,这样 ts-node 也可以解析别名了。

当然,除了选择官方工具 tsc 之外,我们也可以选择其他的工具构建 TypeScript 代码,比如说 Rollup、Babel 等

📚 总结​

  1. export 导出模块内的所有必要的类型定义,可以帮助我们减少 ts(4023) 错误。

  2. 我们可以开启 importHelpers 配置,公用 tslib 替代内联 import 等相关 polyfill 代码,从而大大减小生成代码的体积,配置示例如下:

    {
    "extends": "./tsconfig.json",
    "compilerOptions": {
    "importHelpers": true
    },
    "exclude": ["__tests__", "lib"]
    }

    如以上示例第 4 行,配置 importHelpers 为 true,此时一定要把 tslib 加入模块依赖中:

    npm install tslib -S; // 安装 tslib 依赖
  3. 确保 tsconfig.test.json 和 tsconfig.prod.json 中代码转译相关的配置尽可能一致,避免逻辑虽然通过了单测,但是构建之后运行提示错误。

  4. 慎用 import * as ModuleName,因为较低版本的 tslib 实现的 __importStar 补丁有 bug。如果模块 export 是类的实例,经 __importStar 处理后,会造成实例方法丢失。另外一个建议是避免直接 export 一个类的实例,如下代码所示:

    exports = module.exports = new Command(); // bad
  5. 推荐使用完全支持 TypeScript 的 NestJS 框架开发企业级 Node.js 服务端应用。

开发 Web 应用

DOM 原生操作​

实现一个简单的待办管理应用

1. 初始化项目

# 新建文件夹
mkdir todo-web
cd todo-web
# 初始化 package.json
npm init --y
# 创建放 TypeScript 源码的目录
mkdir src
# CLI 命令入口文件
touch src/cli.ts
# CLI 命令入口文件
touch src/http-serve.ts
# 转译工具自动创建放 JavaScript 代码的目录
mkdir lib
# 单元测试文件目录
mkdir __tests__
# 安装 基础 依赖
npm install typescript ts-node jest@24 ts-jest@24 @types/jest -D

安装完依赖后,我们需要把模块的 main/bin 等参数、start/build/test 等命令写入 package.json 中,如下代码所示:

{
...
"bin": "lib/bin.js",
"main": "lib/http-serve.js",
"files": ["lib"],
"scripts": {
"build": "tsc -p tsconfig.prod.json",
"start": "ts-node src/cli.ts",
"test": "jest --all"
},
...
}

初始化 tsconfig

{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"outDir": "./lib",
"rootDir": "./src",
"lib": ["ESNext", "DOM"],
"strict": true,
"alwaysStrict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

注意:因为设置了 target es5,所以这里我们还需要手动引入 ts-polyfill 为新特性打补丁,以兼容较低版本的浏览器。

此外,如果我们想在函数中使用 this,则可以把 alwaysStrict 设置为 false,这样生成的代码中就不会有“use strict”(关闭严格模式)了。

编码实现​

创建一个模型 src/model.ts,用来维护待办数据层的增删操作

class TodoModel {
private gid: number = 0;
public add = () => this.gid++;
public remove = (id: number) => void 0
}
declare var todoModel: TodoModel;
todoModel = new TodoModel;

在上述示例中,我们定义了模型 TodoModel(示例中仅仅实现了架子,你可以按需丰富这个示例),并在第 7~8 行把模型实例赋值给了全局变量 todoModel。

接下来我们开始实现 src/view.ts,用来维护视图层操作 Dom 逻辑

const list = document.getElementById('todo') as HTMLUListElement | null;
const addButton = document.querySelector<HTMLButtonElement>('#add');
addButton?.addEventListener('click', add);
function remove(this: HTMLButtonElement, id: number) {
const todo = this.parentElement;
todo && list?.removeChild(todo) && todoModel.remove(id);
}
function add() {
const id = todoModel.add();
const todoEle = document.createElement('li');
todoEle.innerHTML = `待办 ${id} <button>删除</button>`;
const button = todoEle.getElementsByTagName('button')[0];
button.style.color = 'red';
if (button) {
button.onclick = remove.bind(button, id);
}
list?.appendChild(todoEle);
}

上述示例中,我们在 tsconfig 的 lib 参数中添加了 DOM(如果 lib 参数缺省,则默认包含了 DOM;如果显式设置了 lib 参数,那么一定要添加 DOM),TypeScript 便会自动引入内置的 DOM 类型声明(node_modules/typescript/lib/lib.dom.d.ts),这样所有的 DOM 原生操作都将支持静态类型检测。

在第 1 行,我们把通过 id 获取 HTMLElement | null 类型的元素断言为 HTMLUListElement | null,这是因为 HTMLUListElement 是 HTMLElement 的子类型。同样,第 6 行、12 行、14 行的相关元素都也有明确类型。尤其是第 12 行的 createElement、第 14 行的 getElementsByTagName,它们都可以根据标签名返回更确切的元素类型 HTMLLIElement、HTMLButtonElement。

然后,在第 2 行我们通过给 querySelector 指定了明确的类型入参,其获取的元素类型也就变成了更明确的 HTMLButtonElement。

此外,因为 DOM 元素的 style 属性也支持静态类型检测,所以我们在第 15 行可以把字符串 'red' 赋值给 color。但是,如果我们把数字 1 赋值给 color,则会提示一个 ts(2322) 错误。

接下来,我们就可以转译代码,并新建一个 index.html 引入转译后的 lib/model.js、lib/view.js 中,再使用上文开发的 http-serve CLI 启动服务预览页面。

通过这个简单的例子,我们感受到了 TypeScript 对 DOM 强大的支持,并且官方也根据 JavaScript 的发展十分及时地补齐了新语法特性。因此,即便开发原生应用,TypeScript 也会是一个不错的选择。

React 框架​

React 作为目前非常流行的前端框架,TypeScript 对其支持也是超级完善。在 1.6 版本中,TypeScript 官方专门实现了对 React JSX 语法的静态类型支持,并在 tsconfig 中新增了一个 jsx 参数用来定制 JSX 的转译规则。

而且,React 官方及周边生态对 TypeScript 的支持也越来越完善,比如 create-react-app 支持 TypeScript 模板、babel 支持转译 TypeScript。要知道,在 2018 年我们还需要手动搭建 TypeScript 开发环境,现在通过以下命令即可快速创建 TypeScript 应用,并且还不用过分关心 tsconfig 和开发构建相关的配置,只需把重心放在 React 和 TypeScript 的使用上(坏处则是修改默认配置会比较麻烦)。

npm i create-react-app -g;
create-react-app my-ts-app --template typescript;
cd my-ts-app;
npm start; // 或者 yarn start

Service 类型化​

TypeScript 在 Service 层的应用, 实际就是把 JavaScript 编写的接口调用代码使用 TypeScript 实现。

举个例子, 以下是使用 JavaScript 编写的 getUserById 方法:

export const getUserById = id => fetch(`/api/get/user/by/${id}`, { method: 'GET' });

在这个示例中,除了知道参数名 id 以外,我们对该方法接收参数、返回数据的类型和格式一无所知。

以上示例换成 TypeScript 实现后效果如下:

export const getUserById = (id: number): Promise<{ id: number; name: string }> =>
fetch(`/api/get/user/by/${id}`, { method: 'GET' }).then(res => res.json());
async function test() {
const { id2, name } = await getUserById('string'); // ts(2339) ts(2345)
}

在使用 TypeScript 的示例中,我们可以清楚地知道 getUserById 方法接收了一个不可缺省、number 类型的参数 id,返回的数据是一个异步的包含数字类型属性 id 和字符串类型属性 name 的对象。而且如果我们错误地调用该方法,比如第 5 行解构了一个不存在的属性 id2,就提示了一个 ts(2339) 错误,入参 'string' 类型不匹配也提示了一个 ts(2345) 错误。

通过两个示例的对比,Service 类型化的优势十分明显。

但是,在实际项目中,我们需要调用的接口少则数十个,多则成百上千,如果想通过手写 TypeScript 代码的方式定义清楚参数和返回值的类型结构,肯定不是一件轻松的事情。此时,我们可以借助一些工具,并基于格式化的接口文档自动生成 TypeScript 接口调用代码。

在业务实践中,前后端需要约定统一的接口规范,并使用格式化的 Swagger 或者 YAPI 等方式定义接口格式,然后自动生成 TypeScript 接口调用代码。目前,这块已经有很多成熟、开源的技术方案,例如Swagger Codegen、swagger-typescript-api、Autos、yapi-to-typescript。

此外,对于前后端使用 GraphQL 交互的业务场景,我们也可以使用GraphQL Code Generator等工具生成 TypeScript 接口调用代码。

以上提到的 Service 类型化其实并未与 React 深度耦合,因此我们也可以在 Vue 或者其他框架中使用 TypeScript 手写或者基于工具生成接口调用代码。

Component 类型化​

TypeScript 在 React Component 中的应用

Component 类型化的本质在于清晰地表达组件的属性、状态以及 JSX 元素的类型和结构。

class 组件​

所有的 class 组件都是基于React.Component 和 React.PureComponent 基类创建的,下面我们看一个具体示例:

interface IEProps {
Cp?: React.ComponentClass<{ id?: number }>;
}
interface IEState { id: number; }
const ClassCp: React.ComponentClass<IEProps, IEState> = class ClassCp extends React.Component<IEProps, IEState> {
public state: IEState = { id: 1 };
render() {
const { Cp } = this.props as Required<IEProps>;
return <Cp id={`${this.state.id}`} />; // ts(2322)
}
static defaultProps: Partial<IEProps> = {
Cp: class extends React.Component { render = () => null }
}
}

在示例中的第 5~14 行,因为 React.Component 基类接收了 IEProps 和 IEState 两个类型入参,并且类型化了 class 组件 E 的 props、state 和 defaultProps 属性,所以如果我们错误地调用了组件 props 中 Cp 属性,第 9 行就会提示一个 ts(2322) 错误。

然后我们可以使用接口类型 React.ComponentClass 来指代所有 class 组件的类型。例如在第 5 行,我们可以把 class 组件 ClassCp 赋值给 React.ComponentClass 类型的变量 ClassCp。

但在业务实践中,我们往往只使用 React.ComponentClass 来描述外部组件或者高阶组件属性的类型。比如在示例中的第 2 行,我们使用了 React.ComponentClass 描述 class 组件 E 的 Cp 属性,而不会像第 5 行那样,把定义好的 class 组件赋值给一个 React.ComponentClass 类型的变量。

此外,在定义 class 组件时,使用 public/private 控制属性/方法的可见性,以及使用Readonly 标记 state、props 为只读,都是特别推荐的实践经验。

下面我们看一个具体的示例:

class ClassCpWithModifier extends React.Component<Readonly<IEProps>, Readonly<IEState>> {
private gid: number = 1;
public state: Readonly<IEState> = { id: 1 };
render() { return this.state.id = 2; } // ts(2540)
}

在示例中的第 2 行,如果我们不希望对外暴露 gid 属性,就可以把它标记为 private 私有。

如果我们想禁止直接修改 state、props 属性,则可以在第 1 行中使用 Readonly 包裹 IEProps、IEState。此时,如果我们在第 4 行直接给 state id 属性赋值,就会提示一个 ts(2540) 错误。

函数组件​

我们可以使用类型 React.FunctionComponent(简写为 React.FC)描述函数组件的类型。因为函数组件没有 state 属性,所以我们只需要类型化 props。

下面我们看一个具体的示例:

interface IEProps { id?: number; }
const ExplicitFC: React.FC<IEProps> = props => <>{props.id}</>; // ok
ExplicitFC.defaultProps = { id: 1 } // ok id must be number
const ExplicitFCEle = <ExplicitFC id={1} />; // ok id must be number
const ExplicitFCWithError: React.FC<IEProps> = props => <>{props.id2}</>; // ts(2399)
ExplicitFCWithError.defaultProps = { id2: 1 } // ts(2332)
const thisIsJSX2 = <ExplicitFCWithError id2={2} />; // ts(2332)

在上述示例中,因为我们定义了类型是 React.FC<IEProps> 的组件 ExplicitFC、ExplicitFCWithError,且类型入参 IEProps 可以同时约束 props 参数和 defaultProps 属性的类型,所以第 24 行把 number 类型值赋予接口中已定义的 id 属性可以通过静态类型检测。但是,在第 57 行,因为操作了未定义的属性 id2,所以提示了 ts(2399)、 ts(2332) 错误。

注意:函数组件返回值类型必须是 React.Element 或者 null,反过来如果函数返回值类型是 React.Element 或者 null,即便未显式声明类型,函数也是合法的函数组件。

如以下示例中,因为我们定义了未显式声明类型、返回值分别是 null 和 JSX 的函数 ImplicitFCReturnNull、ImplicitFCReturnJSX,所以第 3 行、第 6 行的这两个组件都可以用来创建 JSX。但是,因为第 8 行定义的返回值类型是 number 的函数 NotAFC,所以被用来创建 JSX 时会在第 9 行提示一个 ts(2786) 错误。

function ImplicitFCReturnNull() { return null; }
ImplicitFCReturnNull.defaultProps = { id: 1 }
const ImplicitFCReturnNullEle = <ImplicitFCReturnNull id={1} />; // ok id must be number
const ImplicitFCReturnJSX = () => <></>;
ImplicitFCReturnJSX.defaultProps = { id2: 1 }
const ImplicitFCReturnJSXEle = <ImplicitFCReturnJSX id2={2} />; // ok
/** 分界线 **/
const NotAFC = () => 1; //
const WithError = <NotAFC />; // ts(2786)

对于编写函数组件而言,显式注解类型是一个好的实践,另外一个好的实践是用 props 解构代替定义 defaultProps 来指定默认属性的值。

此外,组件和泛型 class、函数一样,也是可以定义成接收若干个入参的泛型组件。

以列表组件为例,我们希望可以根据列表里渲染条目的类型(比如说“User”或“Todo”),分别使用不同的视图组件渲染条目,这个时候就需要使用泛型来约束表示条目类型的入参和视图渲染组件之间的类型关系。

下面看一个具体的示例:

export interface IUserItem {
username: string;
}
export function RenderUser(props: IUserItem): React.ReactElement {
return <>{props.username}</>
}
export interface ITodoItem {
taskName: string;
}
export function RenderTodo(props: ITodoItem): React.ReactElement {
return <>{props.taskName}</>
}
export function ListCp<Item extends {}>(props: { Cp: React.ComponentType<Item> }): React.ReactElement {
return <></>;
}
const UserList = <ListCp<IUserItem> Cp={RenderUser} />; // ok
const TodoList = <ListCp<ITodoItem> Cp={RenderTodo} />; // ok
const UserListError = <ListCp<ITodoItem> Cp={RenderUser} />; // ts(2322)
const TodoListError = <ListCp<IUserItem> Cp={RenderTodo} />; // ts(2322)

在示例中的第 13 行,定义的泛型组件 ListCp 通过类型入参 Item 约束接收了 props 的 Cp 属性的具体类型。在第 16 行、第 17 行,因为类型入参 IUserItem、ITodoItem 和 Cp 属性 RenderUser、RenderTodo 类型一一对应,所以可以通过静态类型检测。但是,在第 18 行、第 19 行,因为对应关系不正确,所以提示了一个 ts(2322) 错误。

class 组件和函数组件类型组成的联合类型被称之为组件类型 React.ComponentType,组件类型一般用来定义高阶组件的属性,如下代码所示:

React.ComponentType<P> = React.ComponentClass<P> | React.FunctionComponent<P>;

最后介绍几个常用类型:

  • 元素类型 React.ElementType:指的是所有可以通过 JSX 语法创建元素的类型组合,包括html 原生标签(比如 div、a 等)和 React.ComponentType,元素类型可以接收一个表示 props 的类型入参;
  • 元素节点类型 React.ReactElement:指的是元素类型通过 JSX 语法创建的节点类型,它可以接收两个分别表示 props 和元素类型的类型入参;
  • 节点类型 React.ReactNode:指的是由 string、number、boolean、undefined、null、React.ReactElement 和元素类型是 React.ReactElement 的数组类型组成的联合类型,合法的 class 组件 render 方法返回值类型必须是 React.ReactNode;

在实际业务中,因为组件接收的 props 数据可能来自路由、Redux,所以我们还需要对类型进行更明确的分解。

下面我们看一个具体的示例:

import React from 'react'; 
import { bindActionCreators, Dispatch } from "redux";
import { connect } from "react-redux";
import { RouteComponentProps } from 'react-router-dom';
/** 路由 Props */
type RouteProps = RouteComponentProps<{ routeId: string }>;
/** Redux Store Props */
type StateProps = ReturnType<typeof mapStateToProps>;
function mapStateToProps(state: {}) {
return {
reduxId: 1
};
}
/** Redux Actions Props */
type DispatchProps = ReturnType<typeof mapDispatchToProps>;
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
doSomething: () => void 0
}, dispatch),
};
}
/** 组件属性 */
interface IOwnProps {
ownId: number;
}
/** 最终 Props */
type CpProps = IOwnProps & RouteProps & StateProps & DispatchProps;
const OriginalCp = (props: CpProps) => {
const {
match: { params: { routeId } }, // 路由 Props
reduxId, // Redux Props
ownId, // 组件 Props
actions: {
doSomething // Action Props
},
} = props;
return null;
};
const ConnectedCp = connect<StateProps, DispatchProps, IOwnProps>(mapStateToProps, mapDispatchToProps)(OriginalCp as React.ComponentType<IOwnProps>);
const ConnectedCpJSX = <ConnectedCp ownId={1} />; // ok

在第 7 行,我们定义了 RouteProps,描述的是从路由中获取的属性。在第 9 行获取了 mapStateToProps 函数返回值类型 StateProps,描述的是从 Redux Store 中获取的属性。

在第 16 行,我们获取了 mapDispatchToProps 函数返回值类型 DispatchProps,描述的是 Redux Actions 属性。在第 25 行,我们定义的是组件自有的属性,所以最终组件 OriginalCp 的属性类型 CpProps 是 RouteProps、StateProps、DispatchProps 和 IOwnProps 四个类型的交叉类型。在第 31~38 行,我们解构了 props 中不同来源的属性、方法,并且可以通过静态类型检测.

注意:在示例中的第 41 行,connect 之前,我们把组件 OriginalCp 断言为 React.ComponentType 类型,这样在第 42 行使用组件的时候,就只需要传入 IOwnProps 中定义的属性(因为 RouteProps、StateProps、DispatchProps 属性可以通过路由或者 connect 自动注入)。

这里使用的类型断言是开发 HOC 高阶组件(上边示例中 connect(mapStateToProps, mapDispatchToProps) 返回的是一个高阶组件)的一个惯用技巧,一般我们可以通过划分 HOCProps、IOwnProps 或 Omit 来剔除高阶组件注入的属性,如下示例中的第 4 行、第 5 行。

interface IHOCProps { injectId: number; }
interface IOwnProps { ownId: number; }
const hoc = <C extends React.ComponentType<any>>(cp: C) => cp;
const InjectedCp1 = hoc(OriginalCp as React.ComponentType<IOwnProps>);
const InjectedCp2 = hoc(OriginalCp as React.ComponentType<Omit<IHOCProps & IOwnProps, 'injectId'>>);

Redux 类型化​

使用 Redux 进行状态管理技术方案的类型化

Redux 类型化涉及 state、action、reducer 三要素类型化,具体示例如下:

// src/redux/user.ts
// state
interface IUserInfoState {
userid?: number;
username?: string;
}
export const initialState: IUserInfoState = {};
// action
interface LoginAction {
type: 'userinfo/login';
payload: Required<IUserInfoState>;
}
interface LogoutAction {
type: 'userinfo/logout';
}
export function doLogin(): LoginAction {
return {
type: 'userinfo/login',
payload: {
userid: 101,
username: '乾元亨利贞'
}
};
}
export function doLogout(): LogoutAction {
return {
type: 'userinfo/logout'
};
}
// reducer
export function applyUserInfo(state = initialState, action: LoginAction | LogoutAction): IUserInfoState {
switch (action.type) {
case 'userinfo/login':
return {
...action.payload
};
case 'userinfo/logout':
return {};
}
}

在示例中的第 27 行,我们定义了 state 的详细类型,并在第 829 行分别定义了表示登入、登出的 action 类型和函数,还在第 30~40 行定义了处理前边定义的 action 的 reducer 函数。

然后,我们就将类型化后的 state、action、reducer 合并到 redux store,再通过 react-redux 关联 React,这样组件在 connect 之后,就能和 Redux 交互了。

不过,因为 state、action、reducer 分别类型化的形式写起来十分复杂,所以我们可以借助 typesafe-actions、redux-actions、rematch、dvajs、@ekit/model 等工具更清晰、高效地组织 Redux 代码。

单元测试​

我们可以选择 Jest + Enzyme + jsdom + ReactTestUtils 作为 React + TypeScript 应用的单元测试技术方案,不过麻烦的地方在于需要手动配置 Jest、Enzyme。因此,我更推荐选择react-testing-library这个方案,这也是 create-react-app 默认内置的单元测试方案。

如下示例,我们为前边定义的 RenderUser 组件编写了单元测试。

import React from 'react';
import { render, screen } from '@testing-library/react';
import { RenderUser } from './Cp';
test('renders learn react link', () => {
render(<RenderUser username={'cr'} />);
const linkElement = screen.getByText(/cr/i);
expect(linkElement).toBeInTheDocument();
});

注意:以上介绍的单测执行环境是 Node.js,TypeScript 会被转译成 CommonJS 格式,而在浏览器端运行时,则会被转译成 ES 格式。因此,不同模块之间存在循环依赖时,转译后代码在浏览器端可以正确运行,而在 Node.js 端运行时可能会出现引入的其他模块成员未定义(undefined)的错误。

知识回顾

Number、String、Boolean、Symbol​

首先,我们来回顾一下初学 TypeScript 时,很容易和原始类型 number、string、boolean、symbol 混淆的首字母大写的 Number、String、Boolean、Symbol 类型,后者是相应原始类型的包裹对象,姑且把它们称之为对象类型。

从类型兼容性上看,原始类型兼容对应的对象类型,反过来对象类型不兼容对应的原始类型。

下面我们看一个具体的示例:

let num: number;
let Num: Number;
Num = num; // ok
num = Num; // ts(2322)

在示例中的第 3 行,我们可以把 number 赋给类型 Number,但在第 4 行把 Number 赋给 number 就会提示 ts(2322) 错误。

因此,我们需要铭记不要使用对象类型来注解值的类型,因为这没有任何意义。

object、Object 和 ​

另外,object(首字母小写,03 讲我们介绍过,以下称“小 object”)、Object(首字母大写,以下称“大 Object”)和 (以下称“空对象”)也是容易混淆的类型。

小 object 代表的是所有非原始类型,也就是说我们不能把 number、string、boolean、symbol 原始类型赋值给 object。在严格模式下,null 和 undefined 类型也不能赋给 object。

下面我们看一个具体示例:

let lowerCaseObject: object;
lowerCaseObject = 1; // ts(2322)
lowerCaseObject = 'a'; // ts(2322)
lowerCaseObject = true; // ts(2322)
lowerCaseObject = null; // ts(2322)
lowerCaseObject = undefined; // ts(2322)
lowerCaseObject = {}; // ok

在示例中的第 2~6 行都会提示 ts(2322) 错误,但是我们在第 7 行把一个空对象赋值给 object 后,则可以通过静态类型检测。

大Object 代表所有拥有 toString、hasOwnProperty 方法的类型,所以所有原始类型、非原始类型都可以赋给 Object。同样,在严格模式下,null 和 undefined 类型也不能赋给 Object。

下面我们也看一个具体的示例:

let upperCaseObject: Object;
upperCaseObject = 1; // ok
upperCaseObject = 'a'; // ok
upperCaseObject = true; // ok
upperCaseObject = null; // ts(2322)
upperCaseObject = undefined; // ts(2322)
upperCaseObject = {}; // ok

在示例中的第 24 行、第 7 行都可以通过静态类型检测,而第 56 行则会提示 ts(2322) 错误。

从上面示例可以看到,大 Object 包含原始类型,小 object 仅包含非原始类型,所以大 Object 似乎是小 object 的父类型。实际上,大 Object 不仅是小 object 的父类型,同时也是小 object 的子类型(回想 15 讲中我们实现的判断两个类型是否相等的工具泛型 isEqualV3,其实就是区分不了大 Object 和小 object)。

下面我们还是通过一个具体的示例进行说明。

type isLowerCaseObjectExtendsUpperCaseObject = object extends Object ? true : false; // true
type isUpperCaseObjectExtendsLowerCaseObject = Object extends object ? true : false; // true
upperCaseObject = lowerCaseObject; // ok
lowerCaseObject = upperCaseObject; // ok

在示例中的第 1 行和第 2 行返回的类型都是 true,第3 行和第 4 行的 upperCaseObject 与 lowerCaseObject 可以互相赋值。

注意:尽管官方文档说可以使用小 object 代替大 Object,但是我们仍要明白大 Object 并不完全等价于小 object。

空对象类型和大 Object 一样,也是表示原始类型和非原始类型的集合,并且在严格模式下,null 和 undefined 也不能赋给 ,如下示例:

let ObjectLiteral: {};
ObjectLiteral = 1; // ok
ObjectLiteral = 'a'; // ok
ObjectLiteral = true; // ok
ObjectLiteral = null; // ts(2322)
ObjectLiteral = undefined; // ts(2322)
ObjectLiteral = {}; // ok
type isLiteralCaseObjectExtendsUpperCaseObject = {} extends Object ? true : false; // true
type isUpperCaseObjectExtendsLiteralCaseObject = Object extends {} ? true : false; // true
upperCaseObject = ObjectLiteral;
ObjectLiteral = upperCaseObject;

在示例中的第 8 行和第 9 行返回的类型都是 true,第10 行和第 11 行的 ObjectLiteral 与 upperCaseObject 可以互相赋值,第2~4 行、第 7 行的赋值操作都符合静态类型检测;而第5 行、第 6 行则会提示 ts(2322) 错误。

综上结论:、大 Object 是比小 object 更宽泛的类型(least specific), 和大 Object 可以互相代替,用来表示原始类型(null、undefined 除外)和非原始类型;而小 object 则表示非原始类型。

严格与非严格模式

关于静态类型检测的工作模式,在课程中我们也经常提到严格和非严格模式的区别,实际上最让人困惑的是 strictNullChecks 和 strictFunctionTypes 这两个设置。

strictNullChecks 影响的是 null、undefined 与其他类型的兼容性问题,比如上边提到,开启 strictNullChecks 时,null、undefined 不兼容大、小 object 和 ,但关闭 strictNullChecks 时,它们又是兼容的。

strictFunctionTypes 则影响的是函数类型检测,开启 strictFunctionTypes 时,函数参数是逆变的,而关闭 strictFunctionTypes 时,函数参数则变成了双向协变。

因此,在 TypeScript 的所有项目中使用严格模式(尤其是以上两个配置),实际上可以降低心智成本。*在实*际工作中,我们只需要理解 TypeScript 在严格模式下的特性并严格遵循,代码就是类型安全的。

类型增强​

在 TypeScript 中,如果文件包含顶层的 export 或者 import,则会被当作 module,在 module 中定义的、没有显式 export 的变量、函数、类对外都不可见;相反,如果文件不包含顶层的 export 或者 import,则会被当作 script,script 里的内容(类型声明、变量声明)都是全局可见的(对 module 也是可见的)。

这就是为什么使用同样的语法进行人工补齐类型声明时,有的类型声明在其他的模块、文件中无需显式 import 就可以直接使用,而有的类型声明必须显式 import 之后才可以使用。

需要注意:因为 script 中的内容都是全局可见的,一方面我们应该避免定义过多全局类型,另一方面也要使用足够特性化的唯一标识来命名全局类型,从而避免全局命名污染。

下面看一个具体的示例:

// myAugmention.ts
namespace MyNameSpaceExample {
export type id = number; // 此处非顶层 export
export type name = string;
}
type TSCourseUserInfoName = string;

在示例中的第 2~4 行,我们使用了 namespace 组织 id、name 等比较容易出现命名冲突的类型(namespace 中只有显式 export 的成员才对外可见),然后在第 6 行命名了一个足够特性化的全局 TSCourseUserInfoName。接下来我们就可以在任何其他地方通过类型名、命名空间名 + 类型名访问全局类型。

此外,为了避免其他人在 myAugmention.ts 中添加顶层 export 或者 import,导致 script 变 module、类型全局可见性被破坏,我们可以显式添加描述信息,比如标明“ script 文件,请勿添加顶层 export 或者 import”。***反过***来,我们也可以在 script 中添加“export ”,显式地把 script 改为 module,避免类型全局污染。

如果我们确实想让 module 中的类型全局可见,则可以使用 declare global 声明全局类型,如下示例:

// myGlobalModule.ts
declare global {
type GlobalUserId = number;
}

在示例中的第 2~4 行,我们声明了可以在任何地方访问的全局类型 GlobalUserId(如果你们的示例中只有这么几行代码,肯定访问不到 GlobalUserId)。

下面我们再来回顾一下类型断言的一般性原则。

类型断言

对于复杂类型而言,父子类型可以互相断言;对于原始类型 number、string、boolean 而言,属于同一原始类型的字面量类型以及字面量类型组成的联合类型也可以互相断言,如下示例:

let NumberLiteral1: 1 = 1;
let NumberLiteral2: 2 = 2;
let StringLiterala: 'a' = 'a';
let StringLiteralb: 'b' = 'b';
let MixedLiteral1: typeof NumberLiteral1 \| typeof StringLiterala = 1;
let MixedLiteral2: typeof NumberLiteral2 \| typeof StringLiteralb = 2;
NumberLiteral1 = NumberLiteral2 as 1; // ok
NumberLiteral2 = NumberLiteral1 as 2; // ok
StringLiterala = StringLiteralb as 'a'; // ok
StringLiteralb = StringLiterala as 'b'; // ok
MixedLiteral1 = MixedLiteral2 as typeof MixedLiteral1; // ok
MixedLiteral2 = MixedLiteral1 as typeof MixedLiteral2; // ok

在示例中的第 7~12 行的类型断言虽然都符合静态类型检测,却没有任何实际的意义,并且不安全。

以上是对本专栏的重难点回顾,接下来我们了解一下 TypeScript 最近一段时间新增的重要特性。

TypeScript 新特性和变更

TypeScript 迭代十分活跃,至本课程截稿已经发布了 4.0、4.1、4.2 版本,以下我按照版本号整理了部分新增特性和 break changes。

4.0可变元组​

03 讲中我们学习了元组的定义:元素类型、个数确定的数组即元组,在 TypeScript 4.0 版本中,新引入了两处功能性的变更支持可变元组:

  • 第一个变更是在元组类型的语法中,我们可以对泛型使用展开运算符,如以下示例第 4 行(注意:TypeScript 4.0 以下版本会提示 ts(1256) 错误);
  • 另外一个变更是可以在元组中的任何位置使用剩余元素,如以下示例第 8 行所示。
const TupleA = ['A'] as const;
const TupleB = ['B'] as const;
type TupleType = readonly any[];
function concat\<T extends TupleType, U extends TupleType>(arr1: T, arr2: U): [...T, ...U] { // ts(1256)
return [...arr1, ...arr2]; // ts(2741)
}
const TupleC = concat(TupleA, TupleB); // ['A', 'B']
type ConcatedTuple = [ ...(typeof TupleA), ...(typeof TupleB)];

可变元组的引入,使得我们可以极其方便地实现一些有意思的功能,比如合并两个元组为一个新的元组的函数,在上述示例中的第 7 行,我们调用了第 4~6 行定义的函数 concat 合并元组 ['A'] 和 ['B'] ,从而得到了新的元组类型 ['A', 'B'] 。如果没有可变元组(比如 在 TypeScript 3.9 中),我们就需要给函数 concat 编写 m * n 个重载类型,比如支持长度为 5 的元组合并就需要编写 25 个类型重载。

可变元组还可以极大地提升函数式编程的类型体验和可能性,我们可以在函数组合中使用可变元组约束高阶函数入参和返回值的类型,比如对 JavaScript 内置 bind 方法更好地进行类型检测支持。

4.0 元组元素标签​

另外一个元组相关的变更是我们可以在元组类型的定义中给元素指定标签,让代码更具语义化、可读性,如下示例:

type LabeledTupleType = [id: number, name: string];

在示例中的第 1 行,我们给元组的两个元素分别指定了 id 和 name 的标签。

4.1模板字面量类型​

不得不说,模板字面量类型是 4.1 版本中非常有创造力和想象力的新特性,它使得字符串类型也具备了可变可运算的能力。

我们可以基于已有的字符串字面量衍生出新的字面量类型,也就是说既可以使用模板语法拼接字符串,也可以使用内置工具函数对模板变量进行转换,如下示例:

type PrefixType\<P extends string, N extends string> = `${P}/${Capitalize<string & N>}`;
type UserLoginAction = PrefixType<'User', 'login'>; // 'User/Login'

示例中的第 1 行,因为我们定义了泛型 PrefixType,它可以接收字符串类型入参 P 和 N,并以“P + / + 首字母大写 N ”的格式返回,所以第 2 行入参是 'User' 和 'login' 时,返回的类型是 'User/Login'。

回想一下在 18 讲中介绍的组件和 Redux 类型化中提到的路由属性和 action type,我们就可以基于模板字符串类型实现更全面的类型化。

4.1 映射类型键名重新映射​

4.1 版本另一个重要的特性:在映射类型中,我们可以使用 as 操作符对键名重新映射(可以理解为针对类型的类型断言),如下示例:

type Getters = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserInfoGetters = Getters<{ id: number; name: string; }>

4.2元组头部/中间剩余元素​

在 4.2 版本中,我们可以在元组的任何地方使用剩余元素表达式,而不再仅仅局限于元组的尾部,如下示例:

let prefixRestTuple: [...rest: string[], number] = ['a', 'b', 1];
let middleRestTuple: [boolean, ...rest: string[], number] = [true
,'a', 'b', 1];

在示例中的第 1 行,我们在元组的头部定义了剩余元素。第 2 行,我们在元组的中间位置定义了剩余元素。

4.2 yield 表达式提示 noImplicitAny 错误​

在 TypeScript 的 4.2 版本中,另一个有用而颇具破坏性的特性是,必须显式注解 yield 表达式的返回值类型,否则会提示 noImplicitAny 错误。

这个变更极有可能影响第 18 讲“TypeScript Web 开发”中提到的使用 Redux-saga 管理副作用的 Redux 类型化方案。如果之前我们在 Redux-saga 副作用函数中没有显式指定 yield 表达式返回值类型,那么 TypeScript 升级为 4.2 版本之后就需要重构代码,并补全缺失的返回值类型。

以上就是我觉得有必要单独补充和同步的 TypeScript 官方新特性和变更。

从这些新特性和变更中我们不难发现,实际上所有的变更都是朝着愈发严格而全面的类型安全目标演进的。因此,我们与其被动地重构代码,以兼容愈发严格的静态类型检测,不如从学习使用 TypeScript 之初就启用严格模式,以追求最高标准,养成好习惯。这也是我在整个专栏期间最中肯的建议和最执着的要求,当然也是最好的实践。