跳到主内容

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 改动文件过滤触发范围,还能再压一压,但收益已经不大,不必过度优化。

避坑总结 ​

  1. 幽灵依赖靠工具兜底,别靠自觉。合并初期大量 import 未声明的包,本地 hoist 时能跑、CI 严格模式必挂;定期跑 pnpm dlx depcheck 扫描,安装期报错就当场补声明,拖到运行期排查成本翻几倍。
  2. 循环依赖会在最痛的时刻爆。A 依赖 B、B 又依赖 A 时,^build 的拓扑排序会直接失败或产出错误顺序;引入包前用 pnpm dlx madge --circular src 之类工具检测,把共享逻辑下沉到独立的第三个包,而不是让两个包互相伸手。
  3. outputs 与 inputs 决定缓存正确性。产物路径漏写进 outputs,缓存命中但 dist 为空,症状是「CI 绿了、页面 404」;环境变量参与构建的(如注入的 API 地址)要写进任务的 inputs,否则换了环境还命中旧缓存。
  4. 常驻任务必须显式关缓存。dev、watch 这类 persistent 任务永远跑不完,配置里 cache: false 是必填项;误缓存会导致后续任务永远拿到一个「进行中」的假产物。
  5. 发布链路用 changesets 接管。Monorepo 的版本管理(哪个包升 minor、哪个只 patch、changelog 归属)靠人肉必乱,changesets 的声明式变更文件是当前社区事实标准,合并当天就接入,晚接一周欠账就多一周。

写在最后 ​

Monorepo 改造的本质不是「把代码放进一个仓库」,而是把散落在各处的隐式协作——谁依赖谁、改了什么要构建什么、版本如何对齐——变成工具可推导的显式声明。pnpm workspace 声明依赖关系,Turborepo 声明任务关系,CI 只做验证。做完这套改造,团队的心智负担明显下降:改公共组件不再是一场跨仓库的仪式,而是一次普通的提交。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。