DRAFT “分久必合” 利用 Monorepo 优化仓库结构 —— git subtree 版本
约 3249 字大约 11 分钟
2026-04-09
本文是基于工作中的实际情况进行的优化。每个人的实际情况各不相同,文中的方法和思路仅供参考。
背景:一盘散沙的依赖仓库,令人头疼的维护成本
目前公司的项目仓库总数超过 200 个。其中除去 30 多个实际的服务仓库,剩下的基本为内部依赖仓库(比如日志组件、测试组件、常量等)。
鉴于对系统安全的日益重视,针对这些仓库依赖的维护工作也是非常头疼的一件事。尽管引入了 Renovate 帮忙自动更新,但受限于合并规则,必须要人工介入手工合并。类似 Node.js 的升级、CVE 对应都是非常消耗人力成本的事情。
终于在 Node 24 升级和应对近期 NPM 供应链投毒的任务下,“积怨已久”的团队决定利用 Monorepo 来改善现有的仓库结构,减少未来的维护工作。
什么是 Monorepo?
在正式开始之前,先简单地介绍一下 Monorepo 的概念: Monorepo 是一种将多个项目(应用、库)的代码存放在同一个代码仓库中的软件工程策略,与与之对应的是传统的Polyrepo(一个项目一个独立仓库)。
简单来说,就是把所有仓库以文件夹的形式放到同一个仓库中。一个简单的 Monorepo 目录结构如下:
my-monorepo/
├── package.json
├── packages/ # 收纳所有共享库
│ ├── constants/
│ │ ├── src/
│ │ ├── package.json # name: "@my-repo/constants"
│ │ └── tsconfig.json
│ │
│ ├── logger/
│ │ ├── src/
│ │ ├── package.json # name: "@my-repo/logger"
│ │ └── tsconfig.json
│ │
│ └── test-utils/ # 测试辅助库
│ ├── src/
│ └── package.json # name: "@my-repo/test-utils"
│
└── node_modules/ # 所有依赖的物理存储位置(硬链接)那对于 Node.js 项目 Monorepo 能带来哪些好处呢?
| 优势 | 说明 |
|---|---|
| 原子化提交 | 修改一个共享包后,所有引用它的应用可以一次性提交,避免版本不匹配。 |
| 统一的依赖管理 | 全仓库统一依赖版本,减少冲突,节省磁盘空间。 |
| 代码复用极简 | 无需走 npm publish 流程,直接通过 workspace:* 协议引用本地源码。 |
| 重构友好 | 修改 API 时,TypeScript 会立即在所有引用处报错,避免遗漏。 |
仓库结构改造
接下来就围绕实际的项目进行改造。为了尽可能减小对系统的影响和控制范围,仓库的改造仅针对依赖库仓库。
启用 PNPM Workspace
利用 git subtree 实现子包拉取和独立推送
彩蛋:意外收获
结局:
我们的项目都切换为了 PNPM 作为报管理器
Monorepo 从入门到实践:依赖库仓库合并与独立推送完全指南
本文档系统性地汇总了关于使用 pnpm 构建 Monorepo,并通过 git subtree 实现子包独立推送的全部知识点。内容由浅入深,适合从零开始搭建依赖库 Monorepo 的开发者阅读。
目录
- Monorepo 基础概念
- pnpm 在 Monorepo 中的核心机制
- 依赖库类型 Monorepo 的目录结构
- 子包之间的引用方式:workspace 协议
- 依赖安装的物理位置与逻辑归属
- pnpm-lock.yaml 的统一管理与子包隔离
- 使用 git subtree 将子包推送到独立仓库
- 裸仓库的概念与 subtree push 的关系
- 推送前的关键步骤:处理 lock 文件与 workspace 协议
- 总结:完整工作流一览
1. Monorepo 基础概念
Monorepo 是一种将多个项目(应用、库)的代码存放在同一个代码仓库中的软件工程策略,与传统的 Polyrepo(一个项目一个独立仓库)相对应。
1.1 核心机制:Workspace(工作区)
Monorepo 的核心是 Workspace 机制。通过在根目录放置一个配置文件(如 pnpm-workspace.yaml),告诉包管理器:"这个目录下的若干子文件夹都是独立的子项目,但它们可以互相引用,并且依赖统一管理。"
1.2 为什么选择 Monorepo?
| 优势 | 说明 |
|---|---|
| 原子化提交 | 修改一个共享包后,所有引用它的应用可以一次性提交,避免版本不匹配。 |
| 统一的依赖管理 | 全仓库统一依赖版本,减少冲突,节省磁盘空间。 |
| 代码复用极简 | 无需走 npm publish 流程,直接通过 workspace:* 协议引用本地源码。 |
| 重构友好 | 修改 API 时,TypeScript 会立即在所有引用处报错,避免遗漏。 |
1.3 Monorepo 工具链演进
- 第一阶段:
yarn workspaces/pnpm workspaces—— 解决依赖安装和链接问题。 - 第二阶段:
Turborepo/Nx/Rush—— 解决构建、测试的任务编排与缓存问题。
2. pnpm 在 Monorepo 中的核心机制
2.1 为什么 pnpm 特别适合 Monorepo?
| 特性 | 在 Monorepo 中的优势 |
|---|---|
| 严格的依赖隔离 | 非扁平化的 node_modules 结构防止"幽灵依赖",子包 A 无法引用子包 B 未声明的依赖。 |
| 原生 workspace 协议 | "shared-utils": "workspace:*" 自动链接本地源码,开发体验流畅。 |
| 硬链接存储 | 所有依赖物理上只存一份,多个子包重复依赖时磁盘占用极小。 |
| 安装速度快 | 比 npm/yarn 快 2-3 倍,在依赖数量巨大的 Monorepo 中优势明显。 |
2.2 pnpm Workspace 核心配置文件
pnpm-workspace.yaml 文件内容:
packages:
- 'packages/*'根目录 package.json 文件内容(必须包含 private: true):
{
"name": "my-monorepo-root",
"private": true,
"scripts": {
"build": "pnpm -r run build",
"test": "pnpm -r run test"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}3. 依赖库类型 Monorepo 的目录结构
当目的是将所有依赖库(如 constants、logger、test-utils)集中管理时,推荐以下结构:
my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── pnpm-lock.yaml
├── .npmrc
│
├── packages/ # 收纳所有共享库
│ ├── constants/
│ │ ├── src/
│ │ ├── package.json # name: "@my-repo/constants"
│ │ └── tsconfig.json
│ │
│ ├── logger/
│ │ ├── src/
│ │ ├── package.json # name: "@my-repo/logger"
│ │ └── tsconfig.json
│ │
│ └── test-utils/ # 测试辅助库
│ ├── src/
│ └── package.json # name: "@my-repo/test-utils"
│
└── node_modules/ # 所有依赖的物理存储位置(硬链接)4. 子包之间的引用方式:workspace 协议
4.1 正确方式:通过 workspace:* 声明依赖
在 packages/logger/package.json 中:
{
"name": "@my-repo/logger",
"dependencies": {
"@my-repo/constants": "workspace:*"
}
}在代码中通过包名导入:
// packages/logger/src/index.ts
import { APP_NAME } from '@my-repo/constants';4.2 错误方式:相对路径
禁止使用 import xxx from '../constants'。原因:
- 破坏了包的独立性。
git subtree push后,目标仓库中../constants路径将不存在,导致构建失败。
4.3 workspace:* 的工作机制
| 阶段 | 行为 |
|---|---|
| 开发阶段 | pnpm 在 node_modules 中创建符号链接,指向本地源码,支持热更新和类型跳转。 |
| 推送/发布阶段 | 通过工具(如 isolate-package)将 workspace:* 自动替换为具体版本号(如 1.0.0)。 |
5. 依赖安装的物理位置与逻辑归属
5.1 物理存储:根目录 node_modules/
执行 pnpm install 后,所有依赖的实际文件都通过硬链接统一存储在:
/my-monorepo/node_modules/.pnpm/5.2 逻辑访问:子包下的符号链接
每个子包目录下也有 node_modules,但它们只是符号链接,指向根目录的真实文件:
packages/logger/
├── node_modules/
│ ├── lodash -> ../../../node_modules/.pnpm/[email protected]/node_modules/lodash
│ └── @my-repo/
│ └── constants -> ../../../../packages/constants # workspace 软链5.3 关键结论
| 维度 | 位置 | 说明 |
|---|---|---|
| 物理存储 | 根目录 node_modules/ | 节省磁盘空间,依赖只存一份。 |
| 逻辑访问 | 各子包 node_modules/(符号链接) | 保证 Node.js 模块解析正常,同时实现依赖隔离。 |
6. pnpm-lock.yaml 的统一管理与子包隔离
6.1 一个 Lock 文件管理所有子包
pnpm-lock.yaml 放在根目录,但它记录了所有子包的所有依赖。其内部结构按子包路径物理分区:
lockfileVersion: '6.0'
importers:
packages/constants:
dependencies:
lodash:
specifier: ^4.17.21
version: 4.17.21
packages/logger:
dependencies:
'@my-repo/constants':
specifier: workspace:*
version: link:../constants
pino:
specifier: ^8.0.0
version: 8.17.2
packages:
/[email protected]:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: false6.2 子包如何独立更新依赖?
| 操作 | 影响范围 |
|---|---|
pnpm add pino --filter @my-repo/logger | 只修改 logger/package.json 和 lock 文件中 logger 对应的区块。 |
pnpm add lodash --filter @my-repo/constants | 只修改 constants 相关的 lock 条目,不影响 logger。 |
6.3 Git 合并冲突概率极低
多人同时修改不同子包的依赖时,lock 文件修改的是不同区块(importers.packages/xxx),合并冲突远少于传统的 npm/yarn lock 文件。
7. 使用 git subtree 将子包推送到独立仓库
7.1 应用场景
在 Monorepo 中统一开发,但仍需要将每个包(如 constants、logger)推送到各自独立的远程仓库,以便外部项目通过 npm install 单独使用。
7.2 核心命令
添加子仓库到 Monorepo
git remote add constants <https://github.com/yourname/constants.git>
git subtree add --prefix=packages/constants constants main --squash从子仓库拉取更新
git subtree pull --prefix=packages/constants constants main --squash将本地修改推送到子仓库
git subtree push --prefix=packages/constants constants main7.3 注意事项
- 推送时建议使用
--squash压缩历史,保持子仓库提交记录简洁。 - 可将常用命令封装为 npm scripts 或 Shell 脚本简化操作。
8. 裸仓库的概念与 subtree push 的关系
8.1 什么是裸仓库(Bare Repository)?
裸仓库是一个没有工作目录的 Git 仓库,只存储 .git 文件夹中的版本历史数据,不包含可直接编辑的源码文件。
| 特性 | 普通仓库 | 裸仓库 |
|---|---|---|
| 能否直接编辑代码 | ✅ | ❌ |
| 创建方式 | git init | git init --bare |
| 目录名约定 | my-project | my-project.git |
| 典型用途 | 本地开发 | 远程中心仓库 |
8.2 为什么 git subtree push 要求目标是裸仓库?
Git 的安全机制:推送到普通仓库可能覆盖他人未提交的工作现场。裸仓库无工作目录,推送不会产生副作用,因此 Git 允许操作。
8.3 实际场景中的解决方案
- GitHub / GitLab 远程仓库:天然就是裸仓库,直接推送即可。
- 本地测试:使用
git init --bare constants.git创建本地裸仓库模拟远程环境。 - 绕过限制:推送到目标仓库的非当前分支(如
monorepo-export),再手动合并。
9. 推送前的关键步骤:处理 lock 文件与 workspace 协议
9.1 问题描述
直接 git subtree push 子目录会面临两个问题:
- 没有
pnpm-lock.yaml:目标仓库克隆后无法锁定依赖版本。 - 存在
workspace:*协议:目标仓库不在 Monorepo 上下文中,无法解析该协议,导致pnpm install失败。
9.2 解决方案:使用 isolate-package 工具
isolate-package 专为此场景设计,能在推送前将子包转换为完全自包含的普通 npm 包。
工作流程
# 1. 安装工具(在 Monorepo 根目录)
pnpm add -D isolate-package
# 2. 进入子包目录,执行构建和隔离
cd packages/constants
pnpm run build
npx isolateisolate 命令做的事:
- 从根目录
pnpm-lock.yaml中提取该子包及其所有依赖的版本信息。 - 将
package.json中的workspace:*替换为实际版本号(如1.0.0)。 - 在
packages/constants/isolate目录下生成专属的pnpm-lock.yaml和完整的包文件。
9.3 推送隔离后的产物
# 将处理后的产物目录推送到远程仓库
git subtree push --prefix=packages/constants/isolate constants main9.4 轻量级替代方案(适用于简单包)
如果包依赖非常简单,也可以手动处理:
- 手动将
package.json中的workspace:*改为具体版本号。 - 进入子包目录,删除
node_modules和pnpm-lock.yaml。 - 执行
pnpm install重新生成独立的 lock 文件。 - 推送整个子包目录。
10. 总结:完整工作流一览
10.1 初始化 Monorepo
# 1. 创建目录结构
mkdir my-monorepo && cd my-monorepo
# 2. 初始化 pnpm workspace
echo "packages:\n - 'packages/*'" > pnpm-workspace.yaml
# 3. 创建根 package.json
npm init -y
# 手动添加 "private": true
# 4. 添加现有子仓库
git remote add constants <url>
git subtree add --prefix=packages/constants constants main --squash10.2 日常开发
# 安装所有依赖
pnpm install
# 给特定子包添加依赖
pnpm add <pkg> --filter @my-repo/logger
# 批量构建
pnpm -r run build
# 运行测试
pnpm -r run test10.3 推送子包到独立仓库
# 1. 进入子包目录
cd packages/logger
# 2. 构建并隔离
pnpm run build
npx isolate
# 3. 推送隔离产物
git subtree push --prefix=packages/logger/isolate logger main10.4 关键要点回顾
| 要点 | 结论 |
|---|---|
| 子包引用方式 | 使用 workspace:* 协议,禁止相对路径。 |
| 依赖存储位置 | 物理统一在根目录,逻辑通过符号链接隔离。 |
| Lock 文件管理 | 一个 pnpm-lock.yaml 管理全部,内部按子包分区,互不干扰。 |
| Subtree 推送前提 | 目标仓库需为裸仓库(GitHub 等远程仓库天然满足)。 |
| 推送前处理 | 使用 isolate-package 转换协议并生成独立 lock 文件。 |
文档生成时间:2026年4月9日基于 pnpm v8+ 和 git subtree 工作流编写
