关于 Monorepo 的知识

84 阅读10分钟

如果你访问过Vue的仓库代码,你会发现这样一个结构,里面有一个文件叫pnpm-workspace.yaml,你看一下你的做过项目中有没有这个文件,如果有你可能就不用继续看下去了,如果没有那你可能还没了解掌握这个知识,可以进一步看看

Pasted image 20251011144309.png

什么是Monorepo?

场景假设

你有三个及以上的仓库项目

  • 假设三个项目都用Vue3 + Elment Plus + Vite等相同的依赖,某天Elment Plus增加了一个新的组件,你需要升级依赖,然后你得手动去三个仓库都升级一遍。然后某天某个库又有新的东西了,你想升级,你又要多个仓库升级一遍...
  • 假设你在某个项目中开发的功能,在其他的项目中也要使用,然后你就开始了复制粘贴,或者你提出将这部分发布成包,那只要你的功能一更新,每个项目也要跟着去更新....

你说这有什么关系,不就多花点时间,不就稍微麻烦了一点吗?确实,但是优秀的程序员永远不浪费时间在重复的坑里 优秀程序员的终极目标:让代码自己跑,而不是让程序员自己跑。 当然,如果项目特别小或者完全独立,可能没必要一定要使用Monorepo。

Monorepo介绍

Monorepo (单一代码库)是一种将多个项目的代码存储在一个共享的代码库中的开发模式。与传统的多仓库模式不同,Monorepo 将所有相关的项目代码放在同一个版本控制系统的单个仓库中,并且这些项目还可能会有相互依赖的关系。

Monorepo的核心特点

  1. 集中管理:将多个相关项目集中在一个代码库中
  2. 模块化设计:各应用都是独立的
  3. 现代技术栈:采用现代JavaScript框架
  4. 易于扩展:项目结构清晰,支持快速扩展新的功能

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 -rpnpm 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 信息自动决定版本号
发布变更的包到 npmlerna 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。