Nx 和 Turborepo 的缓存与依赖图,谁能让 monorepo 前端 CI 只构建你改过的那几个包

Nx 和 Turborepo 在缓存与依赖图这件事上走的是两条路线:Turborepo 靠“内容哈希 + 任务拓扑”做到极简够用,Nx 则用“分布式缓存 + 可插拔哈希输入 + 项目图 API”把增量构建做成了平台能力。如果你的团队已经在用 pnpm workspace 且只需要“改了什么就构建什么”,Turborepo 上手成本接近零;如果你需要跨 CI 机器共享缓存、细粒度控制某个包的哈希输入、或者构建图里有大量非 JS 任务,Nx 的投入会更快回本。

下面从四个维度拆开对比。

缓存命中机制:Nx 的细粒度控制明显更强

Turborepo 的缓存键由 turbo.json 里的 inputsdependsOn 决定,默认输入是包目录下所有被 git 跟踪的文件。turbo 会对这些文件计算哈希,再叠加环境变量、依赖任务哈希,得到一个全局缓存键。命中则跳过任务。这玩意儿的好处是零配置时行为可预测,坏处是当你想排除某些文件时,得手动写 glob。

// turbo.json
{
  "pipeline": {
    "build": {
      "inputs": ["src/**", "tsconfig.json", "!**/*.test.ts"],
      "outputs": ["dist/**"]
    }
  }
}

Nx 的输入控制点更多。每个项目的 targetDefaults 或项目级 targets 里可以指定 inputs,支持 {projectRoot}{workspaceRoot}runtimeenv 等变量。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.jsondependsOn。它不关心你用的是 npm、yarn 还是 pnpm,只要 workspace 协议能解析出依赖关系就行。这带来一个限制:如果你的 repo 里有非 npm 包(比如 Rust crate、Go module 或者 Python 包),Turborepo 无法把它们纳入任务依赖图,除非你在 dependsOn 里手动声明任务级依赖。

Nx 则要求每个项目有一个 project.json 或从 package.jsonnx 字段读取配置,项目之间的关系通过 importsexportstags 等元数据显式声明。Nx 的守护进程会扫描源码里的 import 语句,自动推断项目依赖,也可以手动在 project.json 里写 "implicitDependencies"。这意味着 Nx 能构建出比包管理器更细的依赖图——比如同一个包里的两个子项目,或者一个包依赖另一个包的某个特定目录。

实际效果对比:假设你有 40 个包,改动了 @acme/ui 里的一个按钮组件。Turborepo 会计算出所有依赖 @acme/ui 的包需要重新构建,这没问题。Nx 也会做同样的事,但如果你在 @acme/ui 内部拆了 buttonmodal 两个子项目,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.jsonnamedInputs 和 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_APITURBO_TEAM 环境变量指向自建服务。Nx Cloud 的私有化部署需要企业版授权。

如果我的 monorepo 里有非 npm 包,Turborepo 真的不能用吗?

可以用,但需要手动在 turbo.json 里声明任务依赖。比如一个 Rust crate 的构建任务是 cargo build,你需要在 pipeline 里写清楚它依赖哪些 JS 包的任务,这样 Turborepo 才能正确排序。Nx 对非 JS 项目的支持更好,因为它不依赖包管理器的依赖图,而是用项目图 API 统一建模。