TypeScript 性能手册

本文是对 TypeScript Performance 的翻译。因为有些句子在英文表达中很清晰,但直译却会变得晦涩难懂,所以部分句子选择了意译。

有一些简单的方式来配置 TypeScript,从而获得更快的编译速度和更好的编程体验。越早采用这些实践越好。在最佳实践之前,这里有一些常见的技术,用于审查缓慢的编译速度或是编程体验,一些常见的修复方法,以及一些帮助使用 TypeScript 的团队调查问题的常见方法。

编写容易编译的代码

优先选择接口而不是交叉类型

大多数时候,对象类型的一个简单类型别名的行为和接口是非常相似的。

1
2
interface Foo { prop: string }
type Bar = { prop: string }

然而,一旦你需要组合两个或更多的类型,你就需要选择是扩展一个既定的接口,还是使用交叉类型了。这也就是差异开始显现的时候了。

接口创建一个单一的无嵌套对象类型来检测属性冲突,这对解决冲突是很重要的!另一方面,交叉类型仅是递归的合并属性,然后在某些情况下产出 never。接口的一致性通常会更好,而交叉类型的别名经常无法在其他交叉类型的部分中显示。接口之间的类型关系也会被缓存,而不是作为一个整体的交叉类型。最后一个不同点是当检查一个目标交叉类型前,在检查“有效/无嵌套”类型之前,会先检查类型的每一个组成成分。

根据这些理由,我们建议你借助 extends 来扩展 interface,而不是创建一个新的交叉类型。

1
2
3
4
5
6
- type Foo = Bar & Baz & {
- someProp: string;
- }
+ interface Foo extends Bar, Baz {
+ someProp: string;
+ }

使用类型注解

添加类型注解,特别是返回值的类型,可以为编译器节省很多工作。在某种程度上,这是因为命名类型比匿名类型更为紧凑(编译器可能会推断出匿名类型),这会节省大量读写声明文件的时间(例如用于增量构建时)。类型推断很方便,所以没必要到处都写成这样——不过如果你已经确认了这里是导致代码缓慢的部分,那么你可以尝试这样做。

1
2
3
4
5
6
7
- import { otherFunc } from "other";
+ import { otherFunc, otherType } from "other";

- export function func() {
+ export function func(): otherType {
return otherFunc();
}

优先选择基础类型而不是联合类型

联合类型很棒——它允许你将一大堆可能的值结合为一个类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface WeekdaySchedule {
day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday";
wake: Time;
startWork: Time;
endWork: Time;
sleep: Time;
}

interface WeekendSchedule {
day: "Saturday" | "Sunday";
wake: Time;
familyMeal: Time;
sleep: Time
}

declare function printSchedule(schedule: WeekdaySchedule | WeekendSchedule);

然而,这经常会有性能损耗。每次向 printSchedule 中传递参数时,都需要将其与联合类型中的每一个部分相比较。对于仅有两个元素的联合类型,这种损耗微不足道。然而,如果联合类型有许多元素,就会在编译阶段导致相当大的麻烦。例如,为了从联合类型中消除冗余成员,就需要对元素进行两两比对,消耗是二次曲线的。这种检查可能会发生在对复杂联合类型交叉时,交叉每个联合类型成员都可能导致大量的类型,然后需要编译器消除冗余的成员。为了避免这种情况的一种方法是使用子类型,而不是联合类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface Schedule {
day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
wake: Time;
sleep: Time;
}

interface WeekdaySchedule extends Schedule {
day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday";
startWork: Time;
endWork: Time;
}


interface WeekendSchedule extends Schedule {
day: "Saturday" | "Sunday";
familyMeal: Time;
}

declare function printSchedule(schedule: Schedule);

尝试为每种内置 DOM 元素类型建模时,可能会出现一个更加现实的例子。在这种情况下,最好是创建一个含有公共成员的基本类型 HtmlElement。使用 DivElementImgElement 类扩展,而不是创建穷举的联合类型 DivElement | /*...*/ | ImgElement | /*...*/

使用 Project References

当使用 TypeScript 构建一个庞大的代码库时,将代码库组织成几个独立的项目将会很有帮助。每一个项目都会有自己的 tsconfig.json,它们独立于其他项目。这可以避免同时编译太多文件,也可以使得代码库更容易的组合在一起。

这里有一些基础的方法来将将代码库组织成多个项目。例如,一个程序可能有一个客户端项目,一个服务端项目,以及一个二者共享的项目。

1
2
3
4
5
6
7
8
9
10
              ------------
| |
| Shared |
^----------^
/ \
/ \
------------ ------------
| | | |
| Client | | Server |
-----^------ ------^-----

测试也可以分别被写入各个项目中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
              ------------
| |
| Shared |
^-----^----^
/ | \
/ | \
------------ ------------ ------------
| | | Shared | | |
| Client | | Tests | | Server |
-----^------ ------------ ------^-----
| |
| |
------------ ------------
| Client | | Server |
| Tests | | Tests |
------------ ------------

一个常见的问题是:“一个项目究竟该有多大?”。这和“一个函数究竟应该多大?”或“一个类究竟应该多大?”很相似。这在很大程度上取决于开发者的经验。一个常见的,用来分割 JS/TS 代码的方法是使用文件夹。从这里我们可以得到启发,如果一些事物之间的关系足够强到可以将它们放入一个文件夹,那么它们就该是属于同一个项目中的。在那之前,应当避免创建巨大的或是微型项目。如果一个项目庞大到是其他所有项目之和,那这就是一个警告信号了。同样的,也应当避免创建一大堆仅有单一文件的项目,因为这会徒增无用的开销。

你可以阅读更多有关项目链接的信息

配置 tsconfig.jsonjsconfig.json 文件

TypeScript 和 JavaScript 使用者可以通过 tsconfig.json 文件来配置编译过程。[JavaScript 使用者则可以通过配置 jsconfig.json 来修改编程体验]。

指定文件

你得确保你的配置文件没有一次性囊括了太多文件。

tsconfig.json 中,有两种方法指定项目中的文件

  • files 列表
  • includeexclude 列表

这两者之间的首要区别是 files 接受一个指向源文件的文件列表,而 include/exclude 则是全局匹配文件。

当指定了 files 字段,这将会允许 TypeScript 快速的直接加载文件。假如你的项目中有很多文件,却没有一小部分头部的入口文件,这种方式可能会非常笨重。另外的,也会很容易忘记向 tsconfig.json 中添加新文件,这意味着您可能会纠结于陌生的代码编写方式。所有的这一切都会很麻烦。

include/exclude 可以帮助避免去选择这些文件,但是要付出一定的代价:必须要通过遍历包含的文件路径来发现文件。一旦需要遍历非常多文件夹时,就会拖慢编译的速度。另外的,有时候编译将会包含很多不需要的 .d.ts 文件和测试文件,这会增加编译时间和内存占用。最后,尽管 exclude 有一些合理的默认配置,但一些比如 mono-repos 的配置将意味着像 node-modules 这样的文件夹依然可以被包含。

基于一些最佳实践,我们建议你:

  • 仅指定项目的入口文件夹(例如那些你想要编译/分析的源码)
  • 不要把其他项目的源文件混到同一个文件夹里去
  • 如果要把测试文件都放进和其他代码相同的文件夹里,那就清楚地给出命名,这样就可以轻松的排除它们
  • 避免构建大型构件和像是 node_modules 这样的依赖文件夹

Note:如果没有指定 exclude 字段,node_modules 文件夹是被默认排除的;一旦你添加了,那就记得赶紧排除掉 node_modules 文件夹。

下面是一个用来实现这项需求的合理的 tsconfig.json 配置:

1
2
3
4
5
6
7
{
"compilerOptions": {
// ...
},
"include": ["src"],
"exclude": ["**/node_modules", "**/.*/"],
}

控制包含的 @types

TypeScript 默认情况下会自动包含在 node_modules 文件夹下找到的所有 @types 包,而不管你是不是导入了它们。这是为了可以让 Node.js,Jasmine,Mocha,Chai 等包可以正常工作。因为它们并未被导入——它们仅仅是被加载到了全局环境中。

有时候这个逻辑会拖慢程序在编译和编辑时的速度。如果几个全局包的声明冲突了,这也会导致错误:

1
2
3
4
Duplicate identifier 'IteratorResult'.
Duplicate identifier 'it'.
Duplicate identifier 'define'.
Duplicate identifier 'require'.

在不需要全局包的情况下,这个问题很好修复。在 tsconfig.json / jsconfig.json 文件中为"types" 选项 指定一个空文件即可。

1
2
3
4
5
6
7
8
9
10
11
// src/tsconfig.json
{
"compilerOptions": {
// ...

// 不要自动包含所有东西
// 仅包含我们需要导入的 `@types` 包
"types": []
},
"files": ["foo.ts"]
}

如果你仍然需要一些全局包,可以将它们添加到 types 字段中。

1
2
3
4
5
6
7
8
9
10
//  tests/tsconfig.json
{
"compilerOptions": {
// ...

// 仅包含 `@types/node` 和 `@types/mocha`
"types": ["node", "mocha"]
},
"files": ["foo.test.ts"]
}

增量编译

--incremental 标志允许 TypeScript 将最后一次编译的结果存储在 .tsbuildinfo 文件内。这个文件是用来找出那些可能被重新检查/提交的最小文件集合,很像 --watch 模式的工作方式。

当使用 Composite 标志作为项目引用时,增量编译是默认打开的。但是对于选择加入的任何项目,都可以带来相同的加速。

跳过 .d.ts 检查

默认情况下,TypeScript 会整个儿的检查项目里的 .d.ts 文件,从而找出问题和冲突。然而,这往往完全没有必要。大多数时候,.d.ts 文件已经正常工作了——类型相互扩展的方式已经被验证过一次了,而重要的声明都会被检查一遍。

TypeScript 提供使用 skipDefaultLibCheck 标志跳过其附带的 .d.ts 文件(例如 lib.d.ts)的类型检查的选项。

同时,你也可以激活 skipLibCheck 标志在编译阶段跳过所有 .d.ts 文件的检查。

这两个选项都经常会导致 .d.ts 文件的冲突,所以我们仅建议你在需要 更快 的构建速度时使用。

使用更快的变化检查

狗属于动物类吗?换句话说,List<Dog> 属于 List<Animals> 吗?最简单的比较方法是依次比较这两种类型的成员。不幸的是,这通常会有很大的性能问题。然而,如果我们对 List<T> 知道的足够多的话,就可以在削减一部分检查的情况下,判断二者的类型是否相符(例如不用比较 List<T> 的每一个成员)(特别的,我们需要知道类型参数 Tvariance)。如果启用 strictFunctionTypes 标志,编译器就可以充分的利用此潜在的加速功能(否则,它就会使用较慢但更加宽松的结构检查)。因此,我们建议使用 --strictFunctionTypes 构建(默认情况下在 --strict 下启用)。

配置其他构建工具

Author: ShroXd
Link: http://www.bebopser.com/2021/03/04/TypescriptPerformance/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.