monorepo最佳实践
1. 什么是 Monorepo
?
Monorepo
(Monolithic Repository)是一种项目管理策略,将多个相关但独立的项目代码存储在同一个代码仓库中。与之相对的是Multi-repo(多仓库)策略,即每个项目都有自己独立的代码仓库。
1.2 优势与劣势
优势
- 代码共享和复用:在同一仓库中的代码更容易共享,组件库、工具函数等可以方便地在多个项目间复用
- 原子提交:可以在一次提交中同时更新多个相关项目,保持一致性
- 统一的工作流:所有项目使用相同的构建、测试和部署流程
- 依赖管理优化:统一管理依赖版本,避免版本冲突
- 大规模代码重构更容易:可以在一次操作中修改所有相关项目
- 跨项目协作更便捷:团队成员可以轻松查看、修改其他相关项目的代码
劣势
- 仓库体积增大:随着项目增多,仓库体积可能变得非常大
- 权限控制复杂:不同团队/项目的权限管理变得更复杂
- 构建和CI/CD挑战:需要更智能的构建系统,避免不必要的构建
- 学习曲线:需要团队成员熟悉更多项目结构和工具
1.3 适合 Monorepo
的项目类型
- 紧密关联的多个应用:前后端分离但紧密协作的应用
- 共享组件和库的生态系统:如组件库与使用它的多个应用
- 微前端架构:多个团队开发的前端应用需要集成
- 大型组织内部相关项目:如 Google、Facebook 等大公司的内部项目
- 开源项目及其生态:如 Babel、React 等及其插件体系
- 需要统一工具链和配置的项目群:共享构建配置、类型定义、测试设置等
2. pnpm
作为 Monorepo
解决方案
2.1 pnpm
基本介绍
pnpm
(全称:Performant NPM
)是一个快速、高效、节省磁盘空间的 Node.js
包管理工具。
pnpm
与 npm
、yarn
的比较:
特性 | npm |
yarn |
pnpm |
---|---|---|---|
安装方式 | 复制包到 node_modules | 复制包到 node_modules | 使用硬链接和符号链接 |
磁盘空间 | 占用最多 | 占用较多 | 占用最少 |
安装速度 | 较慢 | 较快 | 最快 |
依赖管理 | 平铺或嵌套结构 | 平铺结构 | 基于内容寻址的存储 |
多项目共享缓存 | 不支持 | 不支持 | 支持全局缓存共享 |
Monorepo 支持 | 需要额外工具 | 需要额外工具 | 原生支持 |
2.2 pnpm
的核心特点
1. 硬链接(symlink
)技术
pnpm
使用硬链接和符号链接来管理依赖:
- 所有包都存储在全局存储中(通常在
~/.pnpm-store
) - 项目中的
node_modules
通过硬链接指向全局存储 - 依赖树通过符号链接组织
2. 高效缓存机制
- 下载的包会被保存在全局存储中
- 即使删除项目,包仍然保留在全局存储中
- 新项目可以直接从缓存中获取已下载的包
3. 极速安装
- 安装相同依赖时几乎不需要下载
- 即使首次安装,速度也通常比
npm
/yarn
快 - 多个项目共享同一份依赖,节省时间和空间
4. 严格的依赖结构
- 防止幽灵依赖(使用未在 package.json 中声明的依赖)
- 确保代码在不同环境中的一致行为
3. pnpm Monorepo
基础搭建
3.1. 初始化项目
首先,创建一个新项目并初始化 pnpm
:
json
mkdir my-monorepo
cd my-monorepo
pnpm init
这会生成一个基本的 package.json
文件。
json
{
"name": "my-monorepo",
"version": "1.0.0",
"private": true,
"description": "My pnpm monorepo project",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
关键配置说明:
"private": true
:防止根包被意外发布到npm
3.2. pnpm workspace 简介
pnpm workspace
是 pnpm
提供的 monorepo
解决方案,它允许:
- 集中管理依赖:在一个仓库中管理多个相互关联的项目
- 共享代码:各个子项目之间可以方便地共享代码
- 统一版本:确保所有项目使用相同版本的依赖
- 提高效率:通过共享node_modules减少磁盘占用和安装时间
- 简化操作:支持一次性对多个包执行命令
3.3. 如何配置 pnpm-workspace.yaml
在项目根目录创建 pnpm-workspace.yaml
文件,定义工作区:
json
packages:
# 所有直接在packages目录下的包
- 'packages/*'
# 所有直接在apps目录下的应用
- 'apps/*'
# 所有在tools目录及其子目录下的包
- 'tools/**'
# 排除在test目录下的包
- '!**/test/**'
配置说明:
- 使用glob模式指定包含的目录
- 匹配单级目录,
*
匹配多级目录 - 前缀
!
表示排除匹配的目录
3.4. 项目结构示例
一个典型的monorepo
项目结构如下:
json
my-monorepo/
├── pnpm-workspace.yaml # workspace配置
├── package.json # 根项目配置
├── pnpm-lock.yaml # 锁文件
├── apps/ # 应用程序
│ ├── web/ # Web前端应用
│ │ ├── package.json
│ │ └── src/
│ └── api/ # API服务
│ ├── package.json
│ └── src/
├── packages/ # 共享包
│ ├── ui/ # UI组件库
│ │ ├── package.json
│ │ └── src/
│ ├── utils/ # 工具函数库
│ │ ├── package.json
│ │ └── src/
│ └── config/ # 共享配置
│ ├── package.json
│ └── src/
└── tools/ # 工具和脚本
├── eslint-config/ # ESLint配置
│ ├── package.json
│ └── index.js
└── ts-config/ # TypeScript配置
├── package.json
└── tsconfig.json
这种结构有以下几个优点:
- 清晰的分层:apps 包含最终产品,packages 包含可复用的库
- 职责分离:每个包都有明确的职责和边界
- 依赖关系清晰:通常 apps 依赖 packages,packages 之间也可能有依赖关系
- 独立性与共享性的平衡:既保持了各个项目的独立性,又能方便地共享代码
3.5. 子包配置package.json
配置注意事项和规范
📦 Monorepo
子包 package.json
必备配置项
字段 | 是否必须 | 说明 | 示例 |
---|---|---|---|
name | 必须 | 包名,全局唯一,用来标识子包,也用于 workspace 依赖和 filter | "name": "utils" 或 "name": "@my-scope/utils" |
version | 必须(内部版本控制或发布用) | 即使不发布,保持一致也有利于管理 | "version": "1.0.0" |
private | 建议加(看用途) | 如果是纯内部包,不对外发布,加 "private": true 避免误发布 |
"private": true |
dependencies | 必须根据实际情况 | 声明本包运行时需要的依赖 | "dependencies": { "lodash": "^4.17.21" } |
devDependencies |
建议 | 开发时需要的依赖,比如测试库、打包工具等 | "devDependencies": { "vitest": "^1.0.0" } |
peerDependencies |
如果是库,就必须加 | 指明由使用方提供的依赖,比如 React 组件库 | "peerDependencies": { "react": "^18.0.0" } |
engines | 可选但推荐 | 限制 Node.js 、pnpm 版本,提高一致性 |
"engines": { "node": ">=18", "pnpm": ">=8" } |
main / module / types | 如果是可被引用的包,必须配置 | 指定入口文件、模块文件、类型文件路径 | "main": "dist/index.js" , "types": "dist/index.d.ts" |
exports | 高级配置(可选) | 精细控制对外暴露哪些模块(用于 ESM /CJS 兼容) |
见下文详细示例 |
🎯 一个标准示例(开发内部 utils 包)
packages/utils/package.json
json
{
"name": "@my-scope/utils",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
},
"dependencies": {
"lodash": "^4.17.21"
},
"devDependencies": {
"typescript": "^5.0.0",
"vitest": "^1.0.0"
},
"engines": {
"node": ">=18",
"pnpm": ">=8"
}
}
🚀 重点解释
重点项 | 为什么重要? |
---|---|
name 唯一性 | Monorepo 内必须唯一,不然 workspace 软链接、filter 可能混乱。最好统一加个作用域,比如 @你的组织名/包名 |
private | 纯内部开发时加上,避免 npm publish 出错(防止误发!) |
exports | 现代 Node 项目、打包工具(如 Vite 、ESBuild )会优先读 exports 字段,控制暴露点 |
peerDependencies |
组件库、工具库如果依赖外部环境,应该用 peerDependencies 声明,防止版本冲突 |
types | 如果包有 TypeScript 支持,一定记得加 "types" ,否则使用方提示不出类型 |
📋 小Tips
- 子包不需要自己安装
pnpm
,本身由根目录控制。 - 子包内部可以有自己的
tsconfig.json
(配合tsconfig.base.json
统一继承) - 子包如果要被其他子包引用,记得在根目录
pnpm-workspace.yaml
声明路径。
例如你的 pnpm-workspace.yaml
要这样:
json
packages:
- "apps/*"
- "packages/*"
这样 apps/admin
能正确引用 packages/utils
。
3.6. 依赖管理:共享与隔离
pnpm
在monorepo
中提供了强大而灵活的依赖管理能力:
依赖类型:
- 工作区依赖:工作区内部包之间的依赖关系
- 根依赖 :在根
package.json
中声明的依赖 - 子包依赖 :在各子包
package.json
中声明的依赖
安装依赖的方式
- 在根目录安装全局共享的依赖
json
# 安装所有工作区共享的依赖(添加到根package.json)
pnpm add lodash -w
# 安装为开发依赖
pnpm add -wD typescript
# 安装子包
pnpm add -D @your-org/typescript-config@workspace:* -w
-w
或 --workspace-root
标志表示在工作区根目录安装依赖。
- 在根目录为某个子包安装依赖
使用 pnpm --filter
命令可以为特定子包安装依赖:
json
# 语法: pnpm --filter <target-package> add <dependency>
pnpm --filter "your-subpackage-name" add @your-org/typescript-config@workspace:*
例如,如果您有一个名为 "my-nextjs-app
" 的子包,想为它安装 typescript-config:
json
pnpm --filter "my-nextjs-app" add @your-org/typescript-config@workspace:*
@workspace:*
表示使用工作区内的包版本。
- 在根目录为多个子包安装依赖
使用 pnpm --filter
配合通配符 ...
可以为多个子包安装依赖:
json
# 语法: pnpm --filter "<pattern>" add <dependency>
pnpm --filter "./apps/*" add lodash
例如,如果您的项目中有多个子应用(如 apps/web
和 apps/admin
),可以用这种方式同时为它们安装 lodash
。
- 只在当前子包安装依赖
如果你在某个子包的目录下操作,可以直接用 pnpm add
,不需要 --filter
:
json
# 语法: 在子包目录下直接执行
pnpm add axios
- 安装开发依赖到特定子包
加上 -D
(或 --save-dev
)参数,将依赖作为开发依赖安装到指定子包:
json
# 语法: pnpm --filter <target-package> add -D <dependency>
pnpm --filter "my-nextjs-app" add -D eslint
例如,想只在 my-nextjs-app
里安装 eslint
作为 devDependency
,可以这样写。
- 在子包中安装工作区内其他包作为依赖
可以使用 @workspace:*
直接安装工作区的其他本地包:
json
# 语法: pnpm --filter <target-package> add <workspace-package>@workspace:*
pnpm --filter "apps/web" add @your-org/ui@workspace:*
比如把 @your-org/ui
这个内部组件库作为依赖加到 apps/web
里。
- 在
package.json
中手动添加
也可以直接编辑子包的 package.json
文件,添加依赖项
json
{
"dependencies": {
"@your-org/typescript-config": "workspace:*"
}
// 或者如果是开发依赖
"devDependencies": {
"@your-org/typescript-config": "workspace:*"
}
}
然后在根目录执行 pnpm install
来安装所有依赖。
共享与隔离机制
pnpm 默认严格隔离依赖,同时提供优化的共享机制:
共享机制:
- 全局 store:所有依赖包存储在一个全局缓存中
- 硬链接共享:所有项目共享相同的依赖包实例
- 根依赖共享:根依赖可供所有子包使用(如开发工具)
隔离机制:
- 严格的 node_modules 结构:每个包只能访问自己声明的依赖
- 符号链接树:通过复杂的链接结构实现依赖隔离
- 防止幽灵依赖:防止使用未声明的依赖
常用命令
json
# 安装所有工作区依赖
pnpm install
# 在所有工作区运行build命令
pnpm -r run build
# 在指定工作区运行dev命令
pnpm --filter web-app run dev
# 查看工作区依赖图
pnpm m ls
4. 日常操作
4.1 在 Monorepo
中新增包/项目
在monorepo
中添加新的包或项目有两种方法:
方法一:手动创建
- 在适当的目录(如
packages/
或apps/
)创建新的子目录 - 初始化
package.json
- 添加必要的配置和依赖
json
# 创建新的库包
mkdir -p packages/new-lib
cd packages/new-lib
pnpm init
# 创建新的应用
mkdir -p apps/new-app
cd apps/new-app
pnpm init
方法二:使用工具自动创建
使用脚手架或自定义脚本创建标准化的新包:
json
# 使用自定义脚本(如果有)
pnpm run create-package my-new-package
# 或使用现有工具(如plop)
pnpm plop package
新包配置示例:
json
{
"name": "@my-scope/new-lib",
"version": "0.1.0",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"test": "jest"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.0.4"
}
}
新增包后需要运行pnpm install
以更新整个工作区的依赖。
4.2 在 Monorepo
内部包之间互相引用
monorepo
最强大的功能之一是包之间可以方便地相互引用。这通过workspace协议实现:
workspace协议语法:
makefile
workspace:<版本范围>
版本范围可以是:
*
:任何版本^1.0.0
:兼容语义版本范围~1.0.0
:次版本范围1.0.0
:精确版本
在package.json
中使用workspace协议:
json
{
"dependencies": {
"@my-scope/ui": "workspace:*",
"@my-scope/utils": "workspace:^0.1.0"
}
}
在代码中导入工作区包:
json
// 在JavaScript/TypeScript中导入
import { Button } from '@my-scope/ui';
import { formatDate } from '@my-scope/utils';
安装工作区依赖:
json
# 为web应用添加ui库作为依赖
pnpm add @my-scope/ui --filter @my-scope/web --workspace
workspace 协议的优势
- 本地开发:更改会立即反映,无需发布
- 版本同步:始终使用项目中的最新版本
- 简化测试:可以同时测试库和使用它的应用
- 统一更新:便于统一升级所有相互依赖的包
4.3 升级/管理不同项目的依赖
在monorepo
中管理依赖版本是一项重要工作,pnpm
提供了多种工具:
查看过时依赖:
json
# 查看所有项目的过时依赖
pnpm -r outdated
# 查看特定项目的过时依赖
pnpm --filter @my-scope/web outdated
更新依赖:
json
# 更新根依赖
pnpm update
# 更新特定包的所有依赖
pnpm update --filter @my-scope/web
# 更新特定依赖
pnpm update react react-dom --filter @my-scope/web
# 更新所有项目的特定依赖
pnpm -r update typescript
管理依赖版本:
- 固定版本:使用精确版本号("react": "18.2.0")
- 范围版本:使用语义版本范围("react": "^18.2.0")
- 集中管理 :在根目录使用工具如
npmrc
或类似工具
处理依赖冲突:
使用pnpm
的覆盖功能解决冲突
json
{
"pnpm": {
"overrides": {
"foo": "^1.0.0",
"bar@^2.1.0": "3.0.0"
}
}
}
4.4 批量执行命令
pnpm
提供了多种方式在工作区的多个包中执行命令:
使用 -r 或 --recursive:
json
# 在所有包中执行build命令
pnpm -r build
# 在所有包中并行执行build命令
pnpm -r --parallel build
# 按拓扑顺序(依赖关系)执行命令
pnpm -r --topological build
使用 --filter 选择特定包:
json
# 在单个包中执行命令
pnpm --filter @my-scope/web build
# 在多个包中执行命令
pnpm --filter "@my-scope/web" --filter "@my-scope/api" build
# 使用glob模式选择包
pnpm --filter "./packages/*" build
高级过滤选择:
json
# 在某个包及其所有依赖项中执行命令
pnpm --filter @my-scope/web... build
# 在依赖某个包的所有包中执行命令
pnpm --filter ...@my-scope/utils build
# 根据package.json字段过滤
pnpm --filter "[private=true]" build
在根目录定义通用脚本:
json
{
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
"dev": "pnpm --filter @my-scope/web dev"
}
}
5. 进阶内容
5.1 Turborepo
/ Nx
/ Rush
等工具协助 Monorepo
任务管理
虽然pnpm
已经提供了不错的monorepo
支持,但对于更大规模的项目,可能需要额外的工具来优化工作流:
Turborepo
Turborepo
是一个高性能的构建系统,专为JavaScript/TypeScript monorepos设计:
主要特点:
- 增量构建:只重新构建已更改的部分
- 远程缓存:团队共享构建缓存
- 任务管道:定义任务之间的依赖关系
- 并行执行:最大化CPU利用率
- 智能任务调度:基于依赖图优化构建顺序
配置示例 (turbo.json
):
json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"lint": {},
"deploy": {
"dependsOn": ["build", "test", "lint"]
}
}
}
5.2 发布 Monorepo
包(比如用 changesets 工具)
发布monorepo
中的包需要特殊的工具和策略,changesets是最流行的选择之一:
Changesets
的工作流程:
- 创建变更集:记录对特定包的更改
- 版本管理:根据变更集确定版本升级
- 生成变更日志 :自动创建
CHANGELOG.md
- 发布包 :将包发布到
npm
安装和配置Changesets
:
json
# 安装
pnpm add -Dw @changesets/cli
# 初始化
pnpm changeset init
这会在项目根目录创建.changeset
目录和配置文件。
基本使用流程:
- 添加变更集:
json
pnpm changeset
- 版本更新:
json
pnpm changeset version
这会根据变更集更新package.json
中的版本并生成CHANGELOG.md。
- 发布包:
json
pnpm changeset publish
这会发布所有已更新版本的包到npm
。
Changesets
配置示例:
.changeset/config.json
:
json
{
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
与CI/CD集成:
在GitHub Actions中自动化发布流程:
json
name: Release
on:
push:
branches:
- main
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
- run: pnpm install
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
publish: pnpm changeset publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
其他发布工具:
Lerna
:经典的monorepo
管理工具,虽然现在与nx集成- Release It:可配置的发布自动化工具
- Semantic Release:全自动版本管理和包发布
6. 实战练习
下面是一个从零开始搭建简单monorepo
项目的步骤:
总结
pnpm Monorepo
是一种强大的项目组织方式,特别适合管理多个相关但独立的项目。通过本指南,我们学习了:
Monorepo
的概念和适用场景pnpm
的优势和工作原理- 如何搭建和配置
pnpm Monorepo
- 项目结构和包管理最佳实践
- 依赖管理和版本控制
- 日常开发操作和命令
- 进阶工具和性能优化
- 实战练习
Monorepo
不仅仅是一种代码组织方式,更是一种开发理念和工作流程。它鼓励代码共享和重用,促进团队协作,简化项目管理。随着项目规模的增长,合理使用Monorepo
可以显著提高开发效率和代码质量。
- 清晰的分层:apps 包含最终产品,packages 包含可复用的库
- 职责分离:每个包都有明确的职责和边界
- 依赖关系清晰:通常 apps 依赖 packages,packages 之间也可能有依赖关系
- 独立性与共享性的平衡:既保持了各个项目的独立性,又能方便地共享代码