如果你访问过Vue的仓库代码,你会发现这样一个结构,里面有一个文件叫pnpm-workspace.yaml,你看一下你的做过项目中有没有这个文件,如果有你可能就不用继续看下去了,如果没有那你可能还没了解掌握这个知识,可以进一步看看
什么是Monorepo?
场景假设
你有三个及以上的仓库项目
- 假设三个项目都用Vue3 + Elment Plus + Vite等相同的依赖,某天Elment Plus增加了一个新的组件,你需要升级依赖,然后你得手动去三个仓库都升级一遍。然后某天某个库又有新的东西了,你想升级,你又要多个仓库升级一遍...
- 假设你在某个项目中开发的功能,在其他的项目中也要使用,然后你就开始了复制粘贴,或者你提出将这部分发布成包,那只要你的功能一更新,每个项目也要跟着去更新....
你说这有什么关系,不就多花点时间,不就稍微麻烦了一点吗?确实,但是优秀的程序员永远不浪费时间在重复的坑里 优秀程序员的终极目标:让代码自己跑,而不是让程序员自己跑。 当然,如果项目特别小或者完全独立,可能没必要一定要使用Monorepo。
Monorepo介绍
Monorepo (单一代码库)是一种将多个项目的代码存储在一个共享的代码库中的开发模式。与传统的多仓库模式不同,Monorepo 将所有相关的项目代码放在同一个版本控制系统的单个仓库中,并且这些项目还可能会有相互依赖的关系。
Monorepo的核心特点
- 集中管理:将多个相关项目集中在一个代码库中
- 模块化设计:各应用都是独立的
- 现代技术栈:采用现代JavaScript框架
- 易于扩展:项目结构清晰,支持快速扩展新的功能
Monorepo的好处与挑战
好处
- 提高开发效率:更容易项目之间共享代码和功能,避免重复实现
- 简化依赖管理:集中管理依赖关系,确保使用相同版本的依赖库
- 便于集成和测试:可以一次性构建和测试整个系统
- 统一的构建和部署流程:确保整个系统的一致性和可靠性
- 更好的代码质量和协作:知识共享
挑战
- 代码库规模和复杂度增加
- 团队协作和权限管理问题
包管理器
说到Monorepo,那肯定离不开包管理器的介绍,如果说没有合适的包管理器,Monorepo就像一辆没有引擎的跑车。
npm
Node.js默认的包管理器,早期版本(<3)深度嵌套,v3+ 开始扁平化(hoisting),使用嵌套 node_modules的安装策略。
项目A/node_modules/
├── vue@3.0.0/
├── element-plus@2.0.0/
│ └── node_modules/
│ ├── lodash@4.17.0/ # 扁平化提升
│ └── dayjs@1.10.0/ # 扁平化提升
├── lodash@4.17.0/ # ❌ 幽灵依赖
└── dayjs@1.10.0/ # ❌ 幽灵依赖
优点
- 生态系统最广泛:拥有最大的包仓库,兼容性最好
- 语义化版本控制:通过
package.json管理依赖,支持^、~等版本约束 - 版本锁定:通过
package-lock.json锁定依赖版本 - 持续改进:v7+ 支持 workspaces、自动 peerDependencies 安装等
缺点
- “幽灵依赖”:未声明但能用的包
- 安装速度慢:依赖逐个下载,无法并行化
- 磁盘空间浪费:每个项目独立存储依赖,导致冗余
- 依赖冲突:嵌套依赖关系复杂,容易出现版本不一致
- 重复安装:即使依赖已缓存,仍会重新下载
- 输出信息冗长:安装过程中大量日志,难以定位问题
yarn
Facebook出品,为解决npm的性能和一致性问题,使用扁平化 + lock 文件的安装策略
项目A/node_modules/
├── vue@3.0.0/
├── element-plus@2.0.0/
│ └── node_modules/
│ ├── lodash@4.17.0/ # 扁平化提升
│ └── dayjs@1.10.0/ # 扁平化提升
├── lodash@4.17.0/ # ❌ 幽灵依赖
└── dayjs@1.10.0/ # ❌ 幽灵依赖
优点
- 安装速度快:并行下载依赖,显著提升安装效率
- 依赖一致性:通过
yarn.lock文件精确锁定版本 - 离线安装:利用缓存实现离线安装,提升开发体验
缺点
- 依赖管理复杂:在大型项目中,依赖解析可能变得复杂,仍有幽灵依赖问题
- 磁盘空间:比pnpm浪费更多空间(但仍优于npm)
pnpm
使用基于内容寻址的存储 + 硬链接/符号链接(也叫软链接) 安装策略,所有包存于全局store(如 ~/.pnpm-store),项目中的node_modules通过符号链接指向store的具体版本, 使用符号链接的node_modules 结构,严格隔离依赖,避免幽灵依赖。目前 pnpm 因其性能、磁盘效率和严格性,正成为越来越多新项目的首选。
这里需要先了解一下pnpm的硬链接和符号链接
硬链接用于文件
- pnpm 将每个包的 文件 存储在全局 store 中。
- 当构建项目
node_modules时,每个文件 都通过 硬链接 从 store 链接到node_modules/.pnpm/...中。 - 这样做可以:
- 节省磁盘空间(不复制文件)
- 保持高性能(硬链接几乎无开销)
- 保证一致性(所有项目使用同一份文件内容
符号链接用于目录结构(或包入口)
- pnpm 使用 符号链接 来构建
node_modules的目录结构,特别是为了实现 平铺(hoisting) 和 依赖解析。 - 例如:
node_modules/lodash→ 符号链接 →node_modules/.pnpm/lodash@4.17.21/node_modules/lodash- 这样可以让 Node.js 的模块解析器找到正确的包,同时保持隔离(避免幽灵依赖)。
pnpm 的 node_modules 是一个“虚拟”的目录结构,大量使用符号链接来模拟传统 npm 的布局,但底层文件仍是硬链接到 store。
另外再不同的操作系统上,硬链接和符号链接的行为略有不同,这不进一步描述。
全局存储 (.pnpm-store)
├── lodash@4.17.0 (10MB) # 只存 1 份(所有项目共享)
└── dayjs@1.10.0 (8MB) # 只存 1 份
项目A/node_modules/
├── vue@3.0.0/
├── element-plus@2.0.0/ # 项目A声明的依赖
│ └── node_modules/ # 严格隔离!
│ ├── lodash@4.17 # ✅ 硬链接 → 指向全局存储的 lodash@4.17.0
│ └── dayjs@1.10 # ✅ 硬链接 → 指向全局存储的 dayjs@1.10.0
├── lodash@4.17 # ✅ 项目A声明的依赖(硬链接 → 指向全局存储的 lodash@4.17.0)
└── dayjs@1.10 # ✅ 项目A声明的依赖(硬链接 → 指向全局存储的 dayjs@1.10.0)
{
"dependencies": {
"vue": "^3.0.0",
"element-plus": "^2.0.0",
"lodash": "^4.17.0", // ✅ 必须声明
"dayjs": "^1.10.0" // ✅ 必须声明
}
}
优点:
- 节省磁盘空间(相同包只存一份)
- 安装速度快(利用硬链接,无需复制文件)
- 依赖隔离严格,符合 Node.js 模块解析规范
- 支持 workspace、monorepo 友好
缺点
- 符号链接在某些环境(如 Docker、Windows)可能有兼容性问题(但已大幅改善)
基于Pnpm的Monorepo方案
看完上面的包管理器,你会发现pnpm天然就支持monorepo,那么接下来将介绍一下,以pnpm作为包管理器的Monorepo方案
pnpm-workspace
工作区定义
在项目根目录创建 pnpm-workspace.yaml 文件,用于声明哪些目录属于 workspace:
# pnpm-workspace.yaml
packages:
- 'packages/*' # 所有 packages/ 下的子目录
- 'apps/**' # 支持嵌套(如 apps/web, apps/api)
- '!**/test' # 排除 test 目录(可选)
注意:每个子目录必须是一个合法的 npm 包(即包含 package.json,且有 name 字段)
本地链接(Local Linking)
当一个 workspace 包 A 依赖另一个 workspace 包 B(通过 name 引用),pnpm 会自动将其链接为符号链接,而不是从 registry 安装。这个在组件库开发的时候,非常的实用。
// packages/utils/package.json
{
"name": "my-utils",
"version": "1.0.0"
}
// packages/app/package.json
{
"name": "my-app",
"dependencies": {
"my-utils": "workspace:*" // 关键:使用 `workspace:*` 或 `workspace:^1.0.0` 等协议,显式声明这是 workspace 内部依赖。
}
}
my-app/node_modules/my-utils→ 符号链接 →../../utils- 修改
my-utils的代码,my-app立即可用(无需npm link)
pnpm 不会像 npm/yarn 那样 hoist(提升)所有依赖到根 node_modules,每个包的依赖严格隔离在 node_modules/.pnpm/ 中,但 workspace 包之间通过符号链接直接引用,避免版本冲突和重复安装
示例项目结构
my-monorepo/
├── pnpm-workspace.yaml
├── package.json # 根 package.json(可选,通常只放 scripts)
├── pnpm-lock.yaml
├── packages/
│ ├── utils/
│ │ └── package.json # name: "my-utils"
│ └── components/
│ └── package.json # name: "my-components", deps: { "my-utils": "workspace:*" }
└── apps/
└── web/
└── package.json # name: "my-web-app", deps: { "my-components": "workspace:*" }
Lerna + pnpm workspace
如果你是开发组件库,我建议使用这种模式,有较好的本地开发联动,Lerna 负责发布/脚本,pnpm 负责链接。
初始化根 package.json
// package.json
{
"name": "my-component-monorepo",
"private": true,
"scripts": {
"build": "pnpm -r --filter=./packages/* run build",
"dev": "pnpm -r --filter=./packages/docs run dev",
"test": "pnpm -r run test",
"release": "lerna version && lerna publish from-package",
},
"devDependencies": {
"lerna": "^8.0.0"
}
}
注意:private: true 防止根项目被误发布;不需要发布的都应该加上这个标识。
初始化 Lerna(使用 pnpm 作为客户端)
// lerna.json
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "independent", // 或 "0.0.0" 表示统一版本
"npmClient": "pnpm",
"useWorkspaces": true
}
关键配置:
"useWorkspaces": true:告诉 Lerna 使用 pnpm workspace,不自己管理 node_modules"npmClient": "pnpm":使用 pnpm 安装依赖
💡 版本策略选择:
"version": "independent":每个包独立版本(适合大型生态)"version": "1.2.0"(固定字符串):所有包统一版本(适合组件库)
版本与发布
配置对应的命令即可使用交互式的发布方式,如lerna version && lerna publish from-package
常用命令
pnpm 常用命令表
| 场景 | 命令 | 说明 |
|---|---|---|
| 安装依赖 | pnpm install 或 pnpm i | 安装项目所有依赖,自动处理 workspace 链接 |
| 添加生产依赖 | pnpm add <package> | 在当前包中添加依赖 |
| 添加开发依赖 | pnpm add -D <package> | 添加到 devDependencies |
| 全局安装包 | pnpm add -g <package> | 全局安装(较少用) |
| 从 workspace 添加本地包 | pnpm add <workspace-package>@workspace:* | 链接到本地 workspace 包 |
| 递归运行脚本(所有包) | pnpm -r run <script-name> | 在所有 workspace 包中运行指定 npm script |
| 递归运行(过滤特定包) | pnpm -r --filter=<package-name> run <script> | 仅在匹配的包中运行脚本 |
| 递归运行(自上次 Git 提交变更的包) | pnpm -r --filter="...[origin/main]" run build | 基于 Git diff 自动选择包 |
| 列出所有 workspace 包 | pnpm m ls 或 pnpm ls --depth=-1 | 显示所有本地包及其版本 |
| 查看某个包的依赖来源 | pnpm why <package> | 显示为何安装了该包(依赖路径) |
| 更新依赖 | pnpm update | 更新所有依赖到 lockfile 允许的最新版本 |
| 删除依赖 | pnpm remove <package> 或 pnpm rm <package> | 从当前包移除依赖 |
| 清理 store(谨慎) | pnpm store prune | 删除未被引用的 store 文件,节省磁盘空间 |
💡 注意:
pnpm -r是pnpm recursive的缩写,专用于 monorepo 场景。
Lerna 常用命令表
| 场景 | 命令 | 说明 |
|---|---|---|
| 初始化配置(手动为主) | (通常手动创建 lerna.json) | 现代 Lerna 推荐直接编写配置文件 |
| 列出所有包 | lerna ls | 显示仓库中所有 package 的名称和版本 |
| 在所有包中运行脚本 | lerna run <script-name> | 执行每个包 package.json 中定义的脚本 |
| 在指定包中运行脚本 | lerna run <script> --scope=<package-name> | 仅在匹配名称的包中运行 |
| 运行自上次发布后变更的包 | lerna run <script> --since | 基于 Git 标签自动判断变更包 |
| 并行运行脚本 | lerna run <script> --concurrency=4 | 控制并发数量(默认并发) |
| 升级版本(交互式) | lerna version | 交互式 bump 版本,更新 package.json,生成 commit 和 tag |
| 自动升级版本(基于 Conventional Commits) | lerna version --conventional-commits | 根据 commit 信息自动决定版本号 |
| 发布变更的包到 npm | lerna publish | 发布有新版本的包到 npm registry |
| 从 Git 标签发布 | lerna publish from-git | 仅发布已打 tag 的包(推荐 CI 使用) |
| 从 package.json 发布 | lerna publish from-package | 发布本地 version 高于 registry 的包 |
| 执行任意命令 | lerna exec -- <command> | 在每个包目录中执行 shell 命令,如 lerna exec -- pwd |
| 查看依赖图 | lerna ls --graph | 输出包之间的依赖关系(JSON 格式) |
💡 注意:Lerna 不负责安装依赖,依赖管理应交给 pnpm/yarn/npm。配置中需设置
"useWorkspaces": true以兼容 pnpm workspace。