pnpm workspace 与 Turborepo 的 Monorepo 工程闭环
Monorepo 的收益很明确:原子化跨包变更、统一依赖版本、复用工程配置。但它同样容易劣化成"一个巨型 node_modules 加一堆脚本"。真正的价值来自缓存与增量:只构建受影响的部分。本文给一套 pnpm + Turborepo 的落地配置。
一、目录与包边界
okgoes/
apps/
web/ # Next/Nuxt 站点
admin/ # 后台
backrender/ # EJS MVC 站点
packages/
ui/ # 组件库(无业务)
api-client/ # OpenAPI 生成产物
config/ # eslint/tsconfig/prettier 预设
utils/
turbo.json
pnpm-workspace.yaml
边界纪律:只有 apps 可以依赖 packages,packages 之间单向依赖、禁止成环。环一旦出现,增量计算退化,缓存全部失效。
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
# .npmrc
shamefully-hoist=false
strict-peer-dependencies=true
auto-install-peers=true
pnpm 的硬链接 + 符号链接结构能省下大量磁盘与安装时间,但副作用是幽灵依赖暴露——所有"能 import 但没在 package.json 里声明"的代码会报错。这是好事,一次修干净即可。
二、任务图与缓存契约
Turborepo 的能力全在输入输出声明上。声明错了,缓存就是错的。
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json", "package.json", "../../packages/ui/src/**"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": { "dependsOn": ["^build"], "outputs": [] },
"test": {
"dependsOn": ["build"],
"inputs": ["src/**", "test/**", "vitest.config.ts"],
"outputs": ["coverage/**"]
},
"dev": { "cache": false, "persistent": true },
"typecheck": { "dependsOn": ["^build"], "outputs": [] }
}
}
三条要点:
^build表示"上游所有依赖包的 build 先完成",这是增量正确性的来源;outputs必须覆盖真实产物,漏一个目录等于每次全跑;- 影响产物但不是源码的环境变量要显式声明,否则缓存会误命中:
"build": { "env": ["NEXT_PUBLIC_API_BASE", "BUILD_COMMIT"], "outputs": ["dist/**"] }
三、第一次跑通与收益度量
pnpm install --frozen-lockfile
pnpm turbo run build --filter="..." --dry-run=text # 看任务图是否正确
pnpm turbo run build
pnpm turbo run build # 第二次应全 cache hit
收益要看三个数字:
# 冷构建 vs 增量构建时间
time pnpm turbo run build --force
time pnpm turbo run build
# 只跑受影响包
pnpm turbo run test --filter="...[origin/main]"
典型大型仓库能拿到全量构建 60~80% 的时间节省,PR 检查从 12 分钟降到 2~3 分钟。
四、CI 里接远程缓存与受影响集
没有远程缓存,本地收益一半都拿不到,因为 CI 每次从零开始。
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build lint test --filter="...[origin/${{ github.base_ref }}]" --remote-only
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURDO_REPO: ${{ secrets.TURBO_TEAM }}
TURBO_API: https://turbo-cache.internal
要点:--filter="...[origin/main]" 只跑相对 base 分支有变更的包及其依赖者,这是 PR 提速的最大来源;--remote-only 防止本地未共享缓存污染结论。
自建有两条路:Vercel 远程缓存(省事)或 MinIO/S3 兼容后端自建(数据不出网)。
# 自建:走支持 HTTP 的缓存服务
pnpm turbo run build --remote-cache-api="https://cache.internal/v8/artifacts"
五、版本与发布的现实选择
两种流派:
- 固定版本(fixed versioning):所有包同版本号一起发,语义简单、CHANGELOG 好写;
- 独立版本(independent):只有变动的包升版本,发布频率低但依赖解析复杂。
大多数内部 monorepo 用不上语义化发布,最省事的做法是:私有包用 workspace:*,发布走 changesets 或干脆不发布(只走 Git 依赖 + CI 构建产物)。
pnpm changeset
pnpm version-packages
pnpm release # turbo run build && publish
六、必须防的四种劣化
- 共享配置包反向依赖业务 → 把 eslint/tsconfig 预设放在
packages/config,它谁都不依赖,只被依赖; - 循环依赖 → 加检查:
pnpm madge --circular --extensions ts,tsx packages/
- 幽灵依赖 →
.npmrc关 hoist,配合 eslint 的import/no-extraneous-dependencies; - 人人都能往根 package.json 塞脚本导致入口爆炸 → 只保留
build/lint/test/dev/typecheck五个入口,具体命令下沉到包内。
再加一条工程约束:类型检查、lint、测试在 CI 强制,本地 pre-commit 只跑改动文件的快速检查(lint-staged)。全量放提交前 hook 会让人绕过 hook。
小结
判断 monorepo 是否成功的标准只有一个:PR 的 CI 时长是否随仓库规模保持平坦。做到这点需要三块——任务图与 cache 契约声明准确、CI 接远程缓存 + 受影响集过滤、包边界单向且不成环。pnpm 解决安装与依赖正确性,Turborepo 解决增量与缓存,两者都不负责治理纪律;纪律靠 packages/* 不得依赖 apps/*、循环检测进 CI、脚本入口收敛这三条硬规矩维持。
评论(0)
登录 后参与评论。