外观
Monorepo 任务编排实测:pnpm workspace + Turborepo 让 CI 从 18 分钟降到 4 分钟
组件库、工具包、两三个业务应用各自一个仓库,是很多团队的前端现状。改一个公共按钮的小样式,要经历「改代码 → 发版 → 应用 A 升级 → 应用 B 升级 → 两边联调」的完整链路,快则半天,慢则三天;更要命的是版本漂移——同一个组件在应用 A 里是 1.8.0,在应用 B 里还是 1.6.2,线上表现不一致的诡异 bug 追查一天,最后发现是版本不一致。Monorepo 把多个包放进同一个仓库统一管理,正是为了终结这种重复劳动。
但 Monorepo 不是把代码挪到一个仓库就完事:十几个包如果每次 CI 都全量构建全量测试,流水线只会比原来更慢。本文以我维护的一个 15 个包的中型仓库(2 个业务应用 + 1 个组件库 + 12 个工具包)为例,完整记录从合并仓库到 CI 提速的全过程。文中时间数据是该仓库在示例环境下的实测记录,仓库规模与机器配置不同会有差异,但优化路径是通用的。
方案对比:三种组织方式的取舍
在动手之前,先把主流的组织方式摆出来对比,避免为了流行而改造。
| 维度 | 多仓库 | 单仓库不编排 | 单仓库 + Turborepo |
|---|---|---|---|
| 跨包改动发布 | 发版 + 各应用逐个升级,链路以天计 | 本地相互引用,联调快 | 一次提交全链路生效 |
| CI 时长 | 各仓库独立但重复劳动多 | 全量构建,包越多越慢 | 依赖图驱动,只构建变更链路 |
| 依赖一致性 | 版本漂移常态 | 可统一但全靠自觉 | lockfile 统一,工具强制约束 |
| 上手成本 | 最低,但协调成本最高 | 低,规模大了失控 | 中,需要理解任务依赖图 |
结论很直接:包数量超过五个、存在共享代码时,单仓库加任务编排是长期成本最低的选择。仓库只有两三个独立应用互不共享代码,维持多仓库反而省心,不必为了架构而架构。
改造完成后的收益体现在三个地方:跨包改动一次提交全量生效;CI 从全量 18 分钟降到平均 4 分钟(依赖图并行 + 构建缓存命中);新人克隆一个仓库就能跑起所有项目。下面按三步实操展开。
第一步:用 pnpm workspace 把仓库拧成一股绳
包管理器选 pnpm 没有太大争议:硬链接的中央 store 让多包仓库的磁盘占用和安装速度都显著优于 npm/yarn,同时它对 workspace 协议的支持最完善。仓库根目录声明 workspace 范围:
yaml
# pnpm-workspace.yaml:声明哪些目录下的包参与 workspace 管理
packages:
- "apps/*" # 业务应用
- "packages/*" # 组件库与工具包目录结构按职责分两层:apps/ 放可部署应用,packages/ 放被复用的库。包之间相互引用不写版本号,而是用 workspace 协议,让 pnpm 直接软链到本地源码:
json
{
"name": "@app/admin",
"dependencies": {
"@pkg/ui": "workspace:*"
}
}workspace:* 的含义是「永远用 workspace 里的这份」,发布时会被自动替换为真实版本号。这一步做完,跨包联调就变成改代码即生效,不需要 link 命令和发版。
紧接着要补一份仓库级 .npmrc,收紧依赖策略:
ini
# 禁止把依赖提升到根 node_modules 顶层,强制每个包显式声明依赖
shamefully-hoist=false
# 严格校验 peerDependencies,冲突在安装期暴露而不是运行期
strict-peer-dependencies=true这里的关键是默认的严格提升策略:pnpm 只把直接依赖暴露给当前包,代码里 import 了没有声明的包(幽灵依赖)会直接报错。合并仓库初期这个报错会非常烦人——原多仓库时代大量包靠提升机制「碰巧能跑」——但正是这份强制,把依赖关系从混沌变成了显式声明的依赖图,后续 Turborepo 的编排才有据可依。
第二步:Turborepo 任务编排与本地缓存
pnpm 解决的是「依赖关系」,Turborepo 解决的是「任务编排」:它读取各包的 package.json scripts,按依赖图决定构建顺序,并对任务结果做内容哈希缓存。仓库根目录的 turbo.json 是编排中枢(Turborepo 2.x 起字段名为 tasks,1.x 时代的 pipeline 写法已废弃):
json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"lint": {},
"test": {
"dependsOn": ["build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}两个声明是灵魂。^build 里的 ^ 表示「先执行所有上游依赖包的 build」:构建组件库的应用会等组件库先构建完,顺序全自动推导,不存在手写构建顺序这回事。outputs 声明任务产物路径,Turborepo 只缓存这些文件——漏声明会导致「缓存命中了但产物是空的」,这是新手第一大坑。
日常使用只有一条命令:
bash
# 从任意包发起:只构建 admin 应用及其依赖链
pnpm turbo run build --filter=@app/admin
# 全仓库构建:无变更的包直接命中本地缓存,秒级完成
pnpm turbo run build实测中最直观的变化是反馈速度:改了组件库一个工具包的代码,turbo run build 只会重跑这一个包和它的下游(组件库、两个应用),其余 11 个包显示 >>> FULL TURBO 直接取缓存,整个流程 40 秒左右出结果,而不是过去的全量三分钟。
第三步:远程缓存接入 CI,PR 只构建变更链路
本地缓存只对一个人生效,CI 机器上每次都是冷启动。Turborepo 提供远程缓存:构建产物按内容哈希存在云端,任何机器(同事本地、CI)第一次构建后,其他机器上同样的输入直接命中。接入只需环境变量:
yaml
# GitHub Actions 片段:pnpm 安装 + turbo 全任务
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build lint test
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} # 远程缓存令牌
TURBO_TEAM: ${{ vars.TURBO_TEAM }} # 团队空间标识工作模式是:main 分支的推送构建负责「写缓存」,PR 流水线负责「读缓存」。绝大多数 PR 只改动一两个包,依赖图之外的包全部命中远程缓存,这是 CI 从 18 分钟降到 4 分钟的主要来源——时间分解大致是:安装 1 分钟左右(pnpm 缓存)、构建与测试 2~3 分钟(只跑变更链路)、其余全部是缓存命中。配合 --filter 按 PR 改动文件过滤触发范围,还能再压一压,但收益已经不大,不必过度优化。
避坑总结
- 幽灵依赖靠工具兜底,别靠自觉。合并初期大量 import 未声明的包,本地 hoist 时能跑、CI 严格模式必挂;定期跑
pnpm dlx depcheck扫描,安装期报错就当场补声明,拖到运行期排查成本翻几倍。 - 循环依赖会在最痛的时刻爆。A 依赖 B、B 又依赖 A 时,
^build的拓扑排序会直接失败或产出错误顺序;引入包前用pnpm dlx madge --circular src之类工具检测,把共享逻辑下沉到独立的第三个包,而不是让两个包互相伸手。 - outputs 与 inputs 决定缓存正确性。产物路径漏写进 outputs,缓存命中但 dist 为空,症状是「CI 绿了、页面 404」;环境变量参与构建的(如注入的 API 地址)要写进任务的 inputs,否则换了环境还命中旧缓存。
- 常驻任务必须显式关缓存。dev、watch 这类 persistent 任务永远跑不完,配置里
cache: false是必填项;误缓存会导致后续任务永远拿到一个「进行中」的假产物。 - 发布链路用 changesets 接管。Monorepo 的版本管理(哪个包升 minor、哪个只 patch、changelog 归属)靠人肉必乱,changesets 的声明式变更文件是当前社区事实标准,合并当天就接入,晚接一周欠账就多一周。
写在最后
Monorepo 改造的本质不是「把代码放进一个仓库」,而是把散落在各处的隐式协作——谁依赖谁、改了什么要构建什么、版本如何对齐——变成工具可推导的显式声明。pnpm workspace 声明依赖关系,Turborepo 声明任务关系,CI 只做验证。做完这套改造,团队的心智负担明显下降:改公共组件不再是一场跨仓库的仪式,而是一次普通的提交。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。