16 tsconfig.json 配置:如何定制 TypeScript 的行为?
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
在 13 讲中我们介绍过,安装 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 的类型。
注意:将
strict设置为true,开启严格模式,是本课程极力推荐的最佳实践。
额外检查
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 配置文件的路径解析所继承的配置文件中出现的相对路径。
小结和预告
tsconfig.json 是 TypeScript 项目非常重要的配置文件,这一讲我们着重介绍了编译选项中不同功能的常用选项,更多的 TypeScript 配置可以在TSConfig Reference中查看学习。
你也可以结合这一讲的内容新建项目并更改 tsconfig.json 实践学习。
17 常见 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' };
我们定义了 JSON 数据结构的 TypeScript 类型。其中,就有对类型别名 JSON 自身的循环引用,即示例中出现的 JSON[] | { [key: string]: JSON }。与第 1 个例子不同的是,这里的引用最终可以具体展开为 string | number | boolean | null 类型,所以不会出现无限循环的情况。
注意:第 2 个例子只能在 TypeScript 3.7 以上的版本使用,如果版本小于 3.7 仍会提示 TS2456 错误。
TS2554
另外,我们需要介绍的是比较常见的一个 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
接下来是 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;
};
我们定义了一个包含 id 和 name 属性的类型别名 Obj。
TS2345
接下来我们介绍一下非常常见的 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
接下来我们介绍 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;
CSSProperties 的 display 属性的类型是字符串字面量类型 'block' | 'flex' | 'grid',虽然变量 style 的 display 属性看起来与 CSSProperties 类型完全兼容,但是 TypeScript 提示了 TS2322 类型不兼容的错误。这是因为变量 style 的类型被自动推断成了 { display: string },string 类型自然无法兼容字符串字面量类型 'block' | 'flex' | 'grid',所以变量 style 不能赋值给 cssStyle。
如下提供了两种解决这个错误的方法。
// 方法 1
const style: CSSProperties = {
display: 'flex',
};
// 方法 2
const style = {
display: 'flex' as 'flex',
};
// typeof style = { display: 'flex' }
在方法 1 中,我们显式声明了 style 类型为 CSSProperties,因此变量 style 类型与 cssStyle 期望的类型兼容。在方法 2 中,我们使用了类型断言声明 display 属性的值为字符串字面量类型 'flex',因此 style 的类型被自动推断成了 { display: 'flex' },与 CSSProperties 类型兼容。
TS2352
接下来我要介绍的是一个 TypeScript 类型收缩特性的 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注释命令在编写预期失败的单元测试中很有用处。
小结与预告
这一讲我们介绍了一些 TypeScript 开发中可能遇到的错误码,并分析解析了错误的原因,同时介绍了如何为之前学习的工具类型、自定义函数编写单元测试。
18 使用 TypeScript 开发 Node.js 应用
学习建议:请按照课程中的操作步骤,实践一个完整的开发流程。
在实际业务中,经常需要使用 Node.js 的场景包括重量级后端应用以及各种 CLI 模块。因此,这一讲我们将引入 TypeScript 开发一个可以指定端口、文件目录、缓存设置等参数的 HTTP 静态文件服务 http-serve CLI NPM 模块。
开发 NPM 模块
在开发阶段,我们使用 ts-node 直接运行 TypeScript 源码就行。构建时,我们使用官方转译工具 tsc 将 TypeScript 源码转译为 JavaScript,并使用 TypeScript + Jest 做单元测试。
下面我们先看看如何初始化 NPM 模块。
初始化模块
首先,我们创建一个 http-serve 目录,然后在 VS Code IDE 中打开目录,再使用“ctrl + `”快捷键打开 IDE 内置命令行工具,并执行“npm init”命令初始化 NPM 模块。
npm init
在初始化过程中我们只需要使用默认的模块设置一直回车确认就可以。执行完命令后,NPM 会在当前目录下自动创建一个 package.json。
接下来需要划分项目结构,我们可以通过命令行工具或者 IDE 创建 src 目录用来存放所有的 TypeScript 源码。
TypeScript 转译为 JavaScript 后,lib 目录一般不需要手动创建,因为转译工具会自动创建,此时我们只需要修改 tsconfig.json 中相应的配置即可。
此外,我们还需要按照如下命令手动创建单元测试文件目录 tests。
mkdir src; // 创建放 TypeScript 源码的目录
touch src/cli.ts // CLI 命令入口文件
touch src/http-serve.ts // CLI 命令入口文件
mkdir lib; // 转译工具自动创建放 JavaScript 代码的目录
mkdir __tests__; // 单元测试文件目录
这里是 TypeScript 开发模块的一个经典目录结构,极力推荐你使用。
接下来我们可以按照如下命令先行安装项目需要的基本依赖。
npm install typescript -D;
npm install ts-node -D;
npm install jest@24 -D;
npm install ts-jest@24 -D;
npm install @types/jest -D;
在上述命令中,TypeScript、ts-node、Jest、Jest 类型声明是作为开发依赖 devDependencies 安装的。
安装完依赖后,我们需要把模块的 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 配置。
初始化 tsconfig
如果我们已经安装了全局的 TypeScript,那么就可以直接使用全局的 tsc 命令初始化。
当然,我们也可以直接使用当前模块目录下安装的 TypeScript 来初始化 tsconfig 配置。这里我推荐全局安装 npx,可以更方便地调用安装在当前目录下的各种 CLI 工具,
tsc --init; // 使用全局
npm install npx -g; // 安装 npx
npx tsc --init; // 或者使用 npx 调用当前目录下 node_modules 目录里安装的 tsc 版本
以上命令会在当前目录下创建一个 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"]
}
注意:在实际项目中,我们并不经常使用 tsc --init 初始化 tsconfig。
出于统一和可控性考虑,我们可以将通用的 tsconfig 配置抽离为单独的 NPM 或直接使用第三方封装的配置,再通过 extends 参数进行复用,比如可以安装www.npmjs.com/package/@ts…等,如下代码所示:
npm install @tsconfig/node10 -D;
在当前模块的 tsconfig.json 中,我们只需保留路径相关的配置即可,其他配置可以继承自 node_modules 中安装的 tsconfig 模块,
{
"extends": "@tsconfig/node10",
"compilerOptions": {
"baseUrl": ".",
"outDir": "./lib"
}
}
任务:请将你惯用的 tsconfig 配置抽离为公共可复用的 NPM 模块,然后发布到 NPM 中,并在示例里引入。
接下来,我们需要使用 Node.js 内置的 http 模块和第三方 ecstatic、commander 模块实现 http-serve 静态文件服务器。
接口设计和编码实现
首先,我们需要安装以下相关依赖。
npm install @types/node -D;
npm install commander -S;
npm install ecstatic -S;
以上命令第 1 行会把 Node.js 内置模块类型声明文件作为开发依赖安装,第 2 行安装的是 CLI 需要用到的 commander,第 3 行安装的是用来处理静态文件请求的 ecstatic。
不幸的是,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) 错误提示。
注意:在业务实践中,如果碰到某个模块缺失类型声明文件,则会提示一个 ts(2307) 的错误,此时我们可以先尝试通过 npm i @types/模块名 -D 安装可能存在的第三方补齐类型声明。如果找不到,再通过 declare module 手动补齐。
接下来,我们在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 启动静态服务,浏览器访问服务效果图如下:
在实际的开发过程中,我们肯定会碰到各种错误,不可能那么顺利。因此,在定位错误时,我们除了可以结合之前介绍的 TypeScript 常见错误等实用技能之外,还可以通过 VS Code 免转译直接调试源码。
下面我们一起看看如何使用 VS Code 调试源码。
使用 VS Code 调试
首先,我们需要给当前项目创建一个配置文件,具体操作方法为通过 VS Code 左侧或者顶部菜单 Run 选项添加或在 .vscode 目录中手动添加 launch.json,
我们将以下配置添加到
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 运行调试,
TypeScript 并不是万能的,虽然它可以帮助我们减少低级错误,但是
并不能取代单元测试。因此,我们有必要介绍一个单元测试的内容。
单元测试
一个健壮的项目往往离不开充分的单元测试,接下来我们将学习如何使用 TypeScript + Jest 为 http-serve 模块编写单测。
在前面的步骤中,我们已经安装了 Jest 相关的依赖,并且配置好了 npm run test 命令,此时可以在项目的根目录下通过如下代码新建一个 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 -D;
npm install 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 等,因篇幅有限,这里就不做深入介绍了。
小结和预告
以上就是使用 TypeScript 开发一个简单静态文件服务 NPM 模块的全过程,我们充分利用了 TypeScript 生态中的各种工具和特性。
关于如何开发基于 TypeScript 的 Node.js 模块和服务,我在下面也总结了一些建议。
- export 导出模块内的所有必要的类型定义,可以帮助我们减少 ts(4023) 错误。
- 我们可以开启 importHelpers 配置,公用 tslib 替代内联 import 等相关 polyfill 代码,从而大大减小生成代码的体积,配置示例如下:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"importHelpers": true
},
"exclude": ["__tests__", "lib"]
}
如以上示例第 4 行,配置 importHelpers 为 true,此时一定要把 tslib 加入模块依赖中:
npm install tslib -S; // 安装 tslib 依赖
- 确保 tsconfig.test.json 和 tsconfig.prod.json 中代码转译相关的配置尽可能一致,避免逻辑虽然通过了单测,但是构建之后运行提示错误。
- 慎用 import * as ModuleName,因为较低版本的 tslib 实现的 __importStar 补丁有 bug。如果模块 export 是类的实例,经 __importStar 处理后,会造成实例方法丢失。另外一个建议是避免直接 export 一个类的实例,如下代码所示:
exports = module.exports = new Command(); // bad
- 推荐使用完全支持 TypeScript 的 NestJS 框架开发企业级 Node.js 服务端应用。
思考题:请对这一讲中的静态文件服务示例进行改造,并为 HttpServer 类及 CLI 添加更多的可配置项,然后通过 VS Code 源码调试及其他章节的经验解决改造过程中碰到的问题。
19 使用 TypeScript 开发 Web 应用的最佳实践
这一讲我们将从 DOM 原生操作和 React 框架这两个方面学习 Web + TypeScript 开发实践。
学习建议:请按照这一讲中的操作步骤,实践一个完整的开发流程。
DOM 原生操作
无论我们使用前端框架与否,都免不了需要使用原生操作接口,因此将 TypeScript 与 DOM 原生操作组合起来进行学习很有必要。
接下来,我们通过手写一个简单的待办管理应用来熟悉常见的操作接口。
配置项目
首先,我们可以参照 18 讲中初始化 Node.js 模块的步骤创建一个 todo-web 项目,并安装 TypeScript 依赖。
然后,我们可以按需调整 lib 和 alwaysStrict 参数配置 tsconfig,
{
"compilerOptions": {
...,
"target": "es5",
"lib": ["ESNext", "DOM"],
"strict": true,
"alwaysStrict": false,
...
}
}
在以上配置的第 4 行,我们设置了 tagert 参数是“es5”。在第 5 行,我们设置了 lib 参数为 "ESNext" 和 "DOM"。这样,我们就可以在 TypeScript 中使用最新的语言特性了(比如 Promise.any 等)。
注意:因为设置了 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 中,再使用 19 讲中开发的 http-serve CLI 启动服务预览页面。
通过这个简单的例子,我们感受到了 TypeScript 对 DOM 强大的支持,并且官方也根据 JavaScript 的发展十分及时地补齐了新语法特性。因此,即便开发原生应用,TypeScript 也会是一个不错的选择。
接下来,我们将学习 TypeScript 与前端主流框架 React 的搭配使用。
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、Component、状态管理这三个分层介绍 TypeScript 在 React App 开发中的实践。
Service 类型化
首先我们介绍的是 TypeScript 在 Service 层的应用,称之为 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 手写或者基于工具生成接口调用代码。
接下来我们将学习 TypeScript 在 React Component 中的应用,将其称之为 Component 类型化。
Component 类型化
Component 类型化的本质在于清晰地表达组件的属性、状态以及 JSX 元素的类型和结构。
注意:TypeScript 中有专门的 .tsx 文件用来编写 React 组件,并且不能使用与 JSX 语法冲突的尖括号类型断言(“<类型>”)。此外,我们还需要确保安装了 @types/react、@types/react-dom 类型声明,里边定义了 React 和 ReactDOM 模块所有的接口和类型。
我们首先了解一下最常用的几个接口和类型。
(1)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 属性的类型,所以第 2-4 行把 number 类型值赋予接口中已定义的 id 属性可以通过静态类型检测。但是,在第 5~7 行,因为操作了未定义的属性 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;
- JSX 元素类型 JSX.Element:指的是元素类型通过 JSX 语法创建的节点类型,JSX.Element 等于 React.ReactElement<any, any>。
以上就是 React Component 相关的类型及简单的类型化。
在实际业务中,因为组件接收的 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 中不同来源的属性、方法,并且可以通过静态类型检测。
这里插播一道思考题:以上示例会提示一个缺少 react-redux、react-router-dom 类型声明的错误,应该如何解决呢?
注意:在示例中的第 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'>>);
组件类型化还涉及 Hooks 等知识点,限于篇幅,本文就不继续展开了。
接下来我们简单了解一下使用 Redux 进行状态管理技术方案的类型化,将其称之为 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 {};
}
}
在示例中的第 2-7 行,我们定义了 state 的详细类型,并在第 8-29 行分别定义了表示登入、登出的 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 代码。限于篇幅,这里就不做深入介绍了,你可以自行到www.npmjs.com/上查看更多信息。
单元测试
我们可以选择 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={'ww'} />);
const linkElement = screen.getByText(/ww/i);
expect(linkElement).toBeInTheDocument();
});
注意:以上介绍的单测执行环境是 Node.js,TypeScript 会被转译成 CommonJS 格式,而在浏览器端运行时,则会被转译成 ES 格式。因此,不同模块之间存在循环依赖时,转译后代码在浏览器端可以正确运行,而在 Node.js 端运行时可能会出现引入的其他模块成员未定义(undefined)的错误。
小结和预告
以上就是 TypeScript 和 Dom 原生操作及结合 React 框架在 Web 侧开发的实践建议,其核心在于类型化 Dom API 和 React 组件、Redux 和 Service。
思考题:类型化 React 组件的要义是什么?
20 如何将 JavaScript 应用切换至 TypeScript?
从 JavaScript 进化到 TypeScript,也就意味着需要大量的迁移、重构操作。因此,接下来我们将学习将 JavaScript 技术栈项目迁移到 TypeScript 的操作步骤和实用技巧。
迁移步骤
调整项目结构
首先,我们可以参照 18 讲和 19 讲的内容调整项目结构,比如使用 src 目录组织源码,typings 目录组织类型声明定义,lib 目录作为 Node.js 模块的构建产物,build 目录作为 Web 项目的构建产物。
然后,我们需要在项目根目录下创建一个 tsconfig.json,让源码和单测共享一个配置文件。
因为如今大多数的 JavaScript 项目都是基于 ES6+ 组织源码,再转译为 JavaScript,其项目结构基本可以划分为如下所示,所以我们只需要创建一个 tsconfig.json 即可。
JavaScript2TypeScriptProject
├── src
│ ├── a.js
│ └── b.js
├── build 或则 lib
├── typings
├── package.json
└── tsconfig.json
配置 tsconfig
在正式讲解之前,我们先插播一道思考题:Node.js 项目需要如何配置?
提示信息:区别仅在于 Node.js 项目需要指定
rootDir、outDir。
以配置 React Web 项目为例,为了尽可能少改动源码、让项目正常运行起来,我们不要一步到位开启严格模式,而应该尽量宽松地配置 tsconfig,如下配置所示。
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"typeRoots": ["node_modules/@types", "./typings"]
},
"include": [ "src", "typings" ]
}
其中,比较重要的配置项分为如下 5 个。
- 第 3 行配置
“target”为 "es5",用来将 TypeScript 转译为低版本、各端兼容性较好的 ES5 代码。 - 第 9 行开启的
allowJs,它允许 JavaScript 和 TypeScript 混用,这使得我们可以分批次、逐模块地迁移代码。 - 第 20 行我们把
typings目录添加到类型查找路径,让 TypeScript 可以查找到自定义类型声明,比如为缺少类型声明的第三方模块补齐类型声明。 - 第 22 行我们把 src 和 typings 目录添加到 TypeScript 需要识别的文件中(也可以按照实际需要添加其他目录或者文件,比如说独立的单测文件目录 tests)。
- 因为是 React Web 项目,所以我们还需要在第 19 行将“jsx”配置为“react”。
注意:因为 Web 项目中不会直接使用 tsc 转译 TypeScript,所以我们无需配置 rootDir、outDir,甚至可以开启 noEmit 配置(如上边配置第 18 行所示,开启该配置 tsc 不会生成转译产物)。
接下来,我们需要结合项目所使用的构建工具集成 TypeScript 构建环境。
构建工具集成 TypeScript
下面我们以非常常见的构建工具 Webpack 集成 TypeScript 为例。
首先我们需要安装如下所示依赖,比如所有用到的第三模块类型声明(通过“npm i -D @types/模块名”进行安装)以及需要用来加载并转译 TypeScript 代码的 Webpack Loader。
npm install -D typescript;
npm install -D @types/react;
npm install -D @types/react-dom;
... // 其他必要依赖
npm install -D ts-loader;
然后,我们选择 ts-loader 作为 TypeScript 加载器,并在 webpack.config.js 配置文件中添加 resolve 和 module 规则,如下配置所示:
module.exports = {
// 其他配置 ...,
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
module: {
rules: [
// 其他配置 loader 规则...,
{
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: { transpileOnly: true }
}
]
}
],
},
plugins: [
// ...其他配置
new require('fork-ts-checker-webpack-plugin')({
async: false,
tsconfig: '...' // tsconfig.json 文件地址
});
]
// 其他配置...
};
首先,我们在第 4 行的 extensions 配置中添加了 .ts、.tsx 文件后缀名,是为了让 Webpack 在解析模块的时候同时识别 TypeScript 文件。
注意:因为 Webpack 是以从左到右的顺序读取 extensions 配置并查找文件,所以按照如上配置,当碰到模块同名的情况,Webpack 将优先解析到 TypeScript 模块。
然后,我们在 17~19 行的 rules 配置中添加了 ts-loader,是为了让 Webpack 使用 ts-loader 加载和转译 .ts、.tsx 文件。
一个比较好的实践是,我们可以开启 ts-loader 的 transpileOnly 配置,让 ts-loader 在处理 TypeScript 文件时,只转译而不进行静态类型检测,这样就可以提升构建速度了。不过,这并不意味着构建时静态检测不重要,相反这是保证类型安全的最后一道防线。此时,我们可以通过其他性能更优的插件做静态类型检测。
最后,我们在第 22 行引入了 fork-ts-checker-webpack-plugin 专门对 TypeScript 文件进行构建时静态类型检测(可以通过如下命令,安装该插件)。这样,只要出现任何 TypeScript 类型错误,构建就会失败并提示错误信息。
我们可以通过如下命令安装 fork-ts-checker-webpack-plugin 插件。
npm install -D fork-ts-checker-webpack-plugin;
实际上,静态类型检测确实会耗费性能和时间,尤其是项目特别庞大的时候,这个损耗会极大地降低开发体验。此时,我们可以根据实际情况优化 Webpack 配置,比如仅在生产构建时开启静态类型检测、开发构建时关闭静态类型检测,这样既可以保证开发体验,也能保证生产构建的安全性。
除了使用 ts-loader 之外,现在我们也可以使用版本号大于 7 的 babel-loader 作为 TypeScript 的加载器。
具体操作:首先,我们可以通过如下命令安装处理 TypeScript 的 babel preset。
// npm i -D babel-loader; // 确保安装版本 > 7
npm i -D @babel/preset-typescript;
注意:因为 React Web 项目必然已经安装了 babel-loader(必须依赖),所以我们不用重新安装 babel-loader,只需确保 babel-loader 的版本号大于 7 即可。
然后,我们在 webpack.config.js 中添加支持 TypeScript 的配置,
module.exports = {
// 其他配置 ...
resolve:
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"]
},
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
use: ['babel-loader']
},
// ...其他配置
]
},
// ...其他配置
};
在以上配置中的第 4 行、第 9 行,我们配置并使用了 babel-loader 来转换 .ts、.tsx 文件。
最后,我们在 babel 配置文件中添加了如下所示的 typescript presets(参见第 5 行)。
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
['@babel/preset-typescript', { allowNamespaces: true }]
],
// ...其他配置
}
注意:因为每个项目中使用的模板不同,所以 babel 配置项可能在 .babelrc、babel.config.js 单独的配置文件中或者内置在
package.json中。
这样,babel-loader 就可以加载并转换 TypeScript 代码了。
需要注意:因为 babel-loader 也是只对 TypeScript 代码做转换,而不进行静态类型检测,所以我们同样需要引入 fork-ts-checker-webpack-plugin 插件做静态类型检测。
配置好构建工具后,接下来需要迁移 JavaScript 代码,我把这个过程形容为“愚公移山”。
愚公移山
为什么形容为“愚公移山”?因为将 JavaScript 迁移到 TypeScript 是一项个没有太大技术含量的体力活,同时也是一项长久、渐进的过程。
迁移 JavaScript 代码的具体操作:首先,我们需要逐个将 .js 文件重名为 .ts、.jsx 文件重名为 .tsx,比如将项目的主入口文件 index.js 改成 index.ts(相应的 webpack.config.js 也需要更改)。然后,我们启动本地服务(npm start)。
不出意外的话,IDE(比如我们推荐的 VS Code)和 fork-ts-checker-webpack-plugin 都会提示 index.ts 有 N 个各式各样的类型错误。
如果我们希望前期始于一个比较高且好的起点,比如在 tsconfig.json 中配置 noImplicitAny 为 true(禁用隐式 any),这样就会提示更多的类型错误。
注意:作为过来人,建议你在 tsconfig.json 的配置上一步到位开启 strict 严格模式。一方面因为我们的课程是基于严格模式编写的,学以致用,另一方面是为了后续无需重复迁移过程,一步到位。当然,你也可以根据项目的实际诉求,选择开启严格模式一步到位或宽松配置 tsconfig。
解决错误
接下来我们要做的事情就是综合利用前面课程的知识(例如 17 讲中介绍的较为常见的错误和分析),逐个解决迁移后的 TypeScript 文件中的各种类型错误。
缺少类型注解
我们看到的第一个错误大概率是缺少某个模块的类型声明文件 ts(7016),比如说缺少路由组件 react-router-dom 的类型声明。
此时,我们可以先通过以下命令尝试安装 DefinitelyTyped 上可能存在的类型声明依赖。
npm i -D @types/react-router-dom;
如果命令执行成功,则说明类型声明存在,并且安装成功,这也意味着我们快速且低成本地解决了一个错误。如果 DefinitelyTyped 上恰好没有定义好的依赖类型声明,那么我们就需要自己解决这个问题了。
回想一下 18 讲中是如何解决依赖的 ecstatic 模块缺少类型声明问题的,首先我们需要频繁使用 declare module 补齐类型声明。然后,我们将各种补齐类型声明的文件统一放在 typings 目录中,比如示例 1 中自定义的 jQuery.d.ts(注意:DefinitelyTyped 有 jQuery 类型定义),示例 2 中声明的静态资源 svg、png、jpg、gif 文件模块的 images.d.ts。
示例 1
// jQuery.d.ts
declare module 'jQuery';
示例 2
// images.d.ts
declare module '*.svg';
declare module '*.png';
declare module '*.jpg';
declare module '*.gif';
关于全局变量、属性缺少类型定义的错误,我们也可以使用 declare 或者扩充相应的接口类型进行解决。
首先我们可以创建一个global.d.ts补齐缺少的类型声明,
declare var $: any;
interface Window {
__REDUX_DEVTOOLS_EXTENSION__: any;
}
interface NodeModule {
hot?: {
accept: (id: string, callback: (...args: any) => void) => void;
};
}
在示例中的第 1 行,我们声明了全局变量 的 ts(2581) 错误。在第 2~4 行,我们扩充了 Window 接口,从而解决了访问 window.REDUX_DEVTOOLS_EXTENSION 时提示属性不存在的 ts(2339) 错误。然后在第 5 到第 9 行,我们扩充了 NodeModule 接口,从而解决了调用 module.hot.accept 方法时提示的 ts(2339) 错误。
注意:不要在 global.d.ts 内添加顶层的 import 或者 export 语句。
思考题:回忆一下 TypeScript 中
script 和 module 的区别。
隐式 any
接下来就是大量函数参数具有隐式 any 类型的 ts(7006) 错误,此时我们需要给所有函数添加类型注解。在解决这些错误时,如果我们结合 05 讲的知识(比如可选参数、剩余参数函数和函数重载)将会得心应手。
一个好的实践建议:如果我们确实需要暂时使用万金油类型 any 来绕过静态类型检测,则可以声明一个具有特殊含义的全局类型 AnyToFix 来代替 any。比如我们可以在 global.d.ts 内添加如下所示的 AnyToFix 类型别名定义。
/** 需要替换成更明确的类型 */
type AnyToFix = any;
这样,我们就可以在任何地方使用 AnyToFix 替代 any ,比如下图中的 func 函数参数 arg 的类型就是 AnyToFix。并且在条件成熟时,我们可以很方便地筛选出需要类型重构的 func 函数,然后将其参数类型修改为更明确的类型。
动态类型
另一类极有可能出现的错误是 JavaScript 动态类型特性造成的。
如下示例第 1~3 行所示,我们习惯先定义一个空对象,再动态添加属性,迁移到 TypeScript 后就会提示一个对象上属性不存在的 ts(2339) 错误 。
const obj = {};
obj.id = 1; // ts(2339)
obj.name = 'ww'; // ts(2339)
此时,我们需要通过重构代码解决这个问题,具体操作是预先定义完整的对象结构或类型断言。
代码重构后的示例如下:
interface IUserInfo {
id: number;
name: string;
}
const obj = {} as IUserInfo;
obj.id = 1; // ok
obj.name = 'ww'; // ok
在第 5 行中,我们使用了类型断言解决了 ts(2339) 错误。
有用的坏习惯
必要时,我们可以使用 // @ts-ignore 注释强制关闭下一行代码静态类型检测,但这绝对是一个坏习惯,示例如下:
Tips:
我们需要铭记所有绕过静态类型检测的方法都是魔鬼,尽量避免使用。
const objString = {
toString: () => '乾元亨利贞'
}
function getString(str: string) {
console.log(`${str}`);
}
// @ts-ignore
getString(objString); // ts(2345)
在示例中的第 7 行,因为我们使用了 // @ts-ignore 注释强行关闭第 8 行的静态类型检测,所以第 8 行并不会提示 ts(2345) 错误。
另外,我们还可以使用 // @ts-nocheck 注释强制关闭整个文件静态类型检测。不过,我建议任何时候都不要使用这个注释。
另外一个有用的坏习惯是双重类型断言,即先把源类型值断言为 unknown,再把 unknown 断言为目标类型。比如上边使用 // @ts-ignore 注释的示例,我们也可以将它改造为双重类型断言,
getString(objString as unknown as string);
这样也不会提示 ts(2345) 错误了。
自动迁移工具
如上边所提到,迁移过程是一项没有技术含量的体力活,因为其中存在很多重复、简单、有规律的操作,比如说将 JavaScript 文件修改为 TypeScript 文件、将模块引入方式从 ES5 require 改为 ES 6 import、将参数隐式 any 类型改为显式 any,这就意味着我们可以借助程序自动完成部分重复的迁移操作。
比如我们可以使用 Airebnb 开源迁移工具ts-migrate,快速地将 JavaScript 项目转换为基本可运行的 TypeScript 项目。因为该工具通过语法分析,可以快速推断出逻辑比较简单的函数/对象/类的类型。如下示例 1 中,JavaScript 函数 mult 经 ts-migrate 自动转换为 TypeScript 后,如下示例 2 中所示的参数 first、second 以及函数返回值类型都被明确为 number。
示例 1
function mult(first, second) {
return first * second;
}
示例 2
function mult(first: number, second: number): number {
return first * second;
}
小结和预告
以上就是JavaScript 应用迁移到 TypeScript 的全部内容,在实际迁移过程中,你需要综合利用前面所有课程的知识才能得心应手。课后建议你挑选一个中小型 JavaScript 项目尝试迁移。
思考题:将 JavaScript 应用迁移到 TypeScript 的过程中,最常见的错误有哪些?都是如何解决的?