前言
最近 Vercel 收购了 Turborepo ,目的是为了加快 Next.js 的构建速度,并且 Turborepo 的作者也加入了 Vercel。
monorepo
什么是monorepo?
在通用的开发场景中,人们希望各个项目之间能够足够的独立,各自的开发和发布不会产生太多的耦合,现在很多的项目也是出于这种考虑去拆成一个一个独立的子项目,在单独的代码仓库中进行管理,这就是我们常见的单代码仓库的开发模式。 但是上面的模式,在某些场景下就会显得低效和繁琐。比如一个仓库的代码被很多其他相关的仓库引用,那么只要这个仓库进行发版,所有依赖了这个代码的仓库也要跟着进行依赖升级和发版。如果把所有有依赖关系的代码都放到一个仓库中进行统一维护,当一个库变动时,其它的代码能自动的进行依赖升级,那么就能精简开发流程、提高开发效率。这种多包的代码仓库管,就是 monorepo。 其实monorepo在前端中非常常见,Babel、Reac、Vue等开源项目都是使用这种方式在管理代码,其中 Babel 官方开源的多包管理工具 Lerna 也被广泛的使用。
Turborepo
Turborepo 是一个适用于 JavaScript 和 Typescript monorepo 的高性能构建工具,它不是一个侵入式的工具,你可以在项目中渐进的引入和使用它,它通过足够的封装度,使用一些简单的配置来达到高性能的项目构建。 和esbuild一样,Turborepo也是基于go实现的工具,在语言层面上就具有一定的性能优势。
优势
- 增量构建:缓存构建内容,并跳过已经计算过的内容,通过增量构建来提高构建速度
- 内容hash:通过文件内容计算出来的hash来判断文件是否需要进行构建
- 云缓存:可以和团队成员共享CI/CD的云构建缓存,来实现更快的构建
- 并行执行:在不浪费空闲 CPU 的情况下,以最大并行数量来进行构建
- 任务管道:通过定义任务之间的关系,让 Turborepo 优化构建的内容和时间
- 约定式配置:通过约定来降低配置的复杂度,只需要几行简单的 JSON 就能完成配置
开始使用
对于一个新的项目,可以运行下面的命令来生成全新的代码仓库
npx create-turbo@latest
对于一个已经存在的 monorepo 项目,可以通过下面的步骤来接入 turborepo
安装Turborepo
将 Turborepo 添加到项目最外层的devDependecies
中
npm install turbo -D
or
yarn add turbo --dev
增加配置
在 package.json 中增加 Turborepo 的配置项
// package.json
{
"turbo": {
}
}
Turborepo 所有相关的配置,都放入turbo这个配置项中
创建任务管道
在package.json
的turbo
中,将想要"涡轮增压"的命令添加到管道中
管道定义了 npm 包中 scripts 的依赖关系,并且为这些命令开启了缓存。这些命令的依赖关系和缓存设置会应用到 monorepo 中的各个包中
{
"turbo": {
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false
}
}
}
}
上面的示例中,build
和test
这两个任务具有依赖性,必须要等他们的依赖项对应的任务完成后才能执行,所以这里用^
来表示。
对于每个包中 package.json 中的 script 命令,如果没有配置覆盖项,那么Turborepo将缓存默认输出到 dist/**
和build/**
文件夹中。可以通过outputs数组来设置缓存的输出目录,示例中将缓存保存到.next/**
文件夹中。Turborep会自动将没个script的控制台log缓存到.turbo/turbo-<script>.log
目录中,不需要自己手动去指定。
dev这个任务通过cache设置为false来禁用这个命令的缓存功能。
pipeline
从上面的 turbo 的配置中可以看出来,管道(pipeline)是一个核心的概念,Turborepo也是通过管道来处理各个任务和他们的依赖关系的。
在传统的monorepo仓库中,比如使用了lerna或者yarn的workspace进行管理,每个npm包的script(如build或者test),都是依赖执行或者独立并行的执行。如果一个命令存在包的依赖关系,那么在执行的时候,CPU的核心可能会被闲置,这样会导致计算性能和时间上的浪费。
Turborepo提供了一种声明式的方法来指定各个任务之间的关系,这种方式能够更容易理解各个任务之间的关系,并且Turborepo也能通过这种显式的声明来优化任务的执行并充分调度CPU的多核心性能。
上图是Turborepo和Lerna的执行流水线对比,可以看出Turborepo能够高效的执行任务,而Lerna一次只能执行一个任务。
配置pipeline
pipeline中每个键名都可以通过运行turbo run
来执行,并且可以使用dependsOn
来执行当前管道的依赖项。
上图的执行流程,可以配置成如下的格式
{
"turbo": {
"pipeline": {
"build": {
"dependsOn": ["^build"],
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"deploy": {
"dependsOn": ["build", "test", "lint"]
}
}
}
}
通过dependsOn
的配置,可以看出各个命令的执行顺序:
- 因为A和C依赖于B,所以包的构建存在依赖关系,根据build的dependsOn配置,会先执行依赖项的build命令,依赖项执行完后才会执行自己的build命令。从上面的瀑布流中也可以看出,B的build先执行,执行完以后A和C的build会并行执行
- 对于test,只依赖自己的build命令,只要自己的build命令完成了,就立即执行test
- lint没有任何依赖,在任何时间都可以执行
- 自己完成build、test、lint后,再执行deploy命令
所有的pipeline可以通过下面的命令执行:
npx turbo run build test lint deploy
常规依赖
如果一个任务的执行,只依赖自己包其他的任务,那么可以把依赖的任务放在dependsOn数组里
{
"turbo": {
"pipeline": {
"deploy": {
"dependsOn": ["build", "test", "lint"]
}
}
}
}
拓扑依赖
可以通过^
符号来显式声明该任务具有拓扑依赖性,需要依赖的包执行完相应的任务后才能开始执行自己的任务
{
"turbo": {
"pipeline": {
"build": {
"dependsOn": ["^build"],
}
}
}
}
空依赖
如果一个任务的dependsOn为undefined
或者[]
,那么表明这个任务可以在任意时间被执行
{
"turbo": {
"pipeline": {
"lint": {
"outputs": []
},
}
}
}
特定依赖
在一些场景下,一个任务可能会依赖某个包的特定的任务,这时候我们需要去手动指定依赖关系。
{
"turbo": {
"pipeline": {
"build": {
"dependsOn": ["^build"],
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"deploy": {
"dependsOn": ["build", "test", "lint"]
},
"frontend#deploy": {
"dependsOn": ["ui#test", "backend#deploy"]
}
}
}
}
在上面的例子中,增加了一个前端的部署任务。这个部署任务,依赖于一个UI组件库和对应的后端项目,只有这个UI组件库通过单测,然后后端项目部署成功,才会进行部署。对于指定包的依赖,使用<package>#<task>
语法。
with Lerna
Lerna是现在常用的monorepo构建工具,它不仅能支持包任务的运行,也能很好的进行包的依赖和版本管理。
和Lerna比较,Turborepo有更好的任务调度机制,并且Lerna运行任务的时候是不会进行缓存的,所以在缓存方面Turborepo也有很大的优势。
对于包的publish以及version的更新,Turborepo还没有进行实现,所以在现阶段可以一起使用Lerna和Turborepo,让他们各司其职。
安装Turborepo,并对package.json进行如下的修改:
{
"scripts": {
- "dev": "lerna run dev --stream --parallel",
+ "dev": "turbo run dev --parallel --no-cache",
- "test": "lerna run test",
+ "test": "turbo run test",
- "build": "lerna run build",
+ "build": "turbo run build",
"prepublish": "lerna run prepublish",
"publish-canary": "lerna version prerelease --preid canary --force-publish",
"publish-stable": "lerna version --force-publish && release && node ./scripts/release-notes.js"
},
"devDependencies": {
"lerna": "^3.19.0",
+ "turbo": "*"
},
+"turbo": {
+ "pipeline": {
+ "build": {
+ "dependsOn": ["^build"],
+ "outputs": ["dist/**"]
+ },
+ "test": {
+ "outputs": []
+ },
+ "dev": {
+ "cache": false
+ }
+ }
}
}
对比
我自己也在维护一个monorepo的项目,但是涉及的组件比较少,不能很直观的看出Turborepo带来的提升,这里就找了 reakit 这个库来进行比较。
安装完依赖后,对package.json做如下的修改:
这个项目中各个包的构建是没有依赖关系的,所有build和lint任务一样,都没有依赖项的配置。
run build
下面以build命令为例进行对比。
为了提高lerna的构建速度,对 lerna-build 开启--parallel
配置项,让它能够并行执行
第一次执行lerna-build,最终的耗时如下图所示
因为lerna没有缓存,所以后面多次运行的结果,基本上都维持在25秒左右
第一次执行yarn turbo-build
,控制台输出为
可以看出第一次执行的时候,消耗的时间和lerna开启并行执行所需要的时间差不多,当第二次运行的时候,因为缓存生效了,最终的输出如下图
因为缓存的存在,所以第二次执行的时候,只花了0.5s
文件变动
修改其中一个包的代码,lerna-build
的时间没有变,还基本上是25秒左右。
而运行turbo-build
的时候,最后的时间输出如下图
可以看出对于没有修改的包的代码,缓存还在生效,所以执行时间还有大幅的缩减。
复杂场景
我们手动创造一个部署的场景,对应的命令和配置如下
{
scripts: {
"turbo-deploy": "turbo run deploy",
"lerna-deploy": "npm run lerna-lint && npm run lerna-build"
},
turbo: {
"deploy": {
"dependsOn": ["lint", "build"]
}
}
}
lerna-deploy 需要45秒左右的时间才能执行完,而 turbo-deploy 只需要20秒左右,并且后面还有缓存的加持。
Remote cache
当多人开个一个项目的时候,团队的成员可以共享构建的缓存,从而加快项目的构建速度。
当一个成员把某个分支构建的缓存文件推送到远程的git仓库是,另一个成员如果在同一个分支上进行开发,那么Turborepo 可以支持你去选择某个成员的构建缓存,并在运行相关的构建任务时,从远端拉去缓存文件到本地,加快构建的速度
运行 npx turbo link,进行登录后,就可以选择要使用的缓存
选择完成后,就可以使用对应的缓存了
在作者的视频demo中,项目在没有使用缓存的情况下,需要花费27秒的时间来完成所有任务的构建
而在使用远端缓存的情况下,只需要3.5秒左右就能完成任务的构建
所以在多人开发的项目中,remote cache是一个杀手锏级别的功能,能够大幅提高构建任务的执行速度
总结
从上面的例子可以看出,执行的任务数量越多,并且依赖越复杂的情况,Turborepo在利用CPU多核心方面的优势就越明显。并且由于缓存的存在,在某些场景下,比如前后端依赖部署,只修改了前端的代码,那么后端代码的构建缓存就能被直接使用,这种情况下可以大大缩减构建时间,提高构建的效率。
所以在现阶段使用Turboreop来代替Lerna进行构建,对于复杂的monorepo项目来说,可以大大减少构建的时间,提高开发体验,具有相当可观的收益。