⚡tsc性能优化 -- Project References

3,628 阅读11分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

什么是 Project References

在了解一个东西是什么的时候,直接看其官方定义是最直观的

TypeScript: Documentation中对于Project References的介绍如下:

Project references are a new feature in TypeScript 3.0 that allow you to structure your TypeScript programs into smaller pieces.

这是TypeScript 3.0新增的特性,这个特性有啥用呢?

将我们的项目分成多个小的片段,也就是允许我们将项目进行分包模块化

这样一来我们在执行tsc对项目进行构建的时候,无论是出于代码转译成js的目的还是说出于单纯的类型检查(将compilerOptions.noEmit置为true)的目的

都可以严格按照自己的需要对想要被tsc处理的那部分代码进行处理,而不是每次都对整个项目进行处理,这是我认为Project References的最大好处

就以一个前后端代码在同一个仓库里维护的项目为例,如果没有Project References,那么我执行tsc时,会对前后端模块都进行类型检查和转译

但实际上如果我只修改了前端部分的代码,理所应当让tsc只处理前端模块,后端模块的构建产物不需要进行重新构建,有了Project References,我们就能实现到这个需求,从而优化项目的构建或类型检查性能

相信大家对Project References有一个大概的认识了,接下来我们就开始实际动手体验一下Project References加深我们对它的理解吧!

示例项目结构

.
├── package.json
├── pnpm-lock.yaml
├── src
│   ├── __test__              // 单元测试
│   │   ├── client.test.ts    // 前端代码测试
│   │   ├── index.ts          // 简陋的单元测试 API
│   │   └── server.test.ts    // 后端代码测试
│   ├── client                // 前端模块
│   │   └── index.ts
│   ├── server                // 后端模块
│   │   └── index.ts
│   └── shared                // 共享模块 -- 包含通用的工具函数
│       └── index.ts
└── tsconfig.json             // TypeScript 配置

这是一个很常见的项目目录结构,有前端代码,有后端代码,也有通用工具函数代码以及前后端的单元测试代码

它们的依赖关系如下:

  • client 依赖 shared
  • server 依赖 shared
  • __test__ 依赖 client 和 server
  • shared 无依赖

不使用 Project References 带来的问题

现在整个项目只有一个tsconfig.json位于项目根目录下,其内容如下:

{
  "compilerOptions": {
    "target": "ES5",
    "module": "CommonJS",
    "strict": true,
    "outDir": "./dist"
  }
}

如果我们执行tsc,它会将各个模块的代码都打包到项目根目录的dist目录下

dist
├── __test__
│   ├── client.test.js
│   ├── index.js
│   └── server.test.js
├── client
│   └── index.js
├── server
│   └── index.js
└── shared
    └── index.js

这有一个很明显的问题,正如前面所说,当我们只修改一个模块,比如只修改了前端模块的代码,那么理应只需要再构建前端模块的产物即可,但是无论改动范围如何,都是会将整个项目都构建一次,这在项目规模变得越来越大的时候会带来极大的性能问题,构建时长会变得特别长

或许你会想着在每个模块里创建一个tsconfig.json,然后通过tsc -p指定每个模块的目录去单独对它们进行构建,没错,这是一种解决方案

但是这会带来下面两个问题:

  • 如果需要全量构建项目,你得需要运行三次tsc,对每个模块分别构建,而tsc的启动时间开销是比较大的,在这个小规模项目里甚至启动开销的时间比实际构建的时间更长,现在还只是运行三次tsc,如果项目模块很多,有几十上百个呢?那光是启动tsc几十上百次都已经会花一些时间了
  • tsc -w不能一次监控多个tsconfig.json,只能是对各个模块都启动一次tsc -w

Project References的出现,就是为了解决上述问题的

tsconfig.json 的 references 配置项

Project References就是tsconfig.json里的references配置项,其结构是一个包含若干对象的数组,对象的结构如下:

{
  "references": [{ "path": "path/to/referenced-project" }]
}

核心就是一个path属性,该属性指向被引用的项目模块路径,该路径下需要包含tsconfig.json,如果该模块不是用tsconfig.json命名的话,你也可以指定具体的文件名,比如:

{
  "references": [{ "path": "path/to/referenced-project/tsconfig.web.json" }]
}

当指定了references选项后,会发生如下改变:

  • 在主模块中导入被引用的模块时,会加载它的类型声明文件,也就是.d.ts后缀的文件
  • 使用tsc --buildtsc -b构建主模块时,会自动构建被引用的模块

这样一来能够带来三个好处:

  • 提升类型检查和构建的速度
  • 减少IDE的运行内存占用
  • 更容易对项目结构进行划分

tsconfig.json 的 composite 配置项

光是在主模块中指定references配置项还不够,还需要在被引用的项目对应的tsconfig.json中开启composite配置项

composite配置项又是干嘛的呢? -- 它可以帮助tsc快速确定如何寻找被引用项目的输出产物

当被引用的项目开启composite配置项后,会有如下改变和要求:

  • 当未指定rootDir时,默认值不再是The longest common path of all non-declaration input files,而是包含了tsconfig.json的目录

    Tips: 关于The longest common path of all non-declaration input files的意思可以到tsconfig.json 文章中关于 rootDir 的介绍中查阅

  • 必须开启include或者files配置项将要参与构建的文件声明进来

  • 必须开启declaration配置项(因为前面介绍references的时候说了,会加载被引入模块的类型声明文件,因此被引用模块自然得开启declaration配置项生成自己的类型声明文件供主模块加载)

使用 Project References 改造示例项目

根据目前我们对Project References的认识,现在可以开始改造一下我们的项目了,首先是根目录下的tsconfig.json配置,它起到一个类似于项目入口的作用,因此这里面只负责添加references声明项目中需要被构建的模块,以及通过exclude将不需要参与构建的模块排除(比如src/__test__中的测试代码)

/tsconfig.json

{
  "references": [
    { "path": "src/client" },
    { "path": "src/server" },
    { "path": "src/shared" }
  ],
  "exclude": ["**/__test__"]
}

然后是各个子模块的tsconfig.json配置,这里我们假设构建目标为es5的代码,所以对于clientserver以及shared来说是存在公共配置的,所以我们可以抽离出一个公共配置,然后在子模块中通过extends配置项公用一个配置

/tsconfig.base.json

{
  "compilerOptions": {
    "target": "ES5",
    "module": "CommonJS",
    "strict": true
  }
}

src/client/tsconfig.json

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "../../dist/client",
    "composite": true,
    "declaration": true
  },

  // 依赖哪个模块则引用哪个模块
  "references": [{ "path": "../shared" }]
}

src/server/tsconfig.json

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "../../dist/server",
    "composite": true,
    "declaration": true
  },

  // 依赖哪个模块则引用哪个模块
  "references": [{ "path": "../shared" }]
}

src/shared/tsconfig.json

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "../../dist/shared",
    "composite": true,
    "declaration": true
  }
}

全量构建

现在我们在项目根目录下运行tsc --build --verbose,就会根据references配置去寻找各个子模块,并对它们进行构建,可以理解为对项目的全量构建

--build 参数表示让tscbuild模式进行构建和类型检查,也就是会使用references配置项,如果不开启的话是不会使用references配置项的,这点可以从官方文档中得证: tsc --build 的作用

--verbose 参数则是会将构建过程中的输出显示在控制台中,不开启该参数的话则不会显示输出(除非构建过程中报错)

运行后/dist目录结构如下

dist
├── client
│   ├── index.d.ts
│   ├── index.js
│   └── tsconfig.tsbuildinfo
├── server
│   ├── index.d.ts
│   ├── index.js
│   └── tsconfig.tsbuildinfo
└── shared
    ├── index.d.ts
    ├── index.js
    └── tsconfig.tsbuildinfo

可以看到,所有子模块都被构建进来了,各模块的产物中有一个tsconfig.tsbuildinfo文件,这个文件起到一个类似缓存的作用,对于后续进行增量构建有着重要作用

前面看到的官方文档中对tsc --build的作用的介绍中的第二点Detect if they are up-to-date主要就是依靠这个缓存文件去识别的

开启--verbose参数后,可以看到控制台输出如下:

[4:50:26 PM] Projects in this build:
    * src/shared/tsconfig.json
    * src/client/tsconfig.json
    * src/server/tsconfig.json
    * tsconfig.json

[4:50:26 PM] Project 'src/shared/tsconfig.json' is out of date because output file 'dist/shared/tsconfig.tsbuildinfo' does not exist

[4:50:26 PM] Building project '/home/plasticine/demo/ts-reference-demo/src/shared/tsconfig.json'...

[4:50:28 PM] Project 'src/client/tsconfig.json' is out of date because output file 'dist/client/tsconfig.tsbuildinfo' does not exist

[4:50:28 PM] Building project '/home/plasticine/demo/ts-reference-demo/src/client/tsconfig.json'...

[4:50:28 PM] Project 'src/server/tsconfig.json' is out of date because output file 'dist/server/tsconfig.tsbuildinfo' does not exist

[4:50:28 PM] Building project '/home/plasticine/demo/ts-reference-demo/src/server/tsconfig.json'...

[4:50:29 PM] Project 'tsconfig.json' is out of date because output 'src/shared/index.js' is older than input 'src/client'

[4:50:29 PM] Building project '/home/plasticine/demo/ts-reference-demo/tsconfig.json'...

[4:50:29 PM] Updating unchanged output timestamps of project '/home/plasticine/demo/ts-reference-demo/tsconfig.json'...

xxx out of date xxx意思就是这个模块没被构建过,因此会开始对其进行构建

由于我们是首次构建,所以三个模块都是没被构建过的,所以三个模块都被检测为out of date

当我们再次运行tsc --build --verbose时,输出如下:

4:54:35 PM - Projects in this build:
    * src/shared/tsconfig.json
    * src/client/tsconfig.json
    * src/server/tsconfig.json
    * tsconfig.json

4:54:35 PM - Project 'src/shared/tsconfig.json' is up to date because newest input 'src/shared/index.ts' is older than output 'dist/shared/tsconfig.tsbuildinfo'

4:54:35 PM - Project 'src/client/tsconfig.json' is up to date because newest input 'src/client/index.ts' is older than output 'dist/client/tsconfig.tsbuildinfo'

4:54:35 PM - Project 'src/server/tsconfig.json' is up to date because newest input 'src/server/index.ts' is older than output 'dist/server/tsconfig.tsbuildinfo'

4:54:35 PM - Project 'tsconfig.json' is up to date because newest input 'dist/server/index.d.ts' is older than output 'src/client/index.js'

可以看到,所有模块都被检测为up to date,从而避免了重复构建

增量构建

如果现在我们修改了client模块的代码,再运行tsc --build --verbose会怎样呢?估计你也能猜到了,只有client模块会被构建,而其他模块则会跳过

4:56:44 PM - Projects in this build:
    * src/shared/tsconfig.json
    * src/client/tsconfig.json
    * src/server/tsconfig.json
    * tsconfig.json

4:56:44 PM - Project 'src/shared/tsconfig.json' is up to date because newest input 'src/shared/index.ts' is older than output 'dist/shared/tsconfig.tsbuildinfo'

4:56:44 PM - Project 'src/client/tsconfig.json' is out of date because output 'dist/client/tsconfig.tsbuildinfo' is older than input 'src/client/index.ts'

4:56:44 PM - Building project '/home/plasticine/demo/ts-reference-demo/src/client/tsconfig.json'...

4:56:45 PM - Project 'src/server/tsconfig.json' is up to date because newest input 'src/server/index.ts' is older than output 'dist/server/tsconfig.tsbuildinfo'

4:56:45 PM - Project 'tsconfig.json' is out of date because output file 'src/client/index.js' does not exist

4:56:45 PM - Building project '/home/plasticine/demo/ts-reference-demo/tsconfig.json'...

4:56:45 PM - Updating unchanged output timestamps of project '/home/plasticine/demo/ts-reference-demo/tsconfig.json'...

相信现在你能体会到Project References的好处了吧,能够很大程度上优化我们的构建速度!

不过实际开发中,tsc更多的是用来进行类型检查,至于compile的工作,则更多地是交给如Babelswcesbuild等工具去完成,这也是官方文档中有提到过的

noEmit配置项介绍

这也是为什么你在vite创建的项目中能够看到默认的build命令配置为tsc && vite build,正是将类型检查的工作交给tsc,而构建工作则交给vite底层依赖的rollup去完成

__test__测试代码的处理

我们的改造貌似已经完成了,但其实还忽略了一个src/__test__,它也可以被视为一个模块,它作为主模块,依赖了clientserver,因此也可以给它加上tsconfig.json配置,并且对于测试代码,我们一般不希望将它们构建成js,只希望tsc负责类型检查的工作,因此我们需要进行如下配置:

src/__test__/tsconfig.json

{
  "compilerOptions": {
    "noEmit": true
  },
  "references": [{ "path": "../client" }, { "path": "../server" }]
}

noEmit的作用刚刚在官方文档中也看到了,不会把产物文件输出,如果我们只需要类型检查能力的话很适合开启该配置项

现在我们如果需要对__test__中的代码进行类型检查的话,只需要执行:

# 忽略 references 配置项
tsc --project src/__test__

# 启用 references 配置项
tsc --build src/__test__

如果是使用--project参数的话,tsconfig.json中可以忽略references配置项,因为即便配置了也不会被使用,这在依赖产物未构建出来时能起作用

而如果使用--build参数,并且clientserver未构建出来时,会先构建它们,再对测试代码进行类型检查,可以根据个人需求场景来决定使用--project还是--build

总结

本篇文章介绍了Project References是什么,并通过一个简单的示例项目,并结合TypeScript Documentation官方文档边实战边解释

总的来说,其使用起来就是:

  • 主模块(tsc --build作用的模块视为主模块)中通过references配置项声明依赖的模块
  • 被引用模块中开启compositedeclaration配置项以支持被引用
  • 通过tsc --build 主模块才可以启用references配置项,这在官方文档中被称为Build Mode,如果直接tsc 主模块的话,是不会启用references配置项的,也就导致依然会对项目中的所有ts文件进行编译(如果没配置includefiles配置项的话)

希望通过本篇文章,能够让你对Project References有一个全面了解,也希望能够将其用在你的项目中,提升类型检查或构建(使用 tsc 进行构建的话)的速度