Nx 和 Turborepo 的缓存与依赖图,谁能让 monorepo 前端 CI 只构建你改过的那几个包
Nx 和 Turborepo 在缓存与依赖图这件事上走的是两条路线:Turborepo 靠“内容哈希 + 任务拓扑”做到极简够用,Nx 则用“分布式缓存 + 可插拔哈希输入 + 项目图 API”把增量构建做成了平台能力。如果你的团队已经在用 pnpm workspace 且只需要“改了什么就构建什么”,Turborepo 上手成本接近零;如果你需要跨 CI 机器共享缓存、细粒度控制某个包的哈希输入、或者构建图里有大量非 JS 任务,Nx 的投入会更快回本。
下面从四个维度拆开对比。
缓存命中机制:Nx 的细粒度控制明显更强
Turborepo 的缓存键由 turbo.json 里的 inputs 和 dependsOn 决定,默认输入是包目录下所有被 git 跟踪的文件。turbo 会对这些文件计算哈希,再叠加环境变量、依赖任务哈希,得到一个全局缓存键。命中则跳过任务。这玩意儿的好处是零配置时行为可预测,坏处是当你想排除某些文件时,得手动写 glob。
// turbo.json
{
"pipeline": {
"build": {
"inputs": ["src/**", "tsconfig.json", "!**/*.test.ts"],
"outputs": ["dist/**"]
}
}
}
Nx 的输入控制点更多。每个项目的 targetDefaults 或项目级 targets 里可以指定 inputs,支持 {projectRoot}、{workspaceRoot}、runtime、env 等变量。Nx 还允许为每个 target 单独定义 namedInputs,比如:
// nx.json
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"]
},
"targetDefaults": {
"build": {
"inputs": ["production", "^production"],
"cache": true
}
}
}
这里的 ^production 表示“加上所有依赖项目的 production 输入”。Turborepo 的 dependsOn 也会传递哈希,但 Nx 把“自身文件变化”和“依赖变化”拆成两个正交维度,排查缓存失效时更直观。
Turborepo 从 1.8 开始支持 turbo 的远程缓存,需要自己搭 Vercel 的缓存服务或者用 --api 指向自建服务。Nx 的 Nx Cloud 提供分布式缓存,免费额度对中小团队够用,私有化部署也有明确路径。单从缓存命中率看,两者在“只改一个包”的场景下都能做到只跑那个包的任务,但 Nx 的哈希输入可定制性在边缘场景(比如某个包的构建依赖一个不在仓库里的环境文件)下更不容易误判。
依赖图:Turborepo 靠约定,Nx 靠显式生成
Turborepo 的依赖图来自 package.json 的 workspace:* 协议和 turbo.json 的 dependsOn。它不关心你用的是 npm、yarn 还是 pnpm,只要 workspace 协议能解析出依赖关系就行。这带来一个限制:如果你的 repo 里有非 npm 包(比如 Rust crate、Go module 或者 Python 包),Turborepo 无法把它们纳入任务依赖图,除非你在 dependsOn 里手动声明任务级依赖。
Nx 则要求每个项目有一个 project.json 或从 package.json 的 nx 字段读取配置,项目之间的关系通过 imports、exports、tags 等元数据显式声明。Nx 的守护进程会扫描源码里的 import 语句,自动推断项目依赖,也可以手动在 project.json 里写 "implicitDependencies"。这意味着 Nx 能构建出比包管理器更细的依赖图——比如同一个包里的两个子项目,或者一个包依赖另一个包的某个特定目录。
实际效果对比:假设你有 40 个包,改动了 @acme/ui 里的一个按钮组件。Turborepo 会计算出所有依赖 @acme/ui 的包需要重新构建,这没问题。Nx 也会做同样的事,但如果你在 @acme/ui 内部拆了 button 和 modal 两个子项目,Nx 可以只构建 button 及其下游,modal 的下游不动。这个粒度差异在包数量多、包内部结构复杂时会被放大。
Turborepo 的 --filter 语法很灵活,支持 --filter=./packages/ui... 这种“包含依赖”的写法,也支持 --filter=@acme/ui^... 这种“包含被依赖者”的写法。Nx 的 nx affected --target=build 配合 --base=main 也能达到类似效果,但 Nx 的 affected 命令基于 git diff 和项目图计算,比 Turborepo 的 filter 更接近“只构建受影响的项目”这个语义。
CI 集成:Turborepo 简单直接,Nx 需要更多配置但回报更高
Turborepo 在 CI 里的用法通常是:
# .github/workflows/ci.yml
- name: Build
run: npx turbo run build --filter=[HEAD^1]
它假设你每个 PR 只改少量包,用 --filter 限制构建范围。缓存默认放在本地 .turbo 目录,跨 CI 机器共享需要配置远程缓存。Turborepo 不提供内置的分布式任务执行,多个任务在同一台机器上按拓扑排序串行或并行跑。
Nx 的 CI 集成更重,但带来的收益是任务可以在多台机器上分布式执行。nx affected --target=build --base=origin/main 会先计算受影响项目,然后通过 Nx Cloud 分发任务。Nx 的 nx-cloud 命令可以自动分片任务:
npx nx-cloud start-ci-run --stop-agents-after=build
npx nx affected --target=build --base=origin/main --parallel=3
对于 100+ 包的 monorepo,Turborepo 单机并行可能受限于机器核数,Nx 的分布式执行能把构建时间从 40 分钟压到 10 分钟以内。但如果你只有 20 个包、CI 机器配置不低,这个优势不明显。
一个容易忽略的点:Turborepo 的缓存是任务级的,Nx 的缓存也是任务级的,但 Nx 额外支持“原子化缓存恢复”——如果某个任务的缓存未命中,Nx 会尝试从远程缓存拉取该任务的输出,而不影响其他任务。Turborepo 的远程缓存拉取是批量进行的,粒度粗一些。
实际选型建议
选 Turborepo 的场景:
- 团队 ≤ 15 人,包数量 10-50 个,构建任务以 JS/TS 为主
- 已经在用 pnpm/npm/yarn workspace,不想引入额外项目配置
- CI 是单机执行,没有多机分布式需求
- 想要“开箱即用”的缓存,能接受全局哈希偶尔误失效
选 Nx 的场景:
- 团队 ≥ 20 人,包数量 50+,或者包内部有子项目结构
- 需要跨 CI 机器共享缓存,且对缓存命中率敏感
- 构建图里有非 JS 任务(Rust、Go、Python、原生模块)
- 需要细粒度控制每个 target 的哈希输入,或者需要
affected的精确语义 - 愿意投入时间配置
project.json、namedInputs和 Nx Cloud
两者没有绝对的优劣势。Turborepo 的哲学是“约定优于配置”,用一套简单规则覆盖 80% 的场景。Nx 的哲学是“显式优于隐式”,用更多配置换取边缘场景下的可控性。我自己的经验是:小团队用 Turborepo 基本不会后悔,大团队用 Nx 的长期收益会超过初期的学习成本。
常见问题
Turborepo 和 Nx 的缓存可以同时用吗?
不建议。两者都维护自己的缓存目录(.turbo 和 .nx/cache),同时运行会产生两套缓存键和两套失效逻辑,排查问题时容易精神分裂。选一个作为任务编排和缓存层,另一个如果已经在用,逐步迁移过去。
Nx 的 affected 和 Turborepo 的 --filter 到底差在哪?
affected 是基于 git diff 和项目依赖图计算的,它回答的问题是“从 --base 到当前 HEAD,哪些项目受到了影响”。--filter 是基于你指定的过滤条件选择项目,它不关心 git 历史,只关心你给的表达式。两者都能达到“只构建改动的包”的效果,但 affected 的语义更精确,--filter 更灵活(你可以用 --filter=./packages/ui... 这种语法构建任意项目集合)。
Turborepo 的远程缓存怎么搭?
官方推荐用 Vercel 的 Remote Cache,免费额度 10GB/月。自建的话,可以部署一个兼容 Turborepo API 的服务(比如 turbo-cache-server),然后在 turbo.json 里设置 "remoteCache": { "signature": true },CI 里通过 TURBO_API 和 TURBO_TEAM 环境变量指向自建服务。Nx Cloud 的私有化部署需要企业版授权。
如果我的 monorepo 里有非 npm 包,Turborepo 真的不能用吗?
可以用,但需要手动在 turbo.json 里声明任务依赖。比如一个 Rust crate 的构建任务是 cargo build,你需要在 pipeline 里写清楚它依赖哪些 JS 包的任务,这样 Turborepo 才能正确排序。Nx 对非 JS 项目的支持更好,因为它不依赖包管理器的依赖图,而是用项目图 API 统一建模。