# 从入门到实践:前端 Monorepo 工程化实战
随着前端项目的规模不断扩大,如何高效管理多个相关项目成为了一个棘手的问题。特别是当你的团队同时维护着多个共享相似技术栈的应用时,可能会遇到这些困扰:重复的依赖安装、繁琐的包发布流程、不一致的工具配置等。
本文将详细介绍如何使用 Monorepo 来解决这些问题,我们会以实际项目为例,使用 pnpm 和 Turborepo 搭建一个高效的前端工程化方案。读完本文,你将了解:
Monorepo是什么,以及它如何解决传统多仓库的痛点- 如何使用
pnpm管理项目依赖和工作空间- 如何使用
Turborepo提升构建效率- 实际项目中的最佳实践和注意事项
# 什么是 Monorepo
在软件开发中,Monorepo("mono"意为"单一","repo"是"存储库"的缩写)是一种策略,它将多个项目集中在一个代码仓库中进行管理。这些项目之间通常具有一定的联系,可以共享代码或依赖,以提高开发效率。
# Monorepo 的特点
Monorepo 使用单一的代码仓库来管理多个项目:
- 项目之间可以通过本地依赖实现无缝共享。
- 提供统一的工具链,便于维护和协作。
例如谷歌管理着一个庞大的 Monorepo 库,包括大约 10 亿个文件,拥有约 3500 万次提交的历史,跨越了谷歌整个 18 年。[2016]
# 对比传统的多仓库(Multirepo)
下图展示了 Multirepo 和 Monorepo 的在代码仓库上区别
在传统的多仓库结构中(Multirepo),每个项目独立维护,可能会面临以下问题:
- 代码共享困难 为了共享代码,需要额外创建一个共享仓库并发布为包,增加了维护成本。
- 代码重复 各项目可能会重复实现相同功能,导致冗余代码和后期维护困难。
- 工具不一致 不同仓库可能使用不同的工具链(构建、部署、代码规范),增加了协作成本。
- 构建效率低 每个项目独立安装依赖,可能会多次重复构建相同的内容。 举个例子: 我们之前的前端仓库下,有运维平台和租户平台,技术栈相差不多,相同的依赖会重复安装到磁盘上,分别维护各自的工具配置和公共组件,不仅导致代码重复,还让统一管理和协作变得复杂。
# Monorepo 的优势
使用 Monorepo,以 JavaScript 生态为例,可以解决以上问题:
- 代码共享简单
在本地直接引用共享包,无需发布到
npm即可使用。 - 统一的工具链
通过集中化管理代码规范(如
eslint、typescript等)和工具链,减少配置成本,保证一致性。 - 高效的构建流程 减少重复安装依赖,利用缓存机制加速构建。
# Monorepo 在 Web 开发领域中
现代 Web 开发中通常涉及前后端,随着使用 JavaScript 全栈开发的流行,前后端代码和服务往往在同一个项目中协作。例如,React、Vue 等前端框架和 Node.js、GraphQL 等后端技术之间的交互性非常强,前后端复用类型、公共常量等等,这使得在同一个仓库中维护前后端代码变得更加高效。
# Monorepo 的实践
我们在一个新项目中实施了 Monorepo,该项目包含了运维前端(admin)和租户前端(tenant),我们将和后端交互用的 Api 抽象成一个独立的包。同时,我们两侧的技术栈基本一致,将相关工具的配置公共部分抽象成包,项目各自继承扩展,最后,我们将一些常用的常量、方法、UI 组件抽象出来,单独管理。
按照我们以往多仓库(Multirepo)方式,这些包分布在各个独立的仓库里,然后将包发布到公司内部或者公开的源上项目中安装导入。这种方式就会产生我前面提到的多仓库的一个缺陷,代码共享困难,在这种模式下,更新共享包(如 @org/api)的流程通常包括以下步骤:
- 修改共享包代码
- 发布新版本
- 更新依赖该包的项目中的版本号
- 重新安装依赖
- 调试和验证
这个过程不仅繁琐,还可能影响开发效率。虽然在开发模式下可以使用软链接(symlink)来实时查看效果,但这种方法仍需手动操作,且可能引入额外的复杂性。
Monorepo 工具和现代依赖管理工具(如 pnpm、Yarn、npm)提供的 Workspace 功能可以有效解决这些问题:
- 简化依赖管理:本地包可以直接被引用,无需发布和版本更新
- 即时生效:对共享包的修改可以立即反映在依赖它的项目中
- 统一构建:确保所有项目使用相同版本的共享包
- 简化工作流:减少了发布、更新和重新安装的步骤
这种方法不仅提高了开发效率,还确保了项目间的一致性,是现代大型前端项目开发的推荐实践。
# Workspace 设置
通俗的说,Workspace 就像一个大文件夹,里面分门别类放着多个小项目(应用)或共享的工具包(模块)。这些小项目和工具包之间可以相互联系,也可以独立运作。
在 JavaScript 中,主流依赖管理工具均支持 Workspace,pnpm 使用 pnpm-workspace.yaml 配置。npm 和 Yarn,使用 package.json 中的 workspaces 字段配置。
我们选择 pnpm 作为我们依赖管理工具,pnpm-workspace.yaml 的内容如下:
packages:
- "apps/*"
- "packages/*"
根据上面的 Workspace 配置,我们把所用的应用和包,放置在一个仓库里,仓库结构如下:
.
├── apps
│ ├── admin
│ │ └── package.json
│ └── tenant
│ └── package.json
├── package.json
├── packages
│ ├── api
│ │ └── package.json
│ ├── eslint-config
│ │ └── package.json
│ ├── shared
│ │ └── package.json
│ ├── typescript-config
│ │ └── package.json
│ └── ui
│ └── package.json
└── pnpm-lock.yaml
- 应用(apps):独立的项目(如前端、后端应用),通常相互隔离。在我们的项目中,
admin和tenant是两个基于React的前端应用,用Vite作为打包工具 - 包(packages):共享的模块,我们项目中包括组件库(ui)、 前后端交互
Api、代码 lint 工具配置(eslint-config)等 一个包可以是另外一个包的依赖,也可以是应用的依赖,比如eslint-config包,可以被ui和api依赖,也可以被admin和tenant依赖 package.json文件:描述包的元数据,包括名称、版本号、依赖等,每个包或者应用必须包含- 不要嵌套包或者应用,后续介绍到
Monorepo工具不支持
下图展示了项目的依赖关系
# 为什么选择 pnpm
节省磁盘空间和提升性能
- 独特的硬链接机制:
pnpm使用硬链接将依赖统一存储在全局缓存中,避免重复安装,节省磁盘空间。npm每个项目都独立存储依赖,导致浪费磁盘空间,特别是在多项目环境下。- 安装速度更快:
pnpm利用缓存机制显著加速依赖安装。
# Monorepo tools
设置完 Workspace 后,我们可以很方便的共享代码,但是还有一个问题:就是任务管理,比如你要构建应用 admin,你先要构建其依赖 @org/ui,而 @org/ui 又依赖 @org/api,此外还要考虑并行构建 tenant 加快构建速度。这些复杂的任务管理,需要一个工具支持。一个优秀的 Monorepo 工具通要常具备以下能力:
- 本地计算缓存 未改变的文件跳过重复构建,提升效率。
- 任务编排 确保任务按依赖顺序执行,例如在构建应用前,先构建其依赖包。
- 分布式计算缓存 允许开发环境和测试环境共享构建缓存(视具体条件实现)。
- 分布式任务执行 支持在多台机器上并行运行任务,缩短构建时间。
Workspace分析 提供项目全貌,帮助开发人员快速理解代码结构。- 依赖可视化 直观展示项目和任务的依赖关系。
目前有很多开源的 Monorepo 工具
Bazel: byGoogle,支持多语言,公司级的工程化工具,非常复杂Lerna:JavaScript社区曾经最流行的工具,后面一段时间作者停止维护了,现在由Nx团队维护Nx: 号称下一代构建系统,对monorepo的支持很好,支持插件、高级持续集成配置,包括缓存和分布式执行任务,功能相对完善Turborepo: 一个由Vercel团队开发的新Monorepo工具,用Rust编写,用于JavaScript/TypeScript开发
Bazel 适合大型组织,Lerna 专注包管理,可以被包管理工具(pnpm)代替,Nx 功能更加强大,相对的学习路线比较陡峭,Turborepo 简单易用,比较适合我们的需求
# Turborepo 安装配置
Turborepo 是运行在 Workspaces 上层,我们已经用 pnpm 配置好了 Workspace,下面开始安装配置 Turborepo,这是简化步骤,更多细节参考文档 getting-started
- 安装,可以选择全局安装或者安装到项目中
# Global install
pnpm add turbo --global
# Install in repository
pnpm add turbo --save-dev --workspace-root
- 添加配置文件
turbo.json,参考文档 Configuration Options
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"persistent": true,
"cache": false
}
}
}
dependsOn 字段,任务开始前必须完成的任务。用法参考configuring-tasks
outputs 字段,任务的输出,不指定,turbo 就不会缓存,turbo 会在本地缓存构建,如果某个依赖或者应用没有更改,turbo 就不会重新构建。
- 项目根目录下
package.json添加脚本等
+ "scripts": {
+ "build": "turbo build",
+ "dev": "turbo dev",
+ "lint": "turbo lint"
+ },
+ "packageManager": "pnpm@9.1.2",
- 运行任务,参考running-tasks
# Turborepo 常见问题和解决方案
在使用 Turborepo 的过程中,你可能会遇到以下常见问题:
# 1. 缓存相关问题
问题: 明明代码没有改变,但 Turborepo 没有使用缓存,每次都重新构建
解决方案:
- 检查
turbo.json中的outputs配置是否正确指定了所有输出文件 - 确保
package.json中的scripts命令是确定性的,不包含随机性(如时间戳) - 使用
turbo build --dry命令查看任务的依赖关系和缓存状态
问题: 想要强制清除缓存重新构建 解决方案:
# 运行时跳过缓存
turbo build --force
# 2. 任务编排问题
问题: 任务执行顺序不符合预期 解决方案:
{
"tasks": {
"build": {
"dependsOn": ["^build"], // ^表示依赖项的build任务
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"], // 依赖当前包的build任务
"outputs": []
}
}
}
问题: 开发时某些任务不需要缓存 解决方案:
{
"tasks": {
"dev": {
"cache": false,
"persistent": true
}
}
}
# 3. 调试技巧
- 使用
turbo run build --graph生成任务依赖图,帮助理解和优化任务流 - 使用
--dry参数预览任务执行计划:turbo build --dry - 使用
TURBO_LOG_VERBOSITY=high环境变量查看详细日志
# 如何开发一个包
开发一个包,共享给应用或者其他的包,是 Monorepo 的核心功能
# 基础代码规范配置
@org/eslint-config 是我们项目的基本的 eslint 配置,我们希望每个包和应用,不各自再维护这么基础的配置,统一使用 @org/eslint-config,保证团队项目的代码风格一致,这个包不需要构建和开发等任务,包含你定义的文件即可
/packages/eslint-config/package.json 部分内容如下
{
"name": "@org/eslint-config",
"version": "0.0.1",
"private": true,
"files": [
"library.js",
"react-internal.js"
]
}
library.js 用于纯 JavaScript 项目,react-internal.js 用于 React 项目,如何使用呢?比如在 admin 项目中
首先在 /apps/admin/package.json 添加依赖
{
"devDependencies": {
"@org/eslint-config": "workspace:*"
}
}
workspace:* 表示从你的 Workspace 中引用,不必从网络上获取,等到这些包发布的时候,会被动态的替换为对应的版本号。
然后在 /apps/admin/.eslintrc 中引用
{
"extends": ["@org/eslint-config/react-internal.js"]
}
这样在 admin 项目中,就可以使用 @org/eslint-config 的配置,其他应用或者包同理,大家基于同一个代码规范配置开发,不必各自维护自己的代码规范配置
# 前后端交互 Api
这个包包含前后端交互的所有 Api,底层基于 HTTP 请求工具 Axios。这个包不仅要导出出各个模块的交互用的 Api 函数,还要包含 JSON 响应的类型 Model,此外后端返回的数据,不可能包含 UI 上需要的所有信息,特别是一些枚举值的映射,我们的做法是维护在前端和类型 Model,一起都由 Api 这个包导出
/packages/api/package.json 部分内容如下
{
"name": "@org/api",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "tsc --watch",
"build": "tsc"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./user": {
"types": "./dist/user/user.d.ts",
"default": "./dist/user/user.js"
},
"./order": {
"types": "./dist/order/order.d.ts",
"default": "./dist/order/order.js"
}
}
}
推荐用 package.json 的 exports 字段定义导出,使用的时候和 @org/eslint-config 一样,先在 /apps/admin/package.json 添加依赖,然后在代码中引入,示例如下
import { fetchUser, type User } from "@org/api/module1";
# 我的 Monorepo 实践心得
在使用 Monorepo 的过程中,我总结了一些个人经验和思考,希望能给大家一些参考:
# 从小规模开始
刚开始接触 Monorepo 时,我也被各种工具和配置搞得有点晕。后来我意识到,与其一开始就追求完美的工程配置,不如从小规模开始尝试。我的建议是:
- 把最常用的共享代码先抽出来
- 随着项目发展,逐步完善工程化配置
这样循序渐进的方式,让团队有足够的时间适应这种新的开发模式。
# 意想不到的收益
除了预期中的代码复用和工程化统一,Monorepo 还带来了一些意想不到的好处:
更容易进行全局重构
- 重构时能确保所有项目同步更新 ,比如你重构了一个
@org/api里请求的方法名称,所有导入的地方都会自动更新
- 重构时能确保所有项目同步更新 ,比如你重构了一个
团队协作更顺畅
- 团队成员之间更容易共享最佳实践,比如我们项目中常用的增删改查页面,可以抽离出来,供团队成员共享
# 需要注意的坑
当然,使用 Monorepo 也不是没有挑战:
仓库体积增长以及权限管理
- 代码都在一个仓库,对所有人都可见,这是
Monorepo的特点,但是有时存在有的应用或者包对某些团队不可见,就需要权限的管理 - 随着时间的增长,整个仓库体积会越来越大,检出仓库会有性能问题
- 我们仓库较小,没有遇到以上问题,但是这些都是客观存在的,需要考虑
- 代码都在一个仓库,对所有人都可见,这是
包的边界划分
- 不要过度拆分包,这可能会带来不必要的复杂性,比如接口的
JSON响应类型,我曾计划拆分一个@org/types的包,相当于类型和接口分开了,考虑到我们规模小,写在一起简单方便。
- 不要过度拆分包,这可能会带来不必要的复杂性,比如接口的