目录
一、一次「缓存命中」引发的事故
CI 日志里,build 任务绿色的,耗时 8 秒,输出一行 cache hit。部署成功。十分钟后,客服反馈:新上线的页面还在请求上周的 API 地址。
回滚、查日志、翻提交记录,最后发现问题不在代码:构建缓存命中了上周的产物,而这一周唯一的改动,是 CI 里新加的一个环境变量。
这是一类很典型的 monorepo 事故。它看起来像「缓存不可靠」,实际上是另一件事:缓存键没有覆盖所有会影响产物的输入,而没有任何工具能替你知道那个变量存在。
事故之后,团队通常会做一件更危险的事:打开一张 Monorepo 工具对比表,开始比较 Turborepo、Nx、pnpm workspaces 的功能勾选。但那张表常常从一开始就问错了问题——这三样东西并不在同一个竞争维度上。
二、先分层:它们根本不在一张表里
把 monorepo 拆成三层,关系会清楚很多:
| 层 | 回答的问题 | 代表工具 | 它不知道什么 |
|---|---|---|---|
| 依赖层 | 包怎么解析、安装、链接、发布? | pnpm workspaces | 任务顺序、产物缓存 |
| 任务层 | 跑哪些任务、按什么顺序、能否复用上次结果? | Turborepo | 包怎么装、源码里的 import 关系 |
| 项目图层 | 仓库里有哪些项目、谁依赖谁、哪些约束必须成立? | Nx | 它也能管前两层,但抽象更厚 |
关键结论只有一句:Turborepo 和 Nx 在任务层与项目图层上存在竞争,pnpm workspaces 和它们不竞争。 pnpm 可以和两者中的任意一个组合,也可以两个都不用。
很多选型讨论之所以吵不出结果,是因为把「我要不要 workspace 协议」和「我要不要远程缓存」放进了同一个问题里。前者是包管理,后者是构建编排,解决的是完全不同的成本。
上面这张图只属于任务层。依赖层不参与这个 hash,它只决定「装进来的东西是不是同一份」。项目图层则决定「哪些任务有资格进入这张图」。三层混在一起,就会出现「缓存命中但依赖装错」或「依赖对了但任务没跑」这类看起来互相矛盾的现象。
三、依赖层:pnpm workspaces 把「隐式可见」变成「显式声明」
先从一个最小配置开始。
packages: - "apps/*" - "packages/*"包之间互相引用,用 workspace: 协议:
{ "name": "@acme/web", "dependencies": { "@acme/ui": "workspace:*", "react": "catalog:" }}这两行分别解决两个不同的问题。
1. workspace:*:本地链接,而不是从 registry 下载
没有这个协议时,团队常见的做法是「先 pnpm build 被依赖的包,再手动改版本号」。workspace:* 让 pnpm 直接把 packages/ui 链接进 apps/web,本地改动即时可见。
到了发布阶段,pnpm 会在打包和发布时把 workspace:* 改写成实际版本号。这个行为很重要:它意味着发布必须走 pnpm publish,而不是 npm publish。后者不认识这个协议,要么直接报错,要么在产出的包里留下一个无法安装的 workspace:* 依赖。这类问题通常不会在本地暴露,只会在用户安装时出现。
2. 严格的 node_modules:让幽灵依赖当场报错
npm 和 Yarn 的默认提升(hoisting)会把依赖尽量摊平到根 node_modules。结果是:你的代码 import 了一个没有写进 package.json 的包,本地依然能跑,因为它是被别的包顺带提升上来的。这就是幽灵依赖(phantom dependency)。
pnpm 默认不为每个包创建扁平目录,而是通过符号链接只暴露该包声明的依赖。没声明就找不到,import 当场失败。
这经常被当成「pnpm 太严格」,但它其实是把一类线上事故提前到了开发阶段:依赖声明不完整时,你希望它在本地报错,还是在别人 pnpm install --prod 之后报错?
有些团队为了兼容老代码,会打开 shamefully-hoist=true 或 node-linker=hoisted。这不是不能做,但要知道自己在做什么:你换回了扁平结构,也换回了幽灵依赖。
3. catalog:把「同一个依赖的版本」收成一份
多包仓库里另一个高频问题是版本漂移:apps/web 用 React 19.0,packages/ui 用 React 19.1,类型定义偶尔还能对上,运行时行为却开始分叉。
catalog 把版本集中声明:
packages: - "apps/*" - "packages/*"
catalog: react: ^19.0.0 react-dom: ^19.0.0 typescript: ^5.6.0各包引用时写 "react": "catalog:"。升级时只改一处,所有包一起动。
pnpm 不负责什么
pnpm -r build 会按 package.json 里的依赖关系做拓扑排序,然后依次执行。它不分析源码 import,也不知道 build 的产物会不会被下游的 test 使用,更没有内容寻址的任务缓存——源码没变,它照样重跑。
如果你只需要统一安装、本地链接和发布顺序,pnpm workspaces 已经够了。如果你需要「第二次构建 8 秒完成」,那是下一层的事。
四、任务层:Turborepo 的缓存键是一份你自己维护的清单
Turborepo 的核心不是「并行跑任务」,而是用 hash 判断一个任务能不能跳过。官方文档里的缓存键大致由这些部分组成:
- 任务定义本身(脚本内容、
turbo.json中的配置); - 该包的输入文件;
- 依赖任务的 hash(当
dependsOn声明了上游任务时); - lockfile 与外部依赖版本;
- 声明过的环境变量;
- 全局依赖文件(
globalDependencies)。
一份能用的配置通常长这样:
{ "$schema": "https://turborepo.dev/schema.json", "globalDependencies": ["tsconfig.base.json"], "globalEnv": ["CI"], "tasks": { "build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", ".env.production"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"], "env": ["NEXT_PUBLIC_API_URL"] }, "test": { "dependsOn": ["build"], "outputs": [] }, "lint": {} }}几个容易读错的点:
dependsOn: ["^build"] 和 ["build"] 不是一回事。 带 ^ 表示「先跑所有依赖包的 build」;不带 ^ 表示「先跑本包的 build」。前者构建依赖图,后者构建同包内的任务顺序。
inputs 一旦写了,就替换默认输入集合。 想加文件而不是替换,要用 $TURBO_DEFAULT$。很多人写 "inputs": ["src/**"],以为只是缩小范围,实际上把 tsconfig.json、vite.config.ts、.env 全部移出了 hash。改配置不触发重建,问题在几周后才以「怎么改了没生效」的形式出现。
outputs 决定缓存保存什么,也决定命中后还原什么。 漏了 .next/**,缓存会命中,构建会「成功」,但部署目录里没有产物。CI 不会报错,因为退出码是 0。
env 和 passThroughEnv 的语义不同。 env 里的变量参与 hash;passThroughEnv 只是把变量传给任务进程,不进 hash。后者适合 token 这类「每次不同但不影响产物」的值;如果把影响产物的变量放进去,就会稳定复现本文开头那次事故。
三个真实的失效现场
现场一:没声明的环境变量。 NEXT_PUBLIC_* 或 VITE_* 这类变量会在构建时被内联进产物。CI 改了变量,hash 没变,缓存命中,旧地址被打进 bundle。修复方式不是关缓存,而是把变量声明进 env。
现场二:outputs 漏项。 远程缓存只保存你声明的路径。命中的机器拿到一份「构建成功但缺文件」的工作区。这个问题最隐蔽的地方在于,它和构建失败长得完全不一样。
现场三:工具版本没进 hash。 升级 TypeScript、Vite 或某个代码生成器,产物可能变化。lockfile 通常会覆盖这些,但如果工具来自全局安装或 Docker 基础镜像,lockfile 里没有它。globalDependencies 或固定镜像版本是更稳的做法。
Turborepo 的远程缓存还带来一个组织问题:缓存是一个共享的产物仓库。 能写缓存的人,就能影响别人的构建产物。公开 CI、外包贡献者、多租户缓存服务都会放大这个风险。至少要做到:缓存写入凭据只给可信的 CI,读取可以放开;定期清理;不要把缓存当成不可变的事实来源。
任务层也不负责什么
Turborepo 不安装依赖,不分析源码里的 import,也不做版本管理。它看到的依赖关系主要来自各包 package.json 里声明的依赖。一个通过 tsconfig 路径别名引用、却没有写进 package.json 的包,在它的图里可能根本不存在。
五、项目图层:Nx 把仓库结构变成可查询对象
Nx 多做的那一层,可以概括成一句话:它不只读取你声明的配置,还分析你的代码。
1. 项目图来自源码,而不只是 package.json
Nx 通过插件解析 import、tsconfig 路径别名和框架配置,构建项目图。之后可以:
nx graphnx affected -t build test --base=origin/main --head=HEADaffected 的价值在于把「这次改了哪些项目」从猜测变成图上的可达性计算。对 CI 来说,这决定了是一次全量构建,还是只构建受影响的子图。
2. 任务可以推断,不必逐个声明
Nx 插件会从 vite.config.ts、next.config.js、jest.config.ts 这类文件推断出 build、test、lint 等任务。项目增多后,这能省掉大量重复配置;代价是「任务从哪来」变得不直观,排查时需要看插件推断结果。
3. 架构约束可以变成 lint 错误
这是 Nx 相对前两层最不一样的能力。给项目打标签,然后声明谁可以依赖谁:
{ "depConstraints": [ { "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:feature", "type:ui", "type:util"] }, { "sourceTag": "type:ui", "onlyDependOnLibsWithTags": ["type:ui", "type:util"] } ]}放进 ESLint 后,feature 直接引用另一业务的内部实现会变成一条 lint 错误。架构文档会过期,lint 规则不会。对多人协作的 monorepo 来说,这往往比缓存更值钱。
4. 代价:更厚的抽象层
Nx 能管依赖安装和任务缓存,但它的能力建立在一层插件与推断之上。项目结构、插件版本、配置约定之间耦合更紧;出问题时,你需要先理解 Nx 认为你的仓库是什么样,再排查实际发生了什么。
另外,Nx 的缓存同样依赖输入声明。namedInputs 里的 default、production、sharedGlobals 如果漏掉了根配置,失效方式和 Turborepo 一模一样:缓存工具不会让错误的输入声明变正确。
六、选型:先问四个问题,别看功能表
问题一:你现在的痛点是哪一层?
- 装依赖慢、版本漂移、幽灵依赖、发布顺序乱 → 先上 pnpm workspaces。
- 装得挺好,但 CI 每次全量跑、构建时间长 → 加 Turborepo 或 Nx 的任务缓存。
- 仓库结构失控、跨项目引用没人管、改一处不知道影响谁 → 需要项目图层。
问题二:谁维护缓存输入声明?
缓存正确性不是工具属性,是团队责任。环境变量、生成文件、代码生成器版本、根配置,每一样都要有人负责声明。没有这个 owner,远程缓存只会把错误传播得更快。
问题三:团队愿意接受多厚的抽象?
Turborepo 的心智模型接近「给 npm scripts 加一层缓存与并行」,配置面小;Nx 的心智模型是「一个能理解代码结构的平台」,能力强,学习和调试成本也更高。两者都能和 pnpm 组合。
问题四:这个仓库真的该是 monorepo 吗?
以下情况,拆仓往往更省事:
- 各产品发布节奏完全独立,强制锁步只会互相拖累;
- 团队之间需要代码级权限隔离;
- CI 的爆炸半径已经大到一次改动要跑两小时;
- 工具链差异极大,共享配置的收益低于维护成本。
Monorepo 真正省下的是「跨项目修改的协调成本」,代价是「所有人共享一套构建与依赖决策」。如果协调本来就不频繁,这笔交易不划算。
七、一条最小落地路径
不要一次上齐三层。按痛点加,能让每一步都可回退:
# 1. 依赖层:先让 workspace 跑起来pnpm init# 写 pnpm-workspace.yaml,把 apps/* 和 packages/* 纳入pnpm install
# 2. 任务层:只给最慢的任务加缓存pnpm add -D -w turbo
# 3. 第一次运行,观察哪些任务真的在重复执行pnpm exec turbo run build test --filter=@acme/web...
# 4. Docker 构建:只带必要的包和 lockfilepnpm exec turbo prune @acme/web --docker--filter=@acme/web... 末尾的三个点表示「该包及其依赖」。turbo prune 会生成一个只包含目标包及其依赖子图的目录,适合做多阶段 Docker 构建,避免把整个仓库复制进镜像。
至于 Nx,等到你真的需要「查询仓库结构」或「把架构约束变成错误」时再引入。为了一个还不存在的需求提前上平台,通常只会得到更厚的配置和更慢的排查。
八、结语
回到开头那次事故。缓存没有坏,pnpm 没有坏,代码也没有坏。坏掉的是一份没人维护的输入清单。
Monorepo 工具选型最容易犯的错,是把三层当成三个竞品,然后比较谁的功能多。更有效的问法是:
- 我在哪一层疼?
- 这一层的正确性由谁负责?
- 我愿意为它维护多少声明?
工具能把任务跑得更快,但只有人能为「这次跳过是安全的」签字。