开门见山:Monorepo 解决的是代码组织问题,pnpm Workspace 解决的是依赖连接问题,Turborepo 解决的是任务编排和缓存问题。
三者不是互相替代的关系,而是现代前端工程里一条很自然的协作链路:
Monorepo:把多个项目放进同一个仓库
↓
pnpm Workspace:让这些项目可以在本地互相引用
↓
Turborepo:按依赖关系运行任务,并把结果缓存起来
↓
CI/CD:只检查和构建真正受影响的部分如果用一句更通俗的话说:
Monorepo 像一家餐厅的后厨,所有档口都在同一个厨房里;pnpm 像食材仓库和传菜通道;Turborepo 像总调度员,知道哪道菜要先做、哪些菜已经做过可以直接复用;CI/CD 则是自动质检员和配送员。
这篇文章不只讲概念,而是从一个真实工程场景出发,解释为什么团队会从多仓库走向 Monorepo,以及 pnpm 和 Turborepo 到底分别在其中负责什么。
一、真实案例:改一个按钮,为什么要发三次包
先看一个很常见的业务团队结构。
团队里有几个前端项目:
admin-web:后台管理系统mobile-h5:移动端活动页docs-site:内部组件文档ui:组件库utils:工具函数库eslint-config:团队统一代码规范
一开始,它们分别放在不同仓库里:
company-admin-web
company-mobile-h5
company-docs-site
company-ui
company-utils
company-eslint-config这种 Multirepo 结构早期很舒服:每个项目独立,权限清晰,部署互不影响。
但项目一多,问题就开始出现。
假设你要改组件库里的 Button,让后台和 H5 都支持新的 loading 状态。在多仓库模式下,流程通常是这样:
1. 修改 company-ui
2. 发版 @company/ui@1.2.3
3. 到 admin-web 升级依赖,联调,提交
4. 到 mobile-h5 升级依赖,联调,提交
5. docs-site 也要升级一次依赖,避免文档和真实组件不一致如果中途发现组件库还有问题,你要重新回到 ui 仓库修 bug,再发一个 1.2.4,再去每个业务仓库升级。
更烦的是工具函数。
比如 utils 里有一个金额格式化函数:
export function formatMoney(value: number) {
return value.toFixed(2);
}后来产品要求支持千分位:
formatMoney(1234567); // "1,234,567.00"这个函数一改,后台、H5、文档站都可能受影响。多仓库下,真正消耗人的不是代码本身,而是发包、升级、联调、回滚这些周边动作。
这就是很多团队走向 Monorepo 的真实原因:不是为了赶潮流,而是为了把跨项目协作从“发布依赖”变成“本地修改”。
二、Monorepo 到底解决什么问题
Monorepo 的定义很简单:把多个应用和多个共享包放在同一个代码仓库里统一管理。
一个典型结构长这样:
my-company-frontend/
apps/
admin-web/
mobile-h5/
docs-site/
packages/
ui/
utils/
eslint-config/
tsconfig/这里一般会把项目分成两类:
| 类型 | 例子 | 作用 |
|---|---|---|
| Apps | admin-web、mobile-h5、docs-site | 可以独立运行、构建、部署的应用 |
| Packages | ui、utils、eslint-config | 被多个应用复用的基础能力 |
Monorepo 的核心收益不是“目录看起来整齐”,而是下面两点。
1. 本地共享,实时生效
以前你修改 @company/ui,要先发包,业务项目再升级版本。
现在你在同一个仓库里改 packages/ui,apps/admin-web 可以直接引用本地包。组件还没发布,业务项目就能立刻联调。
2. 原子化提交
以前一个需求可能拆成三个 PR:
PR 1:修改 ui
PR 2:admin-web 升级 ui
PR 3:mobile-h5 升级 ui如果 PR 2 合了,PR 3 还没合,线上就可能出现版本不一致。
Monorepo 里可以把跨包修改放进同一个 PR:
同一个 PR:
- packages/ui 新增 Button loading
- apps/admin-web 接入 loading
- apps/mobile-h5 接入 loading
- packages/ui 补测试这叫原子化提交。它让一次需求的所有相关修改一起被 review、一起进入主干,也更容易回滚。
三、pnpm Workspace:把本地包真正连起来
Monorepo 只是代码组织方式。光把目录放到一起还不够,你还需要一个工具告诉包管理器:
这些目录都是工作区里的包,它们可以互相引用。
这就是 pnpm Workspace 的作用。
为什么很多团队选 pnpm
pnpm 有几个非常适合 Monorepo 的特点:
- 安装速度快
- 磁盘占用低
- 原生支持 Workspace
- 依赖隔离更严格,不容易误用幽灵依赖
它的磁盘节省来自内容寻址存储。简单理解就是:同一个依赖不会在每个项目里重复复制一份,而是存到统一仓库里,再通过链接给各个项目使用。
核心配置:pnpm-workspace.yaml
在仓库根目录声明工作区范围:
packages:
- "apps/*"
- "packages/*"这表示:
apps 下面的每个子目录都是一个 workspace package
packages 下面的每个子目录也是一个 workspace package然后每个包都有自己的 package.json。
比如组件库:
{
"name": "@company/ui",
"version": "0.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}业务应用可以这样引用:
{
"dependencies": {
"@company/ui": "workspace:*",
"@company/utils": "workspace:*"
}
}这里的 "workspace:*" 就是关键。
它的意思不是去 npm registry 上下载最新版,而是告诉 pnpm:
请使用当前工作区里的本地包。
所以 apps/admin-web 引用 @company/ui 时,实际指向的是本地的 packages/ui。这也是 Monorepo 本地联调体验丝滑的根源。
一个最小可跑结构
my-company-frontend/
pnpm-workspace.yaml
package.json
apps/
admin-web/
package.json
packages/
utils/
package.json
src/index.tspackages/utils/package.json:
{
"name": "@company/utils",
"version": "0.0.0",
"exports": {
".": "./src/index.ts"
}
}apps/admin-web/package.json:
{
"dependencies": {
"@company/utils": "workspace:*"
}
}apps/admin-web/src/demo.ts:
import { formatMoney } from "@company/utils";
console.log(formatMoney(1234567));这时你改 packages/utils/src/index.ts,业务应用就能直接拿到本地修改,不需要 npm publish。
四、Turborepo:它不是打包工具,而是任务指挥官
很多初学者会把 Turborepo 和 Vite、Webpack、Rspack 放在一起比较,这是一个很常见的误区。
它们不是一类东西。
| 工具 | 主要职责 |
|---|---|
| Vite / Webpack / Rspack | 编译、打包、压缩、转换模块 |
| pnpm | 安装依赖、管理 workspace、本地包链接 |
| Turborepo | 编排任务、处理任务依赖、缓存任务结果 |
Turborepo 不负责把 TS 编译成 JS,也不负责压缩混淆代码。真正打包的仍然是每个包自己的工具。
比如:
apps/admin-web可能用 Next.jsapps/mobile-h5可能用 Vitepackages/ui可能用 tsuppackages/utils可能用 tsc
Turbo 负责的是:
当我执行
build时,应该先构建谁?哪些任务可以并行?哪些任务之前跑过,可以直接复用缓存?
turbo.json 的灵魂参数
一个常见配置如下:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {
"outputs": []
},
"test": {
"outputs": ["coverage/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}重点看 dependsOn 和 outputs。
dependsOn: ["^build"]:按依赖关系先后执行
"^build" 的意思是:先执行当前包依赖项的 build,再执行当前包自己的 build。
比如依赖关系是:
admin-web
├─ depends on @company/ui
└─ depends on @company/utils
@company/ui
└─ depends on @company/utils那么构建顺序应该是:
1. @company/utils build
2. @company/ui build
3. admin-web build这就是拓扑排序。
如果没有这个顺序,admin-web 可能在 ui 还没产出 dist 时就开始打包,结果就会出现类型文件找不到、产物没生成、构建偶发失败等问题。
outputs:告诉 Turbo 哪些结果可以缓存
outputs 的作用是告诉 Turbo:
这个任务跑完后,哪些文件是产物,可以被缓存。
例如组件库:
{
"build": {
"outputs": ["dist/**"]
}
}Next.js 应用:
{
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
}当 Turbo 判断输入没有变化时,它可以不重新执行命令,而是直接恢复上一次的产物。
你会看到类似这样的结果:
Tasks: 3 successful, 3 total
Cached: 3 cached, 3 total
Time: 120ms >>> FULL TURBO第一次构建可能要 10 秒:
@company/utils:build 1.2s
@company/ui:build 3.1s
admin-web:build 6.4s
Total 10.7s第二次如果没有任何输入变化:
@company/utils:build cache hit
@company/ui:build cache hit
admin-web:build cache hit
Total 0.1s这就是 Turbo 最爽的地方:它不是让每个构建工具变快,而是尽可能避免重复构建。
五、CI/CD 和 Monorepo 的碰撞:厨房质检员和外卖配送员
很多人理解 CI/CD 时会卡在缩写上。
换成餐厅比喻会更容易。
CI:厨房里的自动化质检员
CI 是 Continuous Integration,持续集成。
它像后厨里的自动质检员。每次你把新菜谱提交到厨房,质检员都会自动检查:
代码提交
↓
安装依赖
↓
跑 lint
↓
跑 test
↓
跑 build
↓
确认这次改动没有把厨房搞坏对应到前端工程里就是:
git push
↓
CI 自动执行 pnpm install
↓
pnpm lint
↓
pnpm test
↓
pnpm buildCI 的价值不是帮你部署,而是让每次合并之前都有一套自动化检查,避免把明显坏掉的代码合进主干。
CD:把通过质检的菜送出去
CD 可以指 Continuous Delivery,也可以指 Continuous Deployment。这里先按持续部署理解。
它像外卖配送员。
当 CI 质检通过之后,CD 会把构建产物自动送到用户能访问的地方:
CI 通过
↓
构建产物上传
↓
服务器 / CDN / Vercel / Docker 镜像更新
↓
用户看到新功能所以 CI/CD 的完整链路可以理解成:
开发者提交代码
↓
CI 自动检查质量
↓
CD 自动发布产物
↓
用户访问新版本Monorepo 下的 CI 痛点
Monorepo 带来代码协作便利,但也带来一个新问题:
仓库变大后,难道每次提交都要把所有应用和所有包都 lint、test、build 一遍吗?
如果仓库里有 20 个应用、50 个包,完整 CI 可能要跑 30 分钟。你只是改了 packages/utils 的一个函数,却要等所有应用都构建完,这显然不合理。
这时 Turborepo 的价值就出来了。
它会基于任务输入生成指纹。输入通常包括:
- 源码文件
package.json- lockfile
- 环境变量
- 任务配置
- 上游依赖产物
如果这些输入没有变化,Turbo 就认为任务结果可以复用。
于是 CI 不再是粗暴地“全仓库重跑”,而是变成:
这次改了什么?
↓
哪些包受到影响?
↓
这些包依赖谁?
↓
只跑必要的 lint / test / build
↓
没变的任务直接命中缓存这就是 Turborepo 对 CI/CD 的降维打击:用任务图和缓存,把团队等待时间从“全量重复劳动”变成“按需验证”。
六、一套可落地的工程配置
下面给一套比较典型的最小配置。
目录结构:
my-company-frontend/
apps/
admin-web/
mobile-h5/
packages/
ui/
utils/
package.json
pnpm-workspace.yaml
turbo.json根目录 package.json:
{
"private": true,
"packageManager": "pnpm@10.0.0",
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test"
},
"devDependencies": {
"turbo": "latest",
"typescript": "latest"
}
}pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {
"outputs": []
},
"test": {
"outputs": ["coverage/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}packages/utils/package.json:
{
"name": "@company/utils",
"version": "0.0.0",
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint .",
"test": "vitest run"
},
"exports": {
".": "./src/index.ts"
}
}packages/ui/package.json:
{
"name": "@company/ui",
"version": "0.0.0",
"scripts": {
"build": "tsup src/index.ts --dts",
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@company/utils": "workspace:*"
}
}apps/admin-web/package.json:
{
"name": "admin-web",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@company/ui": "workspace:*",
"@company/utils": "workspace:*"
}
}这套配置背后的分工很清晰:
- pnpm 负责安装依赖和连接本地包。
- 每个 package 自己负责定义
build、lint、test怎么跑。 - Turbo 负责按依赖关系调度这些任务,并缓存输出。
- CI 只需要调用根目录脚本,比如
pnpm build。
七、面试官真正想听什么
如果面试里被问到 Monorepo、pnpm、Turborepo,不要只背命令。
更好的回答方式是先讲分工:
Monorepo 是代码管理策略,把多个 app 和 package 放在一个仓库里;pnpm Workspace 负责本地包之间的依赖链接,
workspace:*可以让业务应用直接引用本地共享包;Turborepo 是任务运行器,负责根据包依赖图做拓扑排序、并行执行和缓存复用。它不是打包工具,真正打包仍然由 Next.js、Vite、tsup、tsc 这些工具完成。
然后讲价值:
这套架构主要解决跨项目复用和 CI 效率问题。以前共享包改动需要发 npm 包、业务项目再升级版本;现在可以在同一个 PR 里完成共享包和业务应用的原子化修改。CI 里再通过 Turbo 的缓存和任务图,只构建受影响的部分,减少重复劳动。
最后补一个风险意识:
Monorepo 不是银弹。它会让仓库变大,也要求团队统一脚本规范、依赖边界和 CI 策略。如果没有清晰的 package 边界,只是把所有代码塞进一个仓库,最后会变成更大的泥球。
这个回答比“pnpm 很快、Turbo 有缓存”更像工程实践。
八、常见误区
误区 1:Monorepo 就是把项目放进一个文件夹
不是。
Monorepo 的关键不是“放一起”,而是统一依赖、统一脚本、统一 CI、统一变更流程。
如果只是手动建了几个目录,但没有 workspace、没有任务编排、没有边界约束,那只是一个大文件夹。
误区 2:pnpm Workspace 会自动帮你构建依赖
不会。
pnpm 负责把依赖链接起来,但它不理解你的构建顺序。比如 admin-web 依赖 ui,ui 依赖 utils,谁先 build 不是 pnpm 的核心职责。
这正是 Turbo 要解决的问题。
误区 3:Turborepo 可以替代 Vite 或 Webpack
不能。
Turbo 不打包代码,它只是调用各个包里已有的脚本。
你可以理解成:
Vite:厨师,真正做菜
pnpm:仓库管理员,管理食材和通道
Turbo:后厨总调度,安排谁先做、谁可以并行、哪道菜之前做过
CI/CD:质检员和配送员误区 4:有缓存就一定安全
缓存必须建立在准确的输入识别上。
如果构建依赖了某个环境变量,但没有把它纳入任务输入,缓存就可能复用错误结果。真实项目里要特别注意:
- 环境变量
- lockfile
- 构建配置
- 代码生成产物
- 外部接口 schema
缓存越强,越要重视失效条件。
九、最后总结
可以把这套体系记成四句话:
- Monorepo 管代码组织:把多个应用和共享包放进同一个仓库,方便跨包协作。
- pnpm 管依赖连接:用 Workspace 和
workspace:*让本地包互相引用,减少发包联调成本。 - Turborepo 管任务执行:用依赖图、拓扑排序、并行执行和缓存减少重复劳动。
- CI/CD 管质量和交付:自动跑检查、构建和部署,让团队交付更稳定。
真正理解它们的协作关系后,你会发现这不是一套炫技工具链,而是一种工程化思维:
把相关变化放在一起,把重复劳动交给机器,把依赖关系显式化,把团队等待时间降下来。
这才是 Monorepo、pnpm 和 Turborepo 最值得学习的地方。