我们的前端团队有 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 workspaces | yarn workspaces | pnpm workspaces |
|---|---|---|---|
| node_modules 结构 | 扁平(hoisting) | 扁平(hoisting) | 软链接 + 隔离 |
| 幽灵依赖 | 存在 | 存在 | 从机制上杜绝 |
| 磁盘占用 | 高(每仓重复存) | 中(有 zero-install) | 低(全局 store) |
| 安装速度(10 包仓库) | 42s | 31s | 18s |
| 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 改动导致缓存失效。dev和clean必须设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,流程清晰且自动化:
- 开发者在提交 PR 时执行
pnpm changeset,交互式选择要 bump 的包和版本级别(patch/minor/major),会生成一个.changeset/*.md文件随 PR 提交。 - 合并到主分支后,CI 跑
changeset version,自动更新各包的package.json版本号和CHANGELOG.md。 - Changesets bot 自动开一个 "Version Packages" PR,把所有版本变更汇总在一起。
- 合并这个 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或者用.npmrc的shamefully-hoist=true做过渡——但要警惕,这只是缓兵之计,会重新引入幽灵依赖。
# .npmrc(不推荐长期使用,作为过渡)
shamefully-hoist=false
# 推荐:精确提升个别有问题的包
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
另一个常见坑:node-gyp 编译的原生模块(如 node-sass、canvas)在软链接下有时找不到 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 的项目图做更细粒度的依赖可视化,作为这套工作流的补充。