第六章:大型项目管理术——Monorepo 架构与 PNPM Workspaces 实战

第六章:大型项目管理术——Monorepo 架构与 PNPM Workspaces 实战

摘要: 当我们的项目从一个独立的“小作坊”成长为由多个包(如组件库、主应用、文档站)构成的“联合企业”时,传统的 Multi-repo(多仓库)管理模式将面临代码复用困难、依赖管理混乱等巨大挑战。本章,我们将学习解决这一问题的现代前端架构模式——Monorepo(单体仓库)。我们将深入对比它与 Multi-repo 的优劣,并以一个真实的设计系统为案例,手把手教你使用 PNPM Workspaces 搭建、管理和维护你自己的第一个 Monorepo 项目。


6.1. 什么是 Monorepo?它与传统的 Multi-repo 有何不同?

在软件开发中,代码仓库的管理方式主要有两种:

  • Multi-repo (多仓库): 这是最传统、最常见的方式。每个项目、每个库都有自己独立的 Git 仓库。例如,webapp 在一个仓库,ui-components 在另一个仓库。
  • Monorepo (单体仓库): 将所有相关的项目、库都放在同一个 Git 仓库中进行管理。例如,webappui-components 都在同一个仓库的不同子目录中。

这两种模式在工作流、代码共享、版本控制等方面有着天壤之别。

对比维度Multi-repo (多仓库)Monorepo (单体仓库)
代码库管理每个包一个独立的 Git 仓库。所有包共享同一个 Git 仓库。
代码共享困难。共享代码需发布为 npm 包,本地调试需 npm link,流程繁琐。极其容易。可以直接在仓库内部互相引用,无需发布,修改即时生效。
依赖管理分散。每个包有自己的 node_modules,易造成依赖版本不一致和冗余。集中。可将公共依赖提升到根目录,统一管理,保证版本一致。
版本控制分散。每个包独立打版本号。可统一版本,也可独立版本,管理方式更灵活。
原子化提交不可能。一个跨多包的功能修改,需要向多个仓库提交 commits。核心优势。可以一次 commit 完成跨多包的修改,保持逻辑的原子性。
构建与 CI/CD简单。每个仓库配置独立的 CI 流程。较复杂。需要工具支持来识别变更范围,实现按需构建和测试。
适用场景互相完全独立的项目。互相之间有代码共享、有强依赖关系的多个项目。

6.2. Monorepo 的优势:以“设计系统”为例

为了更具体地感受 Monorepo 的威力,让我们构思一个真实的企业级场景:Acme 公司要构建自己的设计系统

这个系统至少包含三个包:

  • @acme/ui-components: 核心的 UI 组件库 (React/Vue)。
  • @acme/docs: 用于展示和测试组件的文档站 (VitePress/Storybook)。
  • @acme/webapp: 使用这套组件库的官方主应用。

Multi-repo 模式下,工作流会是这样:

  1. 修改了 @acme/ui-components 里的一个按钮组件。
  2. 为了在文档站里看效果,需要先将组件库发布一个 beta 版本到 npm。
  3. @acme/docs 项目更新依赖,安装这个 beta 版本,然后启动调试。
  4. 调试通过后,再将组件库发布一个正式版。
  5. @acme/webapp@acme/docs 再更新到正式版。这个流程因为涉及多次发布和安装,效率极其低下。

而在 Monorepo 模式下,工作流将变得无比丝滑:

  1. 直接在 Monorepo 中修改 @acme/ui-components 的按钮组件代码。
  2. 由于 @acme/docs 是直接通过源码引用组件库的,它的开发服务器会 立即热更新,实时看到修改效果。
  3. 调试完成后,进行 一次原子化提交,这个提交同时包含了组件库的修改和(可能有的)文档站的适配修改。

Monorepo 的核心优势在于,通过将所有相关代码集中管理,极大地 降低了跨包协作的成本,让多包开发如同在一个项目中修改不同模块一样简单。


6.3. 实战入门:使用 PNPM Workspaces 搭建你的第一个 Monorepo

PNPM 内置了对 Monorepo(在 PNPM 中称为 Workspaces/工作区)的顶级支持。现在,让我们亲手搭建上面提到的 Acme 设计系统。

第一步:创建 Monorepo 根目录

1
2
3
4
5
6
7
8
9
# 创建并进入项目根目录
mkdir acme-design-system && cd acme-design-system

# 初始化根 package.json
pnpm init

在 package.json 的 下方加上 private: true 是 Monorepo 根目录的最佳实践,它表示这个包是私有的,不会被发布到公共仓库 。
"packageManager": "pnpm@10.14.0",
"private": true

第二步:定义工作区 (Workspace)

创建 pnpm-workspace.yaml 文件,这是声明此项目为 Monorepo 的“开关”。

文件路径: acme-design-system/pnpm-workspace.yaml

1
2
3
packages:
# 我们约定,所有子项目都放在 'packages' 目录下
- 'packages/*'

第三步:创建子项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 创建用于存放所有子项目的目录
mkdir packages

# 进入 packages 目录并创建三个子项目
cd packages
mkdir ui-components
mkdir docs
mkdir webapp
# 分别初始化每个子项目
cd ui-components && pnpm init && cd ..
cd docs && pnpm init && cd ..
cd webapp && pnpm init && cd ..

# 回到项目根目录
cd ..

此时,你的项目结构应该是这样:

1
2
3
4
5
6
7
8
9
10
# acme-design-system/
├── packages/
│ ├── docs/
│ │ └── package.json
│ ├── ui-components/
│ │ └── package.json
│ └── webapp/
│ └── package.json
├── package.json
└── pnpm-workspace.yaml

第四步:安装公共依赖

我们的三个子项目很可能都依赖 reacttypescript。我们可以把这些公共依赖安装在 Monorepo 的 根目录,让所有子项目共享。

使用 -w--workspace-root 标志来告诉 PNPM 在根目录安装。

1
2
# 在 Monorepo 根目录执行
pnpm add react react-dom typescript -D -w

执行后,react 等依赖会被安装到根目录的 node_modules 中,并被自动“提升”(hoist),所有子项目都可以直接引用到它们,无需重复安装。


6.4. 依赖管理与内部包的互相引用

这是 Monorepo 最神奇的地方:如何让 @acme/webapp 依赖本地的 @acme/ui-components

第一步:修改子项目名称

为了能互相引用,我们需要先给子项目 package.json 里的 name 字段赋予规范的、带作用域的名称。

  • packages/ui-components/package.json -> "name": "@acme/ui-components"
  • packages/docs/package.json -> "name": "@acme/docs"
  • packages/webapp/package.json -> "name": "@acme/webapp"

第二步:使用 workspace:* 协议

现在,我们编辑 webapppackage.json,让它依赖 ui-components

文件路径: packages/webapp/package.json

1
2
3
4
5
6
7
8
{
"name": "@acme/webapp",
"version": "1.0.0",
...
"dependencies": {
"@acme/ui-components": "workspace:*"
}
}

workspace:* 这个特殊的协议告诉 PNPM:“我依赖的 @acme/ui-components 不是 NPM 仓库里的某个版本,而是 当前工作区中的那个本地包”。

第三步:运行 pnpm install

Monorepo 根目录 运行:

1
pnpm install

PNPM 会识别到 workspace:* 协议,然后它不会去下载任何东西,而是会在 packages/webapp/node_modules 目录下,创建一个指向 packages/ui-components 目录的 符号链接

第四步:在子项目中运行脚本

如果你想运行 webappdev 脚本,可以使用 -F--filter 标志:

1
2
# 假设 webapp 的 package.json 中有 "dev": "vite"
pnpm --filter @acme/webapp dev

这个命令会精准地只运行指定子项目的脚本,是 Monorepo 开发中的日常操作。


6.5. 本章核心速查总结

分类关键项核心描述
核心概念Monorepo在单一 Git 仓库中管理多个相关联的包,以提升代码复用和协作效率。
核心概念Multi-repo每个包拥有独立的 Git 仓库,是传统的、分散式的管理方式。
PNPM 配置pnpm-workspace.yaml(关键) 用于声明一个项目是 PNPM Monorepo 并定义工作区范围的文件。
PNPM 协议workspace:*(核心)package.json 中使用,用于声明一个依赖是工作区内的本地包。
核心命令pnpm add <pkg> -w在 Monorepo 根目录 安装一个所有包共享的依赖。
核心命令pnpm --filter <pkg_name>(常用) 在 Monorepo 中,精准地对指定子项目执行命令(如 dev, build)。

6.6. 高频面试题与陷阱

面试官深度追问
2025-08-30

你们为什么选择使用 Monorepo 架构?它主要解决了你们团队的什么问题?

我们选择 Monorepo 主要是为了解决跨项目代码复用的问题。比如我们的主应用、文档站都需要使用同一个内部组件库。在 Monorepo 中,组件库可以直接被其他项目以源码形式引用,任何修改都能即时生效,无需发布 npm 包,极大地提升了开发联调的效率。此外,它还带来了依赖集中管理和原子化提交的好处,保证了所有相关项目技术栈和版本的一致性。

听起来不错。那在 PNPM Workspaces 中,如果我想让 webapp 依赖工作区里的 ui-components,我应该怎么在 package.json 中声明?pnpm install 时会发生什么?

我会在 webapppackage.jsondependencies 中,将 @acme/ui-components 的版本号指定为 workspace:*。当在 Monorepo 根目录运行 pnpm install 时,PNPM 会解析这个协议,它不会去 npm 仓库下载,而是在 webappnode_modules 目录下创建一个指向 packages/ui-components 源码目录的符号链接。这样就实现了本地包之间的高效引用。

好的。现在 Monorepo 里有几十个包,我只想运行其中一个,比如 @acme/docsbuild 脚本,应该用什么命令?

我会使用 --filter 标志。在 Monorepo 的根目录执行 pnpm --filter @acme/docs build。这个命令可以让 PNPM 精准地定位到 @acme/docs 这个包,并只在它的上下文中执行 build 脚本,避免了进入特定目录的麻烦,也方便在 CI/CD 流程中进行自动化操作。