🔍 登录 注册

pnpm workspace 与 Turborepo 的 Monorepo 工程闭环

今 今晚打老虎 2026-10-23 👁 0 ⏱ 5min

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": [] }
  }
}

三条要点:

  1. ^build 表示"上游所有依赖包的 build 先完成",这是增量正确性的来源;
  2. outputs 必须覆盖真实产物,漏一个目录等于每次全跑;
  3. 影响产物但不是源码的环境变量要显式声明,否则缓存会误命中:
"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

六、必须防的四种劣化

  1. 共享配置包反向依赖业务 → 把 eslint/tsconfig 预设放在 packages/config,它谁都不依赖,只被依赖;
  2. 循环依赖 → 加检查:
pnpm madge --circular --extensions ts,tsx packages/
  1. 幽灵依赖 → .npmrc 关 hoist,配合 eslint 的 import/no-extraneous-dependencies;
  2. 人人都能往根 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、脚本入口收敛这三条硬规矩维持。

读完了?给作者一点支持

本文采用 CC BY 4.0 协议转载 · © 今晚打老虎

评论(0)

登录 后参与评论。

  • 还没有评论,来抢沙发。

🔗 相关文章