如何减少 monorepo 中 lib 的初始化时间

本文最后更新于:2021年11月14日 晚上

场景

在我们使用 monorepo 将所有的前端项目放到一个项目中后,会面临各种各样的问题,其中的多个通用模块的初始化也会是一个问题。

monorepo 模块依赖图.drawio.svg

下面是目前实践过的一些解决方案

  • 增量构建
  • 捆绑依赖项
  • 基于 esbuild
  • 不构建 dts

增量构建

每次修改 libs 中的内容,其他人通过 git 拉取时都需要重新 initialize,如果知道在哪个包还好,可以仅运行指定包的 initialize 命令。如果不知道的话,则需要运行根目录的 initialize 命令,这其实是非常缓慢的,因为它会重新运行所有包含 intialize 命令的 npm 包,而不管它们是否有变更。

  1. 减少 initialize 时间,提高协作的开发体验
  2. 支持 ci/cd 缓存已构建的 libs,加快构建时间

需求

  • 尽可能地按照依赖图并发执行命令,并且基于 git 变更实现缓存
  • 在指定模块依赖的模块执行命令
  • 在所有子模块中执行命令

真实项目构建时间

  • lerna run
    • 新项目首次初始化 198.54s
    • 非新项目再次初始化 90.17s

吾辈在 yarn-plugin-changed 模块中基于 yarn2 实现了这个功能。

捆绑依赖项

加载一个文件总比加载 100 个小文件要快,这也是为什么 webpack 等工具会将开发的代码打包,yarn2 推动 pnp 的主要原因之一。对于 cli 而言,也是一样的道理,将依赖尽可能地打包到 bundle 中就好了。虽然打包本身会增加一点时间,但在其他模块使用 cli 时就会快一些。

real    0m44.142s
user    0m0.122s
sys     0m0.214s

44.142s => 30s 438ms

参考 VSCode 打包指南

基于 esbuild

rollup 使用 js 编写,它在使用必须的插件之后打包非常缓慢(可能部分要归结于 tsc 本身也非常慢),而 esbuild 在官方性能测试中要快 10-100 倍,这为性能优化提供了一种思路:将 CPU 密集型的功能使用高性能的语言构建。在使用 esbuild 命令行时,基本上,lib/cli 都能在数百毫秒内完成构建,而其中实际运行构建代码的时间大约只有几十毫秒,大部分时间是在等待 cli 启动。

下面是一个在单模块的性能测试

esbuild cli

time esbuild src/bin.ts --bundle --external:esbuild --external:@yarnpkg/cli --platform=node --outfile=dist/bin.js --sourcemap && \
time esbuild src/index.ts --bundle --external:esbuild --external:@yarnpkg/cli --external:fs-extra --platform=node --format=cjs --outfile=dist/index.js --sourcemap && \
time esbuild src/index.ts --bundle --external:esbuild --external:@yarnpkg/cli --external:fs-extra --platform=node --format=esm --outfile=dist/index.esm.js --sourcemap
# 0m0.244s # 15ms
# 0m0.211s # 4ms
# 0m0.212s # 4ms

esbuild base nodejs cli

time liuli-cli build cli # 0m2.276s # 1988ms

不构建 dts

为什么不构建 dts?

构建 dts 很慢,至于多慢呢?下面是一个构建 cli 的时间分析

不生成类型定义

$ time yarn build
√ 构建 cli: 141ms
√ 构建 esm: 18ms
√ 构建 cjs: 17ms
构建完成: 142ms

real    0m4.000s
user    0m0.075s
sys     0m0.138s

生成类型定义

$ time yarn build
√ 构建 cli: 3598ms
√ 构建 esm: 50ms
√ 构建 cjs: 3587ms
√ 生成类型定义: 3602ms
构建完成: 3614ms

real    0m7.763s
user    0m0.000s
sys     0m0.197s

可以看到,生成类型定义耗费了大量的时间,与使用 esbuild 构建完全不在一个时间量级上。但同时,我们也必须注意到,nodejs 花费的真实时间很多,甚至远超构建本身,也就意味着,nodejs 确实存在性能极限。实际项目中,初始化 15 个模块需要 30s 438ms,而生成 dts 则需要 44s 343ms,单点优化对整体已经很难产生数量级的影响了。

问题

  • 通用模块中的 ts 的错误会蔓延到业务模块中,主要是在自定义 src/@types/ 类型的时候

不做任何打包

虽然看起来不可思议,但在 monorepo 中,许多通用模块可能并不需要在 monorepo 之外使用到,所以对于有些不需要发布的模块,可以不做打包,而是直接在 package.json 中指向未打包的入口文件。

下面是一个 lib 的不打包配置

{
  "main": "src/index.ts",
  "module": "src/index.ts",
  "types": "src/index.ts"
}

当然,它也会带来一些副作用,例如

  • 使用该模块必须支持 ts
  • 增加依赖该模块的终端程序的打包时间