把多个前端项目合进一个 Monorepo 是怎样的体验
目录
之前团队的前端项目是这样管理的:
- 每个系统一个 Git 仓库;
- 各自有一套构建脚本、一份依赖清单;
- 公共组件、工具要么复制粘贴,要么用「半私有」的 NPM 包。
结果就是:
- 组件 bug 修一份,要手动同步 N 个仓库;
- 依赖升级经常有「版本错乱」;
- CI 流水线一堆重复配置。
于是我们决定把几个核心前端项目合到一个 Monorepo 里。
这篇文章就是那次迁移的实战记录:工具选择、包划分、依赖管理、CI 改造,全流程。
1. 为什么要搞 Monorepo?
总结下来就是三点:
-
公共代码复用
- 组件库、Hooks、工具函数不用再复制粘贴;
- bug 修一次,多项目同步升级。
-
一致的工程规范
- 统一 ESLint/Prettier/Commit 规范;
- 统一构建脚手架和发布流程。
-
协同开发体验好
- 打开一个仓库就能看到相关项目;
- 修改组件立刻能在多个应用里验证。
当然,Monorepo 不是银弹,仓库会变大、CI 要更精细。
所以「是否值得」的标准是:
项目之间有明显共享,且会长期共同演进。
2. 工具选型:pnpm + Turborepo
我们调研了一圈:Lerna、Nx、Yarn Workspaces、pnpm Workspace、Turborepo……
最后选的是:pnpm Workspace + Turborepo,原因:
- pnpm:硬链接节省磁盘空间、依赖管理清晰、速度快;
- Turborepo:任务编排 + 缓存好用,学习成本相对较低。
2.1 仓库结构
repo-root
├── apps
│ ├── manager-web # 管理端
│ ├── investor-web # 投资人 Web
│ └── mini-program # 小程序(uni-app 等)
├── packages
│ ├── ui-components # 通用 UI 组件库
│ ├── shared-utils # 工具函数
│ └── eslint-config # 统一 ESLint 配置
├── package.json
├── pnpm-workspace.yaml
└── turbo.json
pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
3. 包划分:应用 vs 库
在 Monorepo 里,我习惯先分两类:
-
Apps(应用)
- 真正对外跑的前端应用:管理端、运营端、小程序 H5 等;
- 有自己的路由、页面、部署配置。
-
Packages(库)
- 通用组件、工具库、hooks、eslint/prettier 配置等;
- 尽量无副作用,可发布到私有 NPM(可选)。
举例:
@org/ui-components:封装常用表单、表格、弹窗、布局;@org/shared-utils:日期格式化、金额处理、权限工具等;@org/eslint-config:统一 eslint 配置,一行引用。
这样每个 App 只需要:
// apps/manager-web/package.json
{
"dependencies": {
"@org/ui-components": "workspace:*",
"@org/shared-utils": "workspace:*"
}
}
pnpm 会自动把 workspace 内的包链接起来。
4. 依赖管理:哪些在根,哪些在各自
迁移过程中最容易乱的就是依赖。我们的原则是:
-
构建相关 / 工程工具放在根
- 比如:TypeScript、ESLint、Prettier、Vitest、Commitlint 等;
- 通过
extends或脚本在各包中复用。
-
业务运行时依赖尽量在各包
- 如:Vue/React、UI 库、axios 等;
- 避免所有 App 被同一个版本绑死(但核心技术栈尽量对齐)。
-
禁止「幽灵依赖」
- 用
pnpm默认的严格模式,防止 A 包偷偷用到根依赖; - 依赖谁,就在自己的
package.json写清楚。
- 用
迁移初期踩的一个坑是:很多旧项目依赖是「能跑就行」模式,合进 Monorepo 后这些问题会暴露出来,反而逼着我们把依赖梳理干净。
5. Turborepo:任务编排 & 缓存
turbo.json 示例:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"lint": {
"outputs": []
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "build/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
}
}
}
特点:
- 局部构建:改了哪个包,就只跑那条链路;
- 缓存:相同输入(代码 + 环境)下,build/test/lint 可以直接命中缓存;
- CI 上也能复用缓存,大大加快速度。
开发体验上:
pnpm turbo run dev --filter=manager-web
pnpm turbo run lint
pnpm turbo run build --filter=./apps/*
不需要在每个目录来回 cd。
6. CI 改造:从「多条流水线」到「一条智能流水线」
原来的形态是:
- 每个仓库一条 CI;
- 每个 job 都是「install → lint → test → build → deploy」。
Monorepo 后,我们做了几件事:
-
安装依赖一步到位
- 在根目录跑一次
pnpm install。
- 在根目录跑一次
-
利用 Turborepo 只构建受影响项目
- CI 脚本直接跑:
pnpm turbo run build --filter=...; - Turborepo 根据变更文件自动决定要跑哪些包。
- CI 脚本直接跑:
-
部署仍然分项目
- 比如
apps/manager-web成功构建后,触发对应的部署 job; - 每个 App 仍然有自己的环境变量、部署目标。
- 比如
简化后的 CI 流程更像:
- 安装依赖;
- 执行 turbo 的
lint/test/build; - 根据构建成功的 App 列表触发对应部署。
7. 迁移过程中的坑与经验
-
一口吃太多会噎着
- 刚开始不要一次性把所有项目拖进 Monorepo;
- 先选关系紧密的两三个项目试点,跑顺了再拉更多。
-
公共包边界要克制
- 一开始很容易「什么都想抽成包」;
- 建议从真正稳定、复用度高的模块开始,比如 UI 组件库、工具函数。
-
文档和脚本要跟上
- 新同学要知道:哪个目录放什么、如何本地启动、如何只构建某个 App;
- 在根目录放一份
CONTRIBUTING.md非常有用。
-
版本升级要有节奏
- 核心依赖版本升级前,先在一个 App 验证;
- 通过后再统一升级并跑一轮 CI。
8. 小结
把多个前端项目合进 Monorepo 的体验,总结下来:
- 前期迁移成本不低,需要 touch 很多旧代码和脚本;
- 一旦跑顺,公共能力迭代、依赖管理、CI 开销都会明显改善;
- 更重要的是,整个前端工程体系被迫梳理了一遍,长期收益很大。
如果你也在考虑 Monorepo,可以先从:
- 梳理公共代码;
- 选一两套关联紧密的项目试点;
- 用 pnpm + Turborepo 跑出第一条流水线开始。
剩下的,就让这套体系在实践中一点点长起来。