各位开发者朋友们,大家好!
欢迎来到今天的“构建加速特训营”。我是你们的讲师,一名在代码世界里摸爬滚打多年的资深工程师。
在开始今天的主题之前,我想先问大家一个问题:你们有没有过这种经历?
你在写代码,手指在键盘上飞舞,像钢琴家一样敲出了一段绝妙的逻辑。你满怀期待地按下 Ctrl+S,然后准备运行 npm run build。你端起咖啡,深吸一口气,盯着终端,心想:“大概需要 3 分钟吧。”
然后,你盯着那个进度条,从 0% 走到了 100%,咖啡凉了,你的背也凉了。你回头看了一眼屏幕,发现它还在转圈圈,甚至可能还在报错。
这太痛苦了。 这种等待,比等待电梯到达还要让人抓狂。在 Monorepo 的世界里,随着项目规模变大,构建时间线性甚至指数级增长,这简直就是开发者的“咖啡时间杀手”。
今天,我们要聊的就是如何用魔法打败魔法——通过构建缓存治理,让你的 React 大型 Monorepo 构建速度飞起来。
准备好了吗?让我们把那些慢吞吞的构建过程,统统扔进垃圾桶!
第一章:Monorepo 的诅咒与缓存的救赎
首先,我们要搞清楚为什么大型 Monorepo 的构建这么慢。
想象一下,你的项目是一个巨大的公司。你有前台(Web应用)、后台(Node服务)、还有一堆共享的公共组件库。以前,这些是分开的仓库,你构建一个,停一下,构建另一个。现在,你把它们全塞进一个 monorepo 里。
当你修改了一个微小的按钮样式时,如果你没有缓存,Turborepo(我们今天的主角)得告诉你:“嘿,既然你改了按钮,那整个前台的代码我也得重新检查一遍;既然前台变了,后台依赖的前台代码也得变;既然后台变了,服务端的依赖也得变……”
这就是“瀑布流”式的依赖检查。如果依赖树有 100 层,那你改个 README.md 都得等 100 层的构建跑完。
缓存,就是那个救世主。
它的核心哲学非常简单:如果输入没变,输出就不应该变。
这就引出了一个核心概念:哈希。
第二章:哈希——构建世界的指纹
在深入 Turborepo 之前,我们必须理解“哈希”这个魔法咒语。
Turborepo 怎么知道一个任务需要重新运行呢?它给每个任务计算一个唯一的指纹。
这个指纹怎么算的?它是基于输入文件的哈希。
// 这是一个伪代码示例,展示哈希是如何生成的
function calculateTaskHash(inputs) {
let content = "";
// 1. 获取所有输入文件的路径
inputs.forEach(path => {
// 2. 读取文件内容
const fileContent = fs.readFileSync(path);
// 3. 关键点:不仅要读取内容,还要读取修改时间
// 这就是为什么修改时间会触发重新构建
const stats = fs.statSync(path);
// 4. 拼接字符串:文件路径 + 内容 + 修改时间戳
content += `${path}:${fileContent}:${stats.mtimeMs}`;
});
// 5. 使用 SHA-1 算法生成哈希值
return crypto.createHash('sha1').update(content).digest('hex');
}
看懂了吗?
当你修改了一个文件,比如 Button.tsx,它的哈希值变了。
Turborepo 发现 build 任务依赖 Button.tsx。
于是,Turborepo 说:“build 任务的指纹变了,我不缓存了,我要重新跑一遍!”
如果你没有改 Button.tsx,Turborepo 发现指纹一样,直接从硬盘里把构建好的产物(比如 .js 文件)扔出来,0 秒钟完成构建!
这就是缓存的魔法。
第三章:Turborepo 的核心配置 —— turbo.json
在 React 生态里,Turborepo 是当之无愧的王者。它不仅仅是一个缓存工具,它是一个构建编排器。
要治理缓存,你必须读懂 turbo.json。这就像是给 Turborepo 的“大脑”做手术。
1. 管道与任务定义
首先,我们要定义任务之间的依赖关系。这叫“管道”。
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"], // 这里的 ^ 表示依赖的依赖也要先构建
"outputs": [".next/**", "!.next/cache/**"]
},
"lint": {
"outputs": []
},
"dev": {
"cache": false, // 开发模式通常不需要缓存,因为它是实时编译的
"persistent": true
}
}
}
这段代码意味着什么?
build任务依赖所有以^开头的build任务。这意味着如果你改了packages/ui,apps/web会先等ui构建完,再构建自己。outputs是最关键的。它告诉 Turborepo:“哪些文件是构建的产物?如果这些文件没变,直接用旧文件覆盖。”
注意那个 ! 符号!
.next/cache/** 前面的感叹号表示忽略缓存。Next.js 的缓存目录是用来存储构建优化数据的,我们不需要每次都重新生成它,这会极大加快速度。
2. hashIgnore:你的缓存守护神
这是 Turborepo 配置中最容易被忽视,但最重要的选项。
默认情况下,Turborepo 会把 package.json、tsconfig.json、node_modules 都算进哈希的输入里。
这会导致一个灾难性的情况:你改了 package.json 里的版本号(哪怕只是加了个空格),整个 Monorepo 的缓存全部失效!
为了防止这种情况,我们需要使用 hashIgnore。
{
"globalEnv": ["NODE_ENV"],
"hashIgnore": [
".env*",
"turbo/**",
"dist/**",
"build/**",
"coverage/**",
"node_modules/**"
]
}
为什么 node_modules 要被忽略?
因为 node_modules 是软链接或者是锁文件。虽然它依赖你的源码,但它本身不应该改变你代码的构建结果。如果你把 node_modules 算进哈希,那每次 npm install 都会重跑所有构建,这太蠢了。
为什么 dist 和 build 要被忽略?
因为它们是输出。哈希是基于输入计算的,输出文件本身不应该影响下一次的输入判断(除非有增量编译逻辑,但那是构建工具的事,不是缓存工具的事)。
第四章:远程缓存 —— 跨机器的共享大脑
想象一下,你在本地改了代码,构建成功了。这时候,你的同事在另一台电脑上拉取了代码。
如果这时候他直接跑 turbo run build,他是不是还得等 3 分钟?
不。
Turborepo 支持远程缓存。你可以把构建好的产物上传到 Vercel 的云端,或者你自己搭建一个 MinIO/S3 服务器。
配置起来非常简单,只需要在 CI 环境里设置环境变量:
# 在 GitHub Actions 或 GitLab CI 中
export TURBO_TOKEN="your_token"
export TURBO_TEAM="your_team"
或者如果你用 Vercel,它甚至能自动识别。
工作流程是这样的:
- 你在本地构建,Turborepo 把结果(包括输出文件)打包成二进制数据,上传到云端。
- 同事在 CI 里构建,Turborepo 发现输入文件的哈希和云端一样,于是直接从云端下载结果,秒级完成构建。
这不仅仅是快,这是协作效率的飞跃。
实战代码示例:
如果你要自己搭建远程缓存服务器,你需要使用 turbo login 和 turbo remote cache enable。
# 登录远程缓存
turbo login
# 启用远程缓存
turbo remote cache enable
# 运行构建(此时会自动上传结果)
turbo run build
如果你在 CI 里运行,记得加个参数告诉 Turborepo 它是匿名的,或者是通过 Token 认证的。
第五章:深入理解全局依赖 —— 这里的坑最深
在大型 Monorepo 中,有一个经典问题:全局环境变量。
假设你的 apps/web 依赖一个环境变量 API_URL。
你在 apps/web 里构建,它没问题。
但是,packages/utils 也依赖这个变量(比如它需要打印日志)。
如果你把 API_URL 算在 packages/utils 的哈希输入里,那么当你修改 API_URL 时,packages/utils 也会重新构建,即使它根本不需要构建。
这就是缓存粒度的问题。
Turborepo 提供了 globalDependencies 和 globalVariants 来解决这个问题。
1. globalDependencies
告诉 Turborepo:“这些文件是所有任务共用的,如果它们变了,所有任务都要重新构建。”
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"globalDependencies": [".env*"]
}
}
}
警告:
globalDependencies 是一把双刃剑。如果你把太多文件(比如 package.json)放进去,那每次修改这个文件,整个仓库的缓存就都失效了。
我的建议是: 只把 .env* 放进去。因为环境变量的改变确实应该触发全量的重新构建(虽然这在某些场景下可能不必要,但为了安全起见,通常建议触发)。
2. globalVariants —— 高级玩法
这是针对不同环境(dev, prod)的缓存策略。
假设你有 turbo run build --filter=... --env=NODE_ENV=production。
你可以配置:
{
"pipeline": {
"build": {
"outputs": ["dist/**"],
"globalVariants": ["node-env"]
}
}
}
然后 Turborepo 会生成两套缓存:一套给 NODE_ENV=development,一套给 NODE_ENV=production。
这意味着,你改了代码,dev 环境构建失败,但 prod 环境可能还能命中缓存(如果逻辑没变)。
第六章:Force —— 强制重启的核按钮
有时候,缓存会出bug。比如你改了代码,但缓存没有失效,导致你跑的是旧的代码。
这时候,你需要 --force 参数。
# 强制重新运行所有任务,忽略缓存
turbo run build --force
或者,如果你只想强制重新运行某个包:
# 强制重新构建 packages/ui
turbo run build --force --filter=packages/ui
这就像是给电脑按了“强制重启”。虽然简单粗暴,但有时候是解决缓存中毒的唯一办法。
第七章:Base Hash —— 依赖地狱的终结者
这是 Turborepo 最强大的功能之一,也是很多新手容易晕的地方。
问题场景:
你的项目结构是这样的:
apps/web -> packages/ui -> packages/shared -> packages/utils
你修改了 packages/utils 的代码。
Turborepo 检测到依赖链:web -> ui -> shared -> utils。
于是它从下往上构建:先构建 utils,再构建 shared,再构建 ui,最后构建 web。
但是! 在构建 shared 的时候,它发现 shared 依赖了 packages/shared 的某个文件。它需要去读这个文件。
如果这个文件在 node_modules 里,那还好说。但如果这个文件是 src 目录下的,那 Turborepo 就得去扫描整个 node_modules 来找依赖。
这就导致了“瀑布流”的噩梦。
解决方案:Base Hash
Base Hash 允许你告诉 Turborepo:“不要每次都去扫描 node_modules。给我一个基准哈希,我基于这个基准哈希来计算增量。”
这通常用于那些依赖关系非常复杂的项目。
配置方式:
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"baseHash": "packages/shared/**:packages/ui/**"
// 这里的语法是:对于 packages/shared 的文件,如果它依赖了 packages/ui 的文件,就使用 packages/ui 的哈希作为基准
}
}
}
这就像是给依赖链做了一个索引。它极大地减少了构建时的文件系统扫描开销。
第八章:实战演练 —— 一个完整的 React Monorepo 配置
好了,理论讲得差不多了,我们来看看一个真实的、针对大型 React 项目的配置。
假设我们使用 pnpm 作为包管理器(因为它比 npm 和 yarn 快,且磁盘空间占用小)。
目录结构:
my-monorepo/
├── apps/
│ ├── web/ (Next.js)
│ └── admin/ (React + Vite)
├── packages/
│ ├── ui/ (React Components)
│ ├── api/ (Express Server)
│ └── utils/ (TypeScript Utils)
└── turbo.json
turbo.json 完整配置:
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": ["NODE_ENV", "NEXT_PUBLIC_*"],
"globalDependencies": [".env*"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**", "build/**"],
"cache": true
},
"lint": {
"dependsOn": ["^lint"],
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"clean": {
"cache": false
}
}
}
package.json 里的任务定义:
{
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean && rm -rf node_modules"
}
}
关键点解读:
globalEnv: 我们把NEXT_PUBLIC_*加进去了。这意味着,如果你在apps/web的.env里改了一个公开的环境变量,apps/web的构建会重新运行。这很重要,因为 Next.js 会在编译时把这些变量注入到 JS bundle 里。outputs: Next.js 的.next目录非常庞大,包含了图片优化、字体优化等。我们只缓存构建结果,不缓存.next/cache里的增量编译数据。Vite 的dist也是同理。clean: 这是一个非常有用的脚本。有时候缓存坏了,你只需要turbo run clean,然后重新构建。
第九章:故障排查 —— 当缓存失效时
即使有了最好的配置,缓存偶尔也会失效。这时候,我们需要像侦探一样去排查。
1. 查看缓存命中率
Turborepo 有一个很棒的命令,可以显示缓存的使用情况。
turbo run build --dry=verbose
这个命令不会真正运行构建,而是模拟运行。它会告诉你:
- 哪些任务命中了缓存(绿色对勾)。
- 哪些任务重新运行了(红色叉号)。
- 哪些任务被过滤掉了。
2. 为什么我的 turbo run build 比以前慢?
这通常是因为缓存被清理了。
- 你是不是清空了
node_modules? - 你是不是删除了
.turbo目录? - 你是不是换了电脑?
Turborepo 的本地缓存是存储在 .turbo 目录里的。如果你清理了它,Turborepo 就得从头开始构建。这是正常的。
3. 忽略文件的陷阱
有时候,你修改了代码,但构建没有重新运行。检查一下你的 hashIgnore。
你是不是把 src 目录下的文件忽略掉了?这会导致 Turborepo 认为文件没有变化。
{
"hashIgnore": [
// ... 其他忽略项
// "src/**" <-- 千万不要加这一行!
]
}
4. 并行 vs 串行
默认情况下,Turborepo 是并行的。这意味着如果你有 10 个包,它们会同时开始构建。
这很快,但有时候会导致磁盘 IO 爆炸(比如同时写入大量文件)。
你可以通过 --jobs 参数限制并发数。
turbo run build --jobs=4
第十章:进阶技巧 —— 增量编译与 TypeScript
React 项目通常使用 TypeScript。TypeScript 的增量编译(.tsbuildinfo)和 Turborepo 的缓存是完美配合的。
Turborepo 的哈希机制会读取 .tsbuildinfo 文件。如果你修改了一个文件,TypeScript 会更新 .tsbuildinfo。Turborepo 检测到 .tsbuildinfo 变了,就会重新运行 tsc。
但是,有时候 TypeScript 的增量编译并不完美,它可能会误报错误。
你可以尝试在 turbo.json 里强制 TypeScript 不使用增量编译(仅限开发环境):
{
"pipeline": {
"build": {
"outputs": ["dist/**"],
"env": {
"TSC_COMPILE_ON_ERROR": "true" // 允许 TypeScript 编译错误继续打包
}
}
}
}
这能避免因为一个拼写错误导致的整个构建链中断。
第十一章:CI/CD 集成 —— 让缓存成为标准流程
在本地,我们用缓存很爽。但在 CI(GitHub Actions, GitLab CI)里,我们也应该用。
GitHub Actions 示例:
name: Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- run: npm ci
- name: Install Turbo
run: npm install -g turbo
- name: Build
run: turbo run build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
关键点:
cache: 'npm':这是为了缓存node_modules,避免每次都npm install。这和 Turborepo 的构建缓存是两码事,它们互补。TURBO_TOKEN和TURBO_TEAM:这是启用远程缓存所必需的。
如果你的 CI 环境里没有 Token,Turborepo 会自动降级到本地缓存。虽然本地缓存不能跨机器共享,但至少比每次都全量构建要快得多。
第十二章:性能优化 —— 不要让缓存成为瓶颈
虽然缓存很快,但如果配置不当,它本身也会变慢。
1. 文件系统监控
Turborepo 在开发模式下(turbo run dev)会监听文件变化。
如果你的项目有几千个文件,并且使用了软链接(Symbolic Links),文件系统的监听可能会变得非常慢。
解决方案:使用 nodemon 或者 Turborepo 的 --watch 模式优化。
2. 远程缓存的大小
远程缓存会占用你的存储空间。如果你的构建产物非常大(比如包含了巨大的字体文件或二进制文件),上传和下载会非常慢。
建议:
在 turbo.json 的 outputs 里,尽量只包含编译后的代码(.js, .mjs),不要包含静态资源(.png, .svg)。
{
"pipeline": {
"build": {
"outputs": ["dist/**", "build/**"],
// "images/**" <-- 不要加这个!
}
}
}
静态资源通常由专门的工具处理(如 next/image),或者直接放在 public 目录下,不需要通过 Turborepo 的缓存机制。
第十三章:总结与展望
好了,朋友们,我们聊了很多。
回顾一下今天的核心内容:
- 哈希是核心:理解输入输出,理解为什么修改时间会触发重新构建。
- 配置是关键:
hashIgnore决定了你的缓存是否会因为误操作而失效;outputs决定了缓存命中时的反馈速度。 - 远程缓存是神器:它能让你和同事的构建时间同步为 0。
- 全局依赖要小心:不要滥用
globalDependencies。 - Base Hash 解决复杂依赖:这是大型项目的终极武器。
最后,我想说几句心里话。
在编程的世界里,速度就是幸福。
当你把构建时间从 5 分钟缩短到 30 秒,你会发现,你的心情会变好,你的代码质量会变高,你的 Bug 会变少。因为快速反馈意味着你可以更频繁地测试你的想法。
Turborepo 给我们提供了一个强大的工具,但工具本身不会自动生效。你需要去理解它,去配置它,去驯服它。
希望今天的讲座能让你在构建大型 React Monorepo 的道路上少走弯路。当你下次按下回车键,看着终端瞬间显示 Cache miss, executing 或者 Cache hit 时,你会感受到那种来自技术深处的爽快感。
现在,去优化你的 turbo.json 吧!让构建飞起来!
谢谢大家!