Monorepo 工程化实战:Turborepo 与 pnpm 工作空间深度应用
结论先行:如果你团队有 3 个以上的前端/Node.js 项目共享代码,Monorepo + pnpm + Turborepo 是目前综合成本最低、收益最高的工程方案。我用这套组合把一个 6 个独立仓库的混乱体系重构为单仓库管理,CI 时间从 40 分钟降到 8 分钟。
一、我为什么跳进 Monorepo 这个坑
2023 年底,我接手了一个"中型前端项目群"——6 个独立仓库,共享一套 UI 组件库和工具函数库。每个库都发 npm 包,版本号飞涨,依赖散落各地。
典型的一天是这样过的:
- 在组件库里改了
Button的一个 props,发版v2.14.3 - 切到业务项目 A,升级依赖,跑测试,发现类型报错
- 修复,发版
v2.14.4 - 业务项目 B 的 CI 挂了,因为锁定了
v2.14.1
这就是"多仓库地狱"——代码是共享的,但工作流不是。我需要一个方案让所有项目活在同一个仓库里,共享同一套依赖树,改完代码立刻生效,完全不需要经历版本发布。
选型做了两周,最终敲定:pnpm workspace + Turborepo。
为啥不是 Lerna? 维护状态过去两年一直暧昧不清,虽然 2023 年被 Nx 接手,但社区信任还没恢复。为啥不是 Nx? 太重了,它的插件生态和依赖图对我来说是过度抽象。Turborepo 够轻、够快、心智负担小。
二、目录结构设计:先想清楚怎么分
定了工具之后,第一个问题:代码往哪放?
我花了三天画目录结构。核心原则是按职责切分,不按项目切分。最终长这样:
packages/
├── apps/
│ ├── web-main/ # 主站应用(Next.js)
│ ├── admin-panel/ # 后台管理(Vite + React)
│ └── landing-page/ # 落地页(Astro)
├── packages/
│ ├── ui/ # UI 组件库
│ ├── utils/ # 通用工具函数
│ ├── api-client/ # API 客户端封装
│ ├── eslint-config/ # ESLint 共享配置
│ └── tsconfig/ # TypeScript 共享配置
├── tooling/
│ └── scripts/ # 项目级脚本
├── pnpm-workspace.yaml # pnpm 工作空间配置
├── turbo.json # Turborepo 配置
└── package.json # 根级配置
关键设计决策:
apps/放可部署的应用,每个应用是独立的可构建产物packages/放可复用的内部包,按功能分,不按应用分tooling/放工程化工具,跟业务代码做物理隔离
教训: 我曾经把
ui包叫@company/ui-lib,后来重构时才发现,lib后缀是冗余信息。内部包命名就用@scope/name,简洁才容易被团队记住。
三、pnpm 工作空间搭建:最关键的几个文件
3.1 pnpm-workspace.yaml
packages:
- 'packages/apps/*'
- 'packages/packages/*'
- 'packages/tooling/*'
只有一行,但决定了一切。
3.2 .npmrc
shamefully-hoist=true
strict-peer-dependencies=false
auto-install-peers=true
shamefully-hoist 让我能从 monorepo 包里直接引用没声明为直接依赖的包——这有争议,但对迁移期间兼容旧代码很有用。新项目建议关掉它。
3.3 根级 package.json
{
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test",
"format": "prettier --write .",
"changeset": "changeset",
"ci:version": "changeset version",
"ci:publish": "changeset publish"
},
"devDependencies": {
"turbo": "^2.0.0",
"@changesets/cli": "^2.27.0",
"prettier": "^3.2.0"
},
"packageManager": "pnpm@9.0.0"
}
四、Turborepo 配置:缓存才是灵魂
很多教程把 Turborepo 当"任务编排器"讲,但对我来说 Turborepo 最大的价值是缓存。同样的输入不会执行两遍。
4.1 turbo.json 核心配置
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**", "vitest.config.ts"],
"outputs": []
},
"lint": {
"dependsOn": ["^build"],
"inputs": ["src/**", ".eslintrc.js"],
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
说几个我踩过的坑:
坑 1:dependsOn 里的 ^ 前缀
"^build"表示"所有依赖的 build 执行完后才执行我的 build""build"表示"等我自己的 build 运行一遍"- 不加
^前缀,Turborepo 会并行执行所有包的 build,依赖还没构建好就执行,直接报错
坑 2:inputs 要精确
- 一开始我没写
inputs,随便改个 README 就让整个缓存失效 - 明确只跟踪
src/**和tsconfig.json,其他变化不影响缓存命中
坑 3:远程缓存需要付费
- Turborepo 的远程缓存(Vercel Remote Cache)是付费功能
- 小团队可以用
turbo-server自建,但我选择只用本地缓存 - CI 环境中可以用
TURBO_CACHE_KEY让缓存跨分支共享(下面会讲)
4.2 包之间的依赖声明
在每个包的 package.json 中:
// packages/packages/ui/package.json
{
"name": "@company/ui",
"dependencies": {
"@company/utils": "workspace:*",
"react": "^18.0.0"
}
}
workspace:* 是 pnpm 的关键语法。它告诉 pnpm:"这个依赖就在本仓库里,不要从 npm registry 下载。" 发布时 pnpm 会自动把它替换为实际版本号。
注意: 别写
workspace:^1.0.0或者硬编码版本号,就用workspace:*。pnpm 会在pnpm publish时自动处理版本替换。
五、TypeScript 配置:三份 tsconfig 的哲学
Monorepo 里 TypeScript 的路径映射是核心痛点。我的方案是三层继承:
根级 tsconfig.json(基础配置)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
包级 tsconfig.json(继承根级 + 路径映射)
// packages/packages/ui/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
应用级 tsconfig.json(继承根级 + 引用)
// packages/apps/web-main/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@company/ui": ["../../packages/ui/src"],
"@company/utils": ["../../packages/utils/src"]
}
},
"references": [
{ "path": "../../packages/ui" },
{ "path": "../../packages/utils" }
]
}
为什么不用 TypeScript Project References 的自动模式? 说实话,references 的体验比我想象中差。VS Code 对 Project References 的支持不够稳定,经常需要手动 tsc --build。大多数时候,paths 配合 moduleResolution: Bundler 就够了。
六、Dev 工作流:本地开发的真实体验
6.1 启动开发环境
# 根目录安装所有依赖(仅一次)
pnpm install
# 启动所有应用的 dev server
pnpm dev
Turborepo 会找到所有包里定义了 dev 脚本的,按依赖拓扑顺序启动。
6.2 在应用 A 中调试组件库代码
这是 monorepo 最大的优势——不需要 npm link,不需要发版。
# 我在 web-main 里开发,同时改了 @company/ui 的代码
# 因为 workspace:* 链接,改动立即生效
# TypeScript 类型也能实时检查
但这里有个坑:HMR 热更新在跨包场景下不够稳定。
Next.js 对 Turborepo 的 HMR 支持最好,但 Vite 项目引用 workspace 包时,热更新经常失效。我的解决方案:
# 在 ui 包里添加 watch 模式
# packages/packages/ui/package.json
"scripts": {
"dev": "tsc --watch --preserveWatchOutput",
"build": "tsc"
}
然后在 turbo.json 的 dev 任务里保证 ui 包先跑 watch:
"dev": {
"cache": false,
"persistent": true,
"dependsOn": ["^dev"]
}
这样 @company/ui 每次 src 变化就重新编译到 dist,引用它的应用通过文件变化触发 HMR。
6.3 只构建部分包
# 只构建 web-main 及其依赖
turbo build --filter=@company/web-main
# 排除某个包
turbo build --filter=!@company/landing-page
# 运行某个包及其下游依赖的测试
turbo test --filter=@company/ui...
--filter 语法:
--filter=@company/web-main—— 只这个包--filter=@company/web-main...—— 这个包及其所有依赖(向上)--filter=...@company/web-main—— 所有依赖这个包的项目(向下)--filter=...[HEAD]—— 相对于 git HEAD 有变化的包
七、CI 配置:从 40 分钟到 8 分钟的核心优化
7.1 Turborepo 与 CI 缓存
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # 重要:Turborepo 需要 git 历史
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v4
with:
path: |
node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm lint
- run: pnpm test
- run: pnpm build
关键优化点:
fetch-depth: 2:Turborepo 需要上一个 commit 的 hash 来判断哪些文件变了。深度为 1 会导致缓存全部失效pnpm install --frozen-lockfile:确保 lockfile 一致,CI 中绝对不会意外更新依赖actions/cache缓存 Turbo 的本地缓存目录:同类任务复用缓存,即使跨分支也能部分命中
7.2 增量 CI(按需构建)
# 只对发生变化的包及其依赖执行完整 CI
turbo lint test build \
--filter=...[HEAD] \
--filter=...${{ github.event.pull_request.base.sha }}
这行命令让 CI 只跑有变更的包。以我的经验,大约 60% 的 PR 只改了 1-2 个包,平均 CI 时间从全量构建的 40 分钟降到 8 分钟。
诚实地说: 这个优化效果取决于你仓库的拆分粒度。如果你把所有代码放一个包,
--filter优化等于没效果。拆得越细,CI 优化收益越大。
八、版本管理与发布:Changesets 出场
Monorepo 的版本管理是第二大痛点。我选 @changesets/cli。
8.1 初始化
pnpm add -w -D @changesets/cli @changesets/changelog-github
pnpm changeset init
生成 .changeset/config.json:
{
"$schema": "node_modules/@changesets/config/schema.json",
"changelog": "@changesets/changelog-github",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
8.2 日常工作流
# 1. 开发者创建 changeset
pnpm changeset
# 2. 选择受影响的包,填写 changelog 摘要
# 这会生成一个 .md 文件在 .changeset/ 目录
# 3. 发布时,合并所有 changeset 并更新版本
pnpm ci:version
pnpm ci:publish
核心规则:开发者只需要在第 1 步输入一行文字,后面全是自动化的。
8.3 GitHub Action 自动发布
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Create Release Pull Request
uses: changesets/action@v1
with:
publish: pnpm ci:publish
version: pnpm ci:version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
这个 Action 做的事情:
- 如果
.changeset/目录有未消费的 changeset,自动创建一个 PR("Version Packages") - 合并该 PR 后(即 push 到 main),自动执行
changeset publish发布
九、踩坑实录:我花了两周填的坑
坑 1:幽灵依赖
# 在 @company/ui 里用到了 lodash
// packages/packages/ui/src/button.tsx
import { debounce } from 'lodash' # 编译没问题!
因为在 workspace 的根 node_modules 里有 lodash,所有子包都能直接引用——但这是个假象。@company/ui 的 package.json 没有声明 lodash 依赖。
后果: 发布后用户安装 @company/ui,lodash 不会被安装,运行时直接崩溃。
解决方案:
# 使用 pnpm 的 eslint-plugin 检测
pnpm add -w -D @types/eslint-plugin-import
# .eslintrc.js
module.exports = {
rules: {
'import/no-extraneous-dependencies': [
'error',
{
packageDir: [__dirname, `${__dirname}/../../`],
},
],
},
};
但这个规则配置起来很麻烦。更简单的是加一个 CI 检查:
# 检查所有包的 package.json 是否声明了所有用到的依赖
pnpm exec syncpack list-mismatches
坑 2:Docker 镜像构建
Monorepo 的 Docker 构建有一个经典问题:只复制当前包不够,还得复制它的 workspace 依赖。
错误做法是只复制当前目录:
# ❌ 这样会漏掉 @company/ui 和 @company/utils
COPY packages/apps/web-main ./apps/web-main
正确的多阶段构建:
FROM node:20-alpine AS builder
WORKDIR /app
# 先复制依赖文件(利用 Docker 缓存)
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/packages/ui/package.json ./packages/ui/
COPY packages/packages/utils/package.json ./packages/utils/
COPY packages/apps/web-main/package.json ./apps/web-main/
# 安装依赖
RUN pnpm install --frozen-lockfile
# 复制源码
COPY . .
# 构建
RUN turbo build --filter=@company/web-main
# 生产镜像
FROM node:20-alpine AS runner
COPY --from=builder /app/apps/web-main/.next ./.next
COPY --from=builder /app/apps/web-main/public ./public
COPY --from=builder /app/apps/web-main/package.json ./package.json
CMD ["node", "server.js"]
核心思路: 在 pnpm install 之前,把每个 workspace 包的 package.json 复制进去。这样 Docker 的 layer 缓存能命中——只要 pnpm-lock.yaml 不变,即使改了业务代码也不需要重装依赖。
坑 3:node_modules 膨胀
pnpm 声称"节省磁盘空间",但在 monorepo 场景下,node_modules 的规模仍然大到离谱——我见过一个项目装完超过 5GB。
# 分析 node_modules 大小
du -sh node_modules packages/*/node_modules
优化手段:
- 把
devDependencies里非工程化工具移到根级devDependencies,不要在每个包里重复 - 定期运行
pnpm dedupe来合并版本冲突的依赖
十、效果总结:数据和感受
用这套方案 6 个月后,团队的实际数据:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 仓库数量 | 6 | 1 |
| 包数量 | 6 个独立 npm 包 | 14 个内部包 |
| 全量 CI 时间 | ~40 min | ~12 min |
| 增量 CI 时间 | 无此概念 | ~3-8 min |
| 跨项目修改代码 | 发版+更新依赖,2 天 | 1 次 commit,5 分钟 |
| 开发者 PR 前置等待 | ~1.5 小时/天 | ~15 分钟/天 |
| 类型错误发现时机 | 运行时 | 编译时 |
最让我意外的反而是团队协作模式的变化——因为所有代码在一个仓库里,前端组的人开始主动去看其他项目的代码,跨项目共享组件和工具的增长速度是过去的 3 倍。工具链的变化改变了团队的文化,这是我没想到的。
十一、说点真话:Monorepo 不是银弹
说了这么多好话,我必须诚实地指出该放弃 monorepo 的场景:
- 团队规模 < 3 人,项目 < 2 个 —— 不值得。多仓库 + npm link 就够
- 项目之间没有共享代码 —— 那是多项目,不是 monorepo,强行拼在一起只会增加复杂度
- 有多个语言/技术栈且没有重叠 —— JS + Rust + Python 在同一个仓库里,工具链互不兼容,CI 配置异常复杂
- Org 级的权限隔离要求严格 —— Git 级别的访问控制没有好方案(
CODEOWNERS是事后控制,不是访问控制) - 历史包袱过于沉重的老旧项目 —— 迁移成本可能大于收益
我的建议: monorepo 是"术",不是"道"。当项目间的耦合关系让你感到痛苦时,monorepo 是解药。但如果你还在"加一个组件要不要起一个新仓库"这个阶段纠结,别碰 monorepo,那对你来说是过度工程化。
留个问题给你: 你团队当前的协作瓶颈到底在"代码管理"层面,还是在"沟通流程"层面?如果是后者,换工具解决不了问题。

评论已关闭!