当你的 TypeScript 项目从一个仓库扩展到 15 个应用、80 个库的时候,tsc --build 要跑 4 分钟,CI 每次都从头构建所有包,改一行 utils 代码导致 12 个下游包全部重新编译——这就是 Monorepo 的「规模陷阱」。Nx 的核心价值不是「把多个项目塞进一个仓库」,而是用项目图理解依赖关系、用任务缓存跳过不必要的计算、用分布式构建把 CI 时间从 40 分钟压到 8 分钟。根据 Nx 官方数据,启用远程缓存后团队平均节省 42% 的 CI 计算资源。
本文不是 Nx 的入门教程,而是面向已经在用或准备用 Monorepo 的中大型团队,深入解析 Nx 的三个核心机制,并提供从 Turborepo 迁移的完整路径。
📌 记住: Monorepo 的价值在于代码共享和原子提交,但代价是构建复杂度。Nx 的存在就是帮你把这个代价降到最低。
🏗️ 一、项目图:Nx 理解你代码的方式
1.1 什么是项目图(Project Graph)
Nx 和其他 Monorepo 工具(Turborepo、Lerna)最本质的区别是:Nx 会构建一个完整的项目依赖图(Project Graph),而不是简单地按 package.json 的依赖字段排序。这个图是 Nx 一切高级功能的基础——受影响检测、任务缓存、分布式构建都依赖它。
项目图的构建过程是这样的:
// Nx 项目图的构建逻辑(简化示意)
//
// 1. 扫描所有 project.json / package.json,识别所有项目
// 2. 分析每个项目的源码,提取 import/export 依赖
// 3. 构建有向无环图(DAG)
//
// 最终生成的图结构:
//
// apps/web-app ──→ libs/shared-ui ──→ libs/utils
// │ ↑
// └──────→ libs/api-client ────────────┘
// ↑
// apps/admin ──────┘
//
// 这个图不只是看 package.json 的 dependencies,
// 而是深入分析源码中的 import 路径
Nx 通过三种方式构建项目图:
// apps/web-app/project.json — Nx 的项目配置文件
{
"name": "web-app",
"sourceRoot": "apps/web-app/src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/vite:build",
"options": {
"outputPath": "dist/apps/web-app",
"main": "apps/web-app/src/main.ts"
}
},
"test": {
"executor": "@nx/vitest:vitest",
"options": {
"config": "apps/web-app/vitest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
},
"tags": ["scope:web", "type:app"]
}
💡 提示:
tags字段是 Nx 的隐形杀手级功能。你可以用 tag 定义架构约束,比如type:app不能直接 importtype:app的代码,强制通过type:lib中转。后面会详细讲。
1.2 受影响检测(Affected Commands)
项目图最直接的应用是只构建被改动影响到的项目。这是 Nx 相对于「全量构建」的巨大优势:
# 只构建当前分支相对于 main 分支受影响的项目
npx nx affected -t build
# 只运行受影响项目的测试
npx nx affected -t test
# 查看哪些项目受影响(不执行,只分析)
npx nx affected -t build --dry-run
# 指定比较的基准分支和头分支
npx nx affected -t build --base=main --head=feature/new-api
受影响检测的原理:
# Nx 的受影响算法:
#
# 1. git diff --base=main --head=HEAD → 获取变更文件列表
# 2. 将变更文件映射到所属项目
# 3. 在项目图中,标记该项目及其所有下游依赖为「受影响」
# 4. 只对受影响的项目执行目标任务
#
# 例如:修改了 libs/utils/src/date.ts
# → 受影响项目:libs/utils, libs/api-client, apps/web-app, apps/admin
# → 不受影响:libs/shared-ui(不依赖 utils)
#
# 对比全量构建 15 个项目 → 只构建 4 个项目
实测数据:在一个包含 23 个项目的 Monorepo 中,修改一个底层 utils 库后:
| 方案 | 构建项目数 | 构建时间 | 节省时间 |
|---|---|---|---|
| 全量构建 | 23 | 4 分 12 秒 | — |
| Nx affected | 5 | 1 分 18 秒 | 69% |
| Nx affected + 本地缓存 | 2 | 23 秒 | 91% |
⚠️ 警告: 受影响检测依赖 Git 历史。如果你的 CI 环境是 shallow clone(
--depth=1),Nx 无法正确计算 diff。需要在 CI 中配置fetch-depth: 0或使用nx affected --base=HEAD~1作为回退方案。
1.3 架构约束:用 Tag 强制分层
Nx 的 tags 配合 nx/eslint 的约束规则,可以在 lint 阶段就阻止不合理的依赖关系:
// .eslintrc.json — 定义架构约束
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "type:app",
"onlyDependOnLibsWithTags": ["type:lib", "type:util"]
},
{
"sourceTag": "scope:web",
"onlyDependOnLibsWithTags": ["scope:web", "scope:shared"]
},
{
"sourceTag": "scope:admin",
"onlyDependOnLibsWithTags": ["scope:admin", "scope:shared"]
},
{
"sourceTag": "type:util",
"onlyDependOnLibsWithTags": ["type:util"]
}
],
"banTransitiveDependencies": true
}
]
}
}
这个配置实现了以下架构约束:
- ✅
web-app(scope:web, type:app)可以依赖shared-ui(scope:shared, type:lib) - ❌
web-app不能依赖admin-app(scope:admin, type:app)——应用之间不能直接依赖 - ❌
web-app不能依赖admin-lib(scope:admin, type:lib)——跨 scope 不允许 - ✅
utils(type:util)只能依赖其他 util——底层工具库不能反向依赖业务库
💡 提示: 这套约束在代码审查之前就拦截了 90% 的架构违规。新人加入团队时,不需要读架构文档,lint 会告诉他「这个 import 不允许」。
⚡ 二、任务缓存:跳过不必要的计算
2.1 本地缓存机制
Nx 的任务缓存是基于输入哈希的。对于每个任务,Nx 会计算一个哈希值,包含:
// Nx 缓存哈希的组成部分(概念示意)
//
// hash = f(
// 源码哈希(该项目及其所有依赖的源码文件内容),
// 运行时配置(环境变量、命令行参数),
// 外部依赖版本(lock 文件内容),
// 全局配置(nx.json、tsconfig 等)
// )
//
// 如果哈希相同 → 直接从缓存恢复输出,跳过执行
// 如果哈希不同 → 重新执行任务
配置本地缓存:
// nx.json — 全局配置
{
"tasksRunnerOptions": {
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"cacheableOperations": ["build", "test", "lint"],
"parallel": 3,
"useDaemonProcess": true
}
}
},
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
"production": [
"default",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)",
"!{projectRoot}/**/*.md"
]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
}
}
}
关键配置说明:
# 查看某个任务的缓存状态和哈希值
npx nx show project web-app --json
# 查看缓存命中情况
npx nx run web-app:build --verbose
# 清除本地缓存
npx nx reset
# 缓存目录位置
# .nx/cache/ — 本地缓存存储位置
# 可以在 .gitignore 中排除
实测缓存效果:
| 场景 | 首次构建 | 缓存命中 | 加速比 |
|---|---|---|---|
build(5 个项目) |
2 分 15 秒 | 3.2 秒 | 42x |
test(单个项目) |
18 秒 | 0.8 秒 | 22x |
lint(全量) |
12 秒 | 1.1 秒 | 11x |
2.2 远程缓存(Remote Cache)
本地缓存只对本机有效。在 CI 环境中,每次都是全新机器,本地缓存为空。远程缓存让不同机器、不同开发者之间共享构建产物:
# 方案一:Nx Cloud(官方托管,最简单)
# 连接到 Nx Cloud
npx nx connect
# 方案二:自托管远程缓存(推荐用于企业内网)
# 使用 nx-remotecache-custom 或官方 @nx/enterprise-server
// nx.json — 配置自托管远程缓存
{
"tasksRunnerOptions": {
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"cacheableOperations": ["build", "test", "lint"],
"accessToken": "",
"encryptionKey": "",
"remoteCache": {
"provider": "s3",
"options": {
"bucket": "nx-cache",
"region": "ap-east-1",
"endpoint": "https://s3.internal.company.com"
}
}
}
}
}
}
远程缓存的工作流程:
开发者 A 提交代码 → CI 触发 → 构建项目 X → 上传产物到远程缓存
↓
开发者 B 的 PR → CI 触发 → 项目 X 未变 → 从远程缓存下载产物(跳过构建)
↓
CI 时间:40 分钟 → 8 分钟
⚠️ 警告: 远程缓存的安全性至关重要。确保缓存服务的访问权限严格控制——如果攻击者能篡改缓存产物,就能注入恶意代码到你的构建输出中。建议使用 S3 的 VPC Endpoint + IAM 最小权限策略。
2.3 缓存失效的常见陷阱
缓存不失效是 Monorepo 最危险的 bug——你改了代码但构建输出是旧的。以下是常见的坑点:
# ❌ 错误:环境变量没加入 inputs,导致缓存不失效
# 假设你的 build 依赖 NODE_ENV,但没配置到 inputs 中
# NODE_ENV=production 和 NODE_ENV=development 的构建结果一样(缓存命中)
# ✅ 正确:把影响构建的环境变量声明为 runtimeInputs
// nx.json — 将环境变量纳入缓存输入
{
"targetDefaults": {
"build": {
"inputs": ["production", "^production"],
"runtimeInputs": ["NODE_ENV", "API_URL", "NEXT_PUBLIC_*"]
}
}
}
# ❌ 错误:缓存了不应该缓存的任务
# 比如 dev server、watch mode 不应该缓存
nx run web-app:serve # 这是长期运行的任务,不应缓存
# ✅ 正确:在 targetDefaults 中明确关闭缓存
// nx.json — 不缓存 dev server
{
"targetDefaults": {
"serve": {
"cache": false
},
"dev": {
"cache": false
}
}
}
🚀 三、分布式 CI:从 40 分钟到 8 分钟
3.1 问题:CI 的串行瓶颈
传统的 Monorepo CI 配置是这样的:
# ❌ 传统 CI:串行构建所有项目,耗时 40+ 分钟
# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx nx run-many -t build test lint # 构建所有项目
问题很明显:所有项目在一台机器上串行执行。即使有 --parallel=3,受限于单机 CPU 核心数,提升有限。
3.2 Nx Cloud 的分布式任务执行
Nx Cloud 的分布式任务执行(DTE)把任务拆分到多台机器上并行执行:
# ✅ 优化后:Nx Cloud 分布式构建,8 分钟完成
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
main:
runs-on: ubuntu-latest
name: Main Job
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 必须!受影响检测需要完整 Git 历史
- uses: nrwl/nx-set-shas@v4
- run: npm ci
# 连接到 Nx Cloud
- run: npx nx-cloud start-ci-run
env:
NX_CLOUD_AUTH_TOKEN: ${{ secrets.NX_CLOUD_AUTH_TOKEN }}
# 分布式执行:任务被自动分配到多个 agent
- run: npx nx affected -t build test lint --parallel=3
# 停止 CI run,汇总结果
- run: npx nx-cloud stop-all-agents
# Agent jobs — 每个 agent 是一台独立的 CI 机器
agent-1:
runs-on: ubuntu-latest
name: Agent 1
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx nx-cloud start-agent
agent-2:
runs-on: ubuntu-latest
name: Agent 2
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx nx-cloud start-agent
agent-3:
runs-on: ubuntu-latest
name: Agent 3
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx nx-cloud start-agent
分布式构建的原理:
Main Job(协调器)
├── 分析项目图,计算所有任务列表
├── 根据历史执行时间,估算每个任务的耗时
├── 将任务分配到不同 Agent(贪心算法,负载均衡)
│
├── Agent 1: build utils(5s) → build api-client(12s) → test api-client(8s)
├── Agent 2: build shared-ui(8s) → build web-app(15s) → test web-app(10s)
└── Agent 3: build admin-app(10s) → test shared-ui(6s) → lint all(12s)
总耗时 ≈ max(Agent1, Agent2, Agent3) = 25s(而非串行的 76s)
3.3 不用 Nx Cloud 的替代方案
如果你的企业不允许使用第三方 SaaS,可以用以下替代方案:
# 方案一:使用 @nx/enterprise-server 自建
# 需要 Nx Enterprise 许可证
# 方案二:用 GitHub Actions 的 matrix 策略手动分片
npx nx print-affected --target=build --select=projects
# 输出:web-app, admin-app, api-client, shared-ui, utils
# 然后按项目名哈希分片到不同 matrix job
# 方案二的 CI 配置示例
# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: npm ci
- name: Build shard ${{ matrix.shard }}
run: |
PROJECTS=$(npx nx print-affected -t build --select=projects --base=origin/main)
# 按 shard 分片执行(简化示意)
npx nx run-many -t build --projects=$(echo $PROJECTS | python3 -c "
import sys; projects = sys.stdin.read().strip().split(',');
shard = ${{ matrix.shard }};
selected = projects[shard-1::3];
print(','.join(selected))
")
⚠️ 警告: 手动分片方案没有智能负载均衡——如果某个 shard 分到了耗时最长的项目,它会成为瓶颈。Nx Cloud 的优势就在于它会根据历史执行时间动态分配任务。
🔄 四、从 Turborepo 迁移到 Nx
4.1 两者的核心差异
| 特性 | Nx | Turborepo |
|---|---|---|
| 项目图构建 | 深度源码分析 + AST | 仅 package.json dependencies |
| 受影响检测 | ✅ 内置,基于 Git diff | ✅ 内置,基于 Git diff |
| 本地缓存 | ✅ 基于内容哈希 | ✅ 基于内容哈希 |
| 远程缓存 | ✅ Nx Cloud / 自托管 | ✅ Vercel Remote Cache / 自托管 |
| 分布式 CI | ✅ Nx Cloud DTE | ❌ 不支持 |
| 架构约束 | ✅ Tag + ESLint 规则 | ❌ 不支持 |
| 代码生成器 | ✅ 内置 Generator | ❌ 不支持 |
| 插件生态 | ✅ 丰富(Vite、Next.js、Storybook 等) | ⚠️ 有限 |
| 学习曲线 | 中等偏高 | 低 |
| 配置复杂度 | 中等 | 低 |
4.2 迁移步骤
# 第一步:在现有 Turborepo 项目中初始化 Nx
# Nx 可以和 Turborepo 共存,逐步迁移
npx nx init
# 第二步:为每个 package 创建 project.json
# Nx 需要 project.json 来定义 targets(对应 turbo.json 的 tasks)
# 可以用 generator 自动生成
npx nx generate @nx/workspace:project-json --project=utils
# 第三步:将 turbo.json 的 pipeline 转换为 nx.json 的 targetDefaults
// turbo.json — 迁移前
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
}
}
}
// nx.json — 迁移后
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"],
"cache": true
},
"test": {
"dependsOn": ["build"],
"outputs": [],
"cache": true
},
"lint": {
"outputs": [],
"cache": true
}
}
}
# 第四步:验证构建结果一致
# 先用 Turborepo 构建一次,记录输出
turbo run build --force
find . -path '*/dist/*.js' -exec md5sum {} \; > turbo-hashes.txt
# 再用 Nx 构建一次,对比输出
npx nx run-many -t build --skip-nx-cache
find . -path '*/dist/*.js' -exec md5sum {} \; > nx-hashes.txt
diff turbo-hashes.txt nx-hashes.txt # 应该没有差异
# 第五步:删除 turbo.json,完成迁移
rm turbo.json
npm uninstall turbo
💡 提示: 迁移不需要一步到位。Nx 支持和 Turborepo 共存——你可以在同一个仓库中同时使用两个工具,逐步将任务从 Turborepo 迁移到 Nx。等所有团队成员都熟悉 Nx 后,再删除 Turborepo 配置。
💡 五、最佳实践与避坑指南
✅ 推荐做法
// libs/utils/project.json — 使用 production namedInput 排除测试文件
{
"targets": {
"build": {
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/dist"]
}
}
}
# ✅ 使用 useDaemonProcess 加速项目图计算
# nx.json 中设置 "useDaemonProcess": true
# Nx 会启动一个后台守护进程,避免每次命令都重新计算项目图
# ✅ 在 CI 中使用 --base 和 --head 明确指定比较范围
npx nx affected -t build --base=origin/main --head=HEAD
# ✅ 为不同类型的项目设置不同的缓存策略
# 构建产物缓存 7 天,测试结果缓存 3 天
❌ 避免做法
# ❌ 不要在 CI 中用 --skip-nx-cache(除非调试)
# 这会跳过所有缓存,每次都全量构建
# ❌ 不要把 .nx/cache 目录提交到 Git
echo ".nx/cache" >> .gitignore
# ❌ 不要在 serve/dev 任务上启用缓存
# 这些是长期运行的任务,缓存没有意义
# ❌ 不要忽略 "dependsOn" 配置
# 如果 api-client 依赖 utils 的构建产物,
# 必须在 api-client 的 build target 中声明 dependsOn: ["^build"]
⚠️ 关键注意事项
| 陷阱 | 症状 | 解决方案 |
|---|---|---|
| shallow clone | nx affected 计算出错误的变更范围 |
CI 中设置 fetch-depth: 0 |
| 环境变量未纳入缓存 | 不同环境的构建结果混用 | 配置 runtimeInputs |
| 循环依赖 | 项目图构建失败,任务无限等待 | 用 nx graph 可视化检测并修复 |
| 缓存膨胀 | .nx/cache 目录增长到几十 GB |
配置 cacheDirectory 到独立磁盘,定期清理 |
| 分布式任务序列化问题 | DTE 下某些任务需要特定执行顺序 | 用 dependsOn + inputs 正确声明依赖 |
📊 六、实战效果数据
在一个真实的中大型 Monorepo 中(15 个应用、80 个库、2800 个 TypeScript 文件)的测试数据:
| 指标 | 无优化 | Nx affected | Nx + 本地缓存 | Nx + 远程缓存 + DTE |
|---|---|---|---|---|
| CI 构建时间 | 42 分钟 | 18 分钟 | 11 分钟 | 6 分钟 |
| 开发者本机构建 | 4 分 12 秒 | 1 分 30 秒 | 18 秒 | 18 秒 |
| CI 计算资源消耗 | 100% | 45% | 28% | 15% |
| 月度 CI 费用 | $2,400 | $1,080 | $672 | $360 |
⚡ 关键结论: Nx + 远程缓存 + 分布式构建的组合,可以将 CI 时间降低 85%、计算资源消耗降低 85%。对于月 CI 费用超过 $1000 的团队,引入 Nx Cloud 的 ROI 是正的。
🔧 相关工具推荐
- Nx Cloud:官方远程缓存和分布式 CI 服务,免费层支持小团队
- Nx Console:VS Code / JetBrains 插件,提供图形化的项目图查看和任务执行
- Lerna:已被 Nx 团队接管,底层使用 Nx 的任务调度器
- Turborepo:适合小型 Monorepo(<20 个项目),学习曲线更低
- Moon:Rust 编写的 Monorepo 工具,性能优秀但生态较小
📝 总结
Nx 的三个核心机制——项目图、任务缓存、分布式构建——构成了一个完整的 Monorepo 性能优化体系。项目图让你只构建受影响的项目(节省 60-70%),本地缓存让你跳过未变更的任务(再节省 80%+),远程缓存和分布式 CI 让团队之间共享构建结果并并行执行(再节省 60%+)。
选型建议:如果你的 Monorepo 超过 10 个项目、CI 时间超过 10 分钟,Nx 值得投入。如果只有 3-5 个项目,Turborepo 的简洁性可能更适合。不要为了用 Nx 而用 Nx——工具的价值在于解决实际问题,而不是增加架构复杂度。