我们的前端团队有 10 个人,对外维护着 8 个独立的 npm 包:一套 React 组件库、一个 HTTP 客户端、一个表单引擎、一个 icons 包、两个业务 SDK、一个文档站点、一个 CLI 工具。最早是 8 个独立仓库,每次组件库升级,下游 SDK 要联动改一遍,文档站又得等 SDK 发版,一次跨包改动要在 5 个仓库之间切来切去。今年我们把所有包合并到了一个 Monorepo,用 pnpm 管依赖、Turborepo 管任务编排。本文记录这套工作流是怎么搭起来的,以及踩过的几个典型坑。

一、为什么是 pnpm:软链接 + 严格依赖隔离

在 npm 和 yarn 早期,Monorepo 用的是 node_modules 扁平化(hoisting)。所有依赖都被提升到根目录的 node_modules,子包即使没在 package.json 里声明,也能间接 require 到。这就是臭名昭著的"幽灵依赖"(phantom dependency):一旦某个包被提升走了,另一个依赖它的包就会突然报错,而且只在生产环境才暴露。

pnpm 的设计从根本上解决了这个问题,靠的是三层机制:

  • 全局 store:所有包只在全局 store 存一份(按内容寻址,hash 相同的包只存一次),项目里的 node_modules 全是软链接。10 人团队下,磁盘占用从 npm 的 1.8GB 降到 300MB 左右。
  • 非扁平的 node_modules:每个包的 dependencies 直接软链到自己的 node_modules/.pnpm无法访问未声明的依赖
  • peer dependency 自动隔离:不同包对同一个 peer(比如 React 17 / 18)可以并存,互不污染。
维度npm workspacesyarn workspacespnpm workspaces
node_modules 结构扁平(hoisting)扁平(hoisting)软链接 + 隔离
幽灵依赖存在存在从机制上杜绝
磁盘占用高(每仓重复存)中(有 zero-install)低(全局 store)
安装速度(10 包仓库)42s31s18s
peer dependency 多版本冲突冲突并存

二、目录结构与 workspace 配置

我们的目录长这样,packages 放对外发布的包,apps 放内部应用,tools 放构建脚本:

monorepo/
├── apps/
│   ├── docs/            # 组件库文档站(VitePress)
│   └── playground/      # 本地联调用的 sandbox 应用
├── packages/
│   ├── ui/              # React 组件库(核心)
│   ├── http/            # HTTP 客户端
│   ├── form-engine/     # 表单引擎
│   ├── icons/           # 图标包
│   ├── sdk-a/           # 业务 SDK A
│   ├── sdk-b/           # 业务 SDK B
│   ├── cli/             # 内部 CLI 工具
│   └── shared/          # 跨包共享的工具与类型
├── tools/
│   └── release/         # 发布脚本
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
└── tsconfig.base.json

workspace 的声明非常简单,就一个 yaml:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
  - 'tools/*'

包之间的引用用 workspace: 协议,这样 pnpm 会保证引用的永远是当前仓库内的版本,而不是去 registry 拉一份:

// packages/http/package.json
{
  "name": "@tubinan/http",
  "version": "2.4.1",
  "dependencies": {
    "@tubinan/shared": "workspace:^1.2.0",
    "axios": "^1.6.0"
  }
}

关键点:workspace:^ 在发布时会被 pnpm 自动替换成实际版本号(如 ^1.2.0),开发期则指向本地包,做到了"开发期本地联动,发布期版本准确"。

三、Turborepo:任务编排与缓存

Monorepo 最大的工程痛点是任务调度。假设 8 个包都要 build,互相之间又有依赖,用 pnpm -r build 是并行全跑,会乱序;用 npm-run-all 又得手动写依赖图。Turborepo 解决两件事:按依赖关系拓扑排序,以及任务结果缓存

核心配置在 turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["tsconfig.base.json", "env.d.ts"],
  "globalEnv": ["NODE_ENV", "CI"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "build/**"],
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"],
      "inputs": ["src/**", "test/**", "vitest.config.ts"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    }
  }
}

几个字段的含义必须讲清楚,调错时全靠这些细节:

  • dependsOn: ["^build"]:先把这个包依赖的包的 build 跑完(^ 表示上游)。
  • outputs:声明产物路径,Turbo 会把这些文件做 hash 缓存。下次如果 inputs 没变,直接从缓存恢复,跳过实际构建
  • inputs:决定缓存命中的输入范围。不写的话默认是包内所有文件,容易因为一个无关的 README 改动导致缓存失效。
  • devclean 必须设 cache: false,否则 dev server 会被错当成可缓存任务。

实际效果:第一次 pnpm turbo build 大概 95 秒跑完 8 个包;只改了 packages/icons 之后再 build,Turbo 只重跑 icons 和它的下游(ui、docs),其余 6 个包全部命中缓存,整体只要 14 秒。

$ pnpm turbo build
• Packages changed: icons
• build cache hit: 6/8
  @tubinan/http        6.4ms  cache hit
  @tubinan/form-engine 7.1ms  cache hit
  @tubinan/icons       412ms  FULL BUILD
  @tubinan/ui          8.9s   FULL BUILD (depends on icons)
  docs                 3.2s   FULL BUILD (depends on ui)
  ...
 Tasks: 8 successful, 8 total / Time: 14.3s

远程缓存:CI 上的杀手锏

Turborepo 的远程缓存(Remote Cache)让多个 CI runner、多个开发者共享同一份构建缓存。配置后,本地 build 命中的产物,CI 上也能直接命中。在 10 人团队里,它带来的收益是:CI 上的平均构建时间从 4 分 12 秒降到了 1 分 40 秒。

# .turbo/config.json(或环境变量 TURBO_TOKEN / TURBO_TEAM)
{
  "apiurl": "https://vercel.com/api",
  "team": "tubinan",
  "token": "your-token"
}

自托管方案可以用 Turborepo 的 Remote Cache Server(开源),把缓存存到自己的 S3,避免把代码产物上传到第三方。

四、变更影响分析:知道改一行影响谁

Monorepo 里一个常见顾虑是"我改了 shared,会不会把下游全炸了"。Turborepo 的 dry-run 配合 --filter 可以做到精准的影响分析:

# 列出 packages/shared 的所有下游包
pnpm turbo build --filter='...@tubinan/shared...'

# 只构建 git 中发生变更的包及其下游
pnpm turbo build --filter='...[HEAD^1]'

# 只跑主分支上没构建过的包(PR 检查用)
pnpm turbo build --filter='...[origin/main]'

我们把第二条加到了 PR 检查流水线里,PR 检查时间从平均 6 分钟降到了 2 分钟出头。

五、版本发布流程:Changesets

多包仓库的发布是最容易出错的一环。我们用 Changesets 来管理版本和 changelog,流程清晰且自动化:

  1. 开发者在提交 PR 时执行 pnpm changeset,交互式选择要 bump 的包和版本级别(patch/minor/major),会生成一个 .changeset/*.md 文件随 PR 提交。
  2. 合并到主分支后,CI 跑 changeset version,自动更新各包的 package.json 版本号和 CHANGELOG.md
  3. Changesets bot 自动开一个 "Version Packages" PR,把所有版本变更汇总在一起。
  4. 合并这个 PR,CI 执行 changeset publish,把所有有变更的包一次性发到 npm。
// .changeset/config.json
{
  "$schema": "https://unpkg.com/@changesets/config@2.0.0/config.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [["@tubinan/ui", "@tubinan/icons"]],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["docs", "playground"]
}

这里有两个字段很关键:linked 让 ui 和 icons 版本号联动(保证大版本一致);updateInternalDependencies: "patch" 让某个包升级后,下游包的 workspace:* 会被自动 bump 到新的 patch 范围。

六、踩坑记录:幽灵依赖被消除后引发的报错

这是迁移 pnpm 后最高频的问题。原来用 npm workspaces 时,某个包里的代码偷偷用了未声明的依赖,靠 hoisting 侥幸能跑。pnpm 把这个口子堵死后,CI 上立刻冒出一堆 Module not found

典型案例:我们的 sdk-a 里用到了 lodash.debounce,但 package.json 里只声明了 lodash。npm 时代因为 lodash.debounce 被提升到根 node_modules 而能跑,pnpm 下直接报:

Error: Cannot find module 'lodash.debounce'
Require stack:
- packages/sdk-a/src/throttle.ts

排查和修复的方法:

  • pnpm why <pkg> 看依赖来源,确认是不是幽灵依赖。
  • 把缺失的依赖补到对应包的 package.json:pnpm --filter @tubinan/sdk-a add lodash.debounce
  • 对于暂时无法修改的第三方包(它本身依赖了幽灵依赖),在根 package.json 里加 pnpmfile.cjs 或者用 .npmrcshamefully-hoist=true 做过渡——但要警惕,这只是缓兵之计,会重新引入幽灵依赖。
# .npmrc(不推荐长期使用,作为过渡)
shamefully-hoist=false
# 推荐:精确提升个别有问题的包
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

另一个常见坑:node-gyp 编译的原生模块(如 node-sasscanvas)在软链接下有时找不到 binding。解决办法是确保用 --filter 在对应包目录里跑构建,或者把这些包加入 node-linker=hoisted 的白名单。

七、最终收益

指标迁移前(多仓)迁移后(Monorepo)
跨包改动一次迭代周期2-3 天0.5 天
本地 install 时间—(每仓分别装)18s
CI 构建平均耗时4m12s(多仓分别构建)1m40s(含远程缓存)
磁盘占用(10 人本地)约 1.8GB / 人约 300MB / 人
幽灵依赖引发的生产事故季度 2-3 起0 起

结语

Monorepo 不是银弹,它的前提是包之间真的有频繁联动——如果你的 8 个包一年才互相改一次,多仓反而更轻。但当团队规模上来、包之间耦合度变高时,pnpm + Turborepo + Changesets 这套组合提供的依赖隔离、任务缓存、自动化版本管理,会把工程效率拉到一个全新的水位。后面我会再单独写一篇,聊聊我们怎么在 Turborepo 之外用 Nx 的项目图做更细粒度的依赖可视化,作为这套工作流的补充。