要理解 Monorepo 的底层原理,我们先忘掉那些高大上的工具名称,回归到最本质的问题:一个代码仓库如何管理多个相互依赖的包?
其实,Monorepo 的核心思想没什么魔法,主要还是依赖于包管理工具(如np,yarn,pnpm)的包依赖解析和软链接机制。
核心问题:如何让一个包引用同一仓库里的另一个包
假设我们有一个 Monorepo,里面有两个包:@my-org/ui 和 @my-org/app。app的包需要引用ui包里的组件。
如果按照传统做法,我们可能这么做:
- 在 ui 包里发布一个版本到 npm registry 上
- 在 app 包里,像引用其他普通 npm 包一样,安装 @my-org/ui@1.0.0。
这种方式在开发过程中非常麻烦,每当修改 ui 包,我们都必须发布新版本,然后去 app 包里修改依赖。这根本无法满足实时开发的需求。
Monorepo 的底层核心就是为了解决这个问题的。
解决方案:Workspaces 和软链接
现代包管理工具都提供了 Workspaces 功能,这是 Monorepo 得以实现的基石。以pnpm为例,它的 pnpm-workspace.yaml 文件会告诉它,仓库里哪些目录是独立的包。
# pnpm-workspace.yaml
packages:
- 'packages/*'
当你运行 pnpm install 时,pnpm不仅会处理外部依赖,还会解析并处理内部依赖。
回到例子中,app包的 package.json 文件里会有这样一行:
// packages/app/package.json
{
"name": "@my-org/app",
"dependencies": {
"@my-org/ui": "workspace:*" // 这里的 "workspace:*" 是关键
}
}
当 pnpm install 看到 workspace:*时,它会做以下两件事:
- 解析路径:它会发现 ui 包就在本仓库的 packages/ui 目录下
- 创建软链接:在 packages/app/node_modules/my-org/ 的目录下,它会创建一个软链接,这个链接指向 packages/ui 目录。
简单来说,这个 软链接 就是 Monorepo 的魔法所在:当你在 app 包里导入同仓库其它目录时,Node.js 就会沿着这个软连接找到 packages/ui 目录下的真实代码。这样一来,无论你修改 ui 包里的任何代码,app包都能立即看到最新修改,无需发布,更新版本。
软链接就像一个快捷方式,你修改源文件夹里的文件,快捷方式里看到的文件也能同步更新。
高级功能:任务编排与缓存
解决了包依赖问题,下一步就是高效地构建和测试所有包。如果每次修改一个文件就重新构建整个项目,效率会非常低下。这就是Nx和Turborepo等高级工具大显身手的地方。
这些高级工具的底层原理可以概括为以下几点:
-
依赖图:
这些工具首先会构建一个依赖图,这个图会清晰地展示所有包之间的依赖关系。
例如:app 依赖于 ui,ui 依赖于 utils。
通过这个图,工具可以智能地决定必须先构建utils,再构建 ui ,最后才构建 app。如果依赖图中有两个包没有依赖关系,它们可以同时构建,以节省时间。
-
任务缓存
这是高级 Monorepo 工具最强大的功能。如果一个包的输入没有改变,那么它的输出也不会改变。
为了验证这一点,这些工具会:
-
计算 哈希值:在执行一个任务(如 build 或 test)钱,它会计算该包所有输入文件的哈希值,这些输入包括:
- 该包的源代码文件。
- 它所依赖的包的输出文件。
- 配置文件(tsconfig.json,webpack.config.js)
- 环境变量
-
检查缓存:拿着这个哈希值,工具会从本地或远程缓存中寻找。
- 如果找到了匹配的哈希值,说明这个任务已经运行过,并且输入没有改变,工具会重用缓存中的结果。
- 如果找不到,工具才会真正执行这个任务。执行完成后,它会把结果存入缓存,并用哈希值作为索引。
通过这种方式,当你只修改了 app 包里的一个文件时,Turborepo 或 Nx 会发现 ui 或 utils 包的哈希值没变,因此会跳过它们的构建,只执行 app 的构建任务。这极大的提升了开发效率。
-
远程缓存
远程缓存是任务缓存的延伸。团队成员可以将本地结果上传到一个共享的远程服务器。这样,当一个成员运行一个任务时,即使他之前没有运行过,也可以直接从远程缓存中下载结果,而不是重新构建。这对于持续集成(CI)和团队协作尤为重要。
因此,依赖图和任务缓存解决了 Monorepo 最耗时的操作:构建,测试和打包。也解决了一个痛点:只修改了一个包,整个项目的 CI/CD 流程却要重新构建和测试所有服务。
题外扩展:为什么说 Pnpm 是 Monorepo 的理想选择
要理解为什么 pnpm 是 Monorepo 的理想选择,我们需要从 Monorepo 两个核心问题入手:
- 如何管理重复大量的依赖
- 如何处理内部包之间的依赖关系
Pnpm 的底层机制,特别是内容寻址存储和符号链接,完美解决了这些问题。
-
节省硬盘空间:内容寻址存储与硬链接
问题概述:在传统的包管理器(npm,yarn)中,每个项目的 node_modules 目录下会安装一套完整的依赖副本,对于项目中复杂的依赖关系会造成硬盘空间的巨大浪费。而 Pnpm 通过内容寻址机制从根本上解决了这个问题。
- 全局存储:当你 pnpm install 时,它会将所有依赖包下载到一个全局共享的存储目录中(通常位于 ~/.pnpm-store)。这个目录是内容寻址的,这意味着一个包只会以内容的哈希值作为名称,在硬盘上只存储一份物理副本。
- 硬链接:在 Monorepo 的每一个子包的 node_modules 目录下,pnpm 不会复制文件,而是会创建指向指向全局存储中文件的硬链接。因此,无论,你在多少个子包中安装了同一个依赖,它在硬盘上都会只存在一份物理副本。
这极大地节省了硬盘空间,也显著加快了安装速度,这对于 Monorepo 来说至关重要。
-
处理依赖关系:符号链接和扁平化结构
在一个大型复杂的项目中,处理内部包之间的依赖是一个巨大的挑战,而 pnpm 是如何解决的呢:
- Pnpm 使用了一种独特的 node_modules 结构,不会直接在根目录或子包的 node_modules 安装所有依赖。它会在 node_modules 下创建一个 .pnpm 的虚拟目录,这个目录的每个子目录代表一个包。
- 符号链接:项目根 node_modules 下的包,实际上是指向 .pnpm 目录中响应虚拟目录的符号链接
举个例子,如果你的 app 包依赖 ui 包,ui 包又依赖 lodash,pnpm 会这样做:
-
在全局存储中保存
ui和lodash的一份物理副本。 -
在 Monorepo 的根
node_modules中,创建软链接app -> packages/app和ui -> packages/ui。 -
在
packages/app/node_modules下,创建软链接ui,指向packages/ui。 -
在
packages/ui/node_modules下,创建软链接lodash,指向.pnpm/lodash@version虚拟目录。 -
在
.pnpm/lodash@version虚拟目录中,有指向全局存储的硬链接。
总结: 这样巧妙的结构尽可能地避免了幽灵依赖(无法使用未生命的依赖)并保证内部包之间的关系得到了正确的解析。
总结展望
Monorepo 底层主要依赖包管理器和任务工具。包管理器(如 pnpm)通过硬链接和内容寻址存储,解决了多个项目共享依赖时的空间浪费问题。同时,软链接确保了内部包之间的依赖能正确解析。任务工具(如 Nx)则利用依赖图和任务缓存,智能地只构建和测试受影响的代码,极大提升了构建效率。