Skip to main content

Monorepo 与 Turborepo 缓存

Monorepo 的收益是共享与原子提交,代价是工具链复杂度和 CI 时间。缓存机制决定它到底是省力还是添堵。

一、收益是哪几个、代价在哪​

先把两边都摆出来,别只看一边。

收益:共享代码不用发 npm 包再逐个升级;一次改动能同时验证所有调用方;依赖版本统一;跨包的重构是原子提交,不会出现「A 改了、B 还没跟上」的中间态。

代价:仓库权限变难(所有人都能看到所有代码);CI 压力集中到一个仓库;构建时间若不优化会失控;新人面对一整个仓库的目录会比面对单个项目更慌。

所以判断标准不是「团队大不大」,而是包之间是否真的存在共享与联动。两个互不相干的项目放进同一个仓库,收获的只有代价。

一个常见的演进路径是:两个应用、一个共享目录、一个跑遍所有 npm run build 的脚本 → 共享目录变成三个包、第三个应用出现、CI 从 4 分钟涨到 26 分钟 → 有人开始靠「不重建」来提速。到这一步,问题不在 Monorepo,而在只有工作区、没有任务编排与缓存。

二、包管理器与编排器​

三层的职责要分开,混着谈最容易选错:

  • 包管理器(pnpm workspaces) —— 管依赖:谁依赖谁、版本怎么解析、本地包怎么互相引用(workspace:*)。
  • 任务编排(Turborepo / Nx) —— 管执行:按依赖图排顺序、算输入哈希、命中缓存就跳过。
  • 发版工具(changesets 一类) —— 管版本:改了哪些包、各自升多少、changelog 怎么写。

多数团队用「pnpm + Turborepo」就够;当需求扩展到生成器、项目图可视化、强制模块边界、分布式执行时,才需要考虑更完整的平台。

# pnpm-workspace.yaml:声明哪些目录是工作区
packages:
- 'apps/*'
- 'packages/*'
// 内部包用 workspace:* 引用本地版本,pnpm 会以符号链接解析,无需先发布
{ "dependencies": { "@my/ui": "workspace:*", "@my/utils": "workspace:*" } }

需要说清的是:workspaces 只解决依赖解析,它既不管任务执行,也不缓存任何东西。 只上 workspaces 不上任务编排,就是前面那个「CI 26 分钟」的由来。

三、命中与失效的判定​

一次构建算任务指纹查缓存命中指纹由这些拼起来:· 源文件内容 hash· 依赖版本 lockfile· 环境变量(要显式声明)· 构建器自身版本任一项变了这批任务全部失效重跑「本地没跑过」与「环境变量没声明」都会静默变成未命中——这是最常见的「缓存看起来不生效」。
图:任务级缓存的命中判定,以及最常被忽略的两个失效来源

缓存的判据是输入哈希。Turborepo 把每个任务的输入(源码、依赖版本、声明的环境变量、任务定义本身)算成一个哈希:

  • 哈希没变 → 命中缓存,不真正执行,直接复用上次的产物与日志;
  • 哈希变了 → 真正执行,并把产物存进缓存供下次使用。
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**"]
},
"test": { "dependsOn": ["build"], "outputs": [] },
"dev": { "cache": false, "persistent": true }
}
}

三个字段要理解准确:

  • dependsOn: ["^build"] —— 先构建所有上游依赖(^ 表示上游)。它走的是依赖图,不是目录顺序;
  • inputs —— 参与哈希的文件。任务读了不在 inputs 里的文件,缓存就会安静地骗你:文件改了但哈希没变,于是复用了旧产物。这是缓存失效类问题里最难查的一种;
  • outputs —— 命中缓存时要恢复的产物。不声明 outputs,等于什么都没缓存。

dev 那行也有讲究:常驻任务要标 persistent: true 且 cache: false,否则任务永不结束,整个流水线会挂住。

本地缓存只帮一个人。 团队要真正受益得配远程缓存——工程师 A 构建过的包,工程师 B 和 CI 直接命中。缓存接口是开放规范,既可以用托管服务,也可以自己用对象存储搭一个。

调试缓存未命中时用 --summarize 看任务摘要对比,比猜快得多。

四、版本与发布流程​

包多了之后,手动发版必然出错:谁依赖谁、该升 major 还是 patch、changelog 谁来写。

标准做法是让改动方在 PR 里声明影响(changeset 文件:改了哪些包、各自什么级别),发版时由工具统一计算版本号、生成 changelog、按依赖顺序发布。

# 只构建某个应用及其上游依赖;CI 里常用它做增量构建
turbo run build --filter=web
# 只构建受当前分支改动影响的包(Git 比对 origin/main)
turbo run build --filter="...[origin/main]"

两条纪律:

  • 不要跨包用相对路径 import(../../packages/ui),要走正式的包依赖声明,否则依赖图是断的,任务编排和缓存全部失效;
  • workspace:* 不要直接发布出去,要用支持改写的发布命令(或先 pack 检查一遍),否则消费者装到的会是一个无法解析的版本范围。

缓存有没有真的命中,第二次跑一次就知道,别只看配置:

# 第一次: Tasks: 3 successful, 3 total   Cached: 0 cached
turbo run build --filter=web

# 不改代码再跑一次,应当变成 Cached: 3 cached,耗时接近 0
turbo run build --filter=web

# 若第二次仍是 0 cached,说明有输入没被声明(环境变量、未纳入 git 的文件)

五、不该上仓的几种团队​

  • 包之间没有共享:两个独立产品放一起,只会共享 CI 与权限问题;
  • 团队很小且没有跨包改动:单体仓库更简单;
  • 只是想「看起来先进」:它会放大一切既有问题——构建慢会变得更慢,权限混乱会变得更混乱;
  • 发布节奏要求完全隔离:一方要随时发布、另一方要长周期验证时,共享仓库会互相拖累。

六、为什么这次没命中缓存​

Monorepo 不等于多个项目放一个仓库。没有任务编排与缓存,它只是放大版的单仓库,构建只会更慢。

缓存命中也不等于结果正确——环境变量和未声明的输入没进哈希时,你会拿到错误缓存,这类问题的表现就是「明明改了却没生效」。

不是所有项目都该收敛成 Monorepo:包之间没有共享、团队规模很小时,它带来的多是负担。

光上 workspaces 也不够,它只解析依赖,不跑任务也不缓存,两者要配套。

没有任务编排与缓存的 Monorepo 只是放大版的单仓库——先想清楚包之间为什么要在一起,再谈用什么工具。

缓存的输入输出怎么声明才不会误命中,Turbo 文档 的 Caching 一节讲得比实践摸索清楚。