为什么PNPM能够实现Monorepo🙆🙆🙆

3,354 阅读18分钟

Monorepo 是一种项目代码管理方式,指单个仓库中管理多个项目,有助于简化代码共享、版本控制、构建和部署等方面的复杂性,并提供更好的可重用性和协作性。Monorepo 提倡了开放、透明、共享的组织文化,这种方法已经被很多大型公司广泛使用,如 Google、Facebook 和 Microsoft 等。

mono 来源于希腊语 μόνος 意味单个的,而 repo,显而易见地,是 repository 的缩写。将不同的项目的代码放在同一个代码仓库中,这种把鸡蛋放在同一个篮子里的做法可能乍看之下有些奇怪,但实际上,这种代码管理方式有很多好处。

在我们前端开发当中使用的 Vue 和 React 都是在 Monorepo 策略仓库中开发出来的。

Monorepo 的进化

从单仓库巨石应用(Monolith),到多仓库多模块应用(MultiRepo),最后转向单仓库多模块应用(MonoRepo)。每个阶段都有其优势和挑战,选择哪种方式取决于项目的具体需求和团队的工作流程。

  1. 单仓库巨石应用(Monolith):这种结构在项目初期比较常见,因为一切都在一个仓库中,所以便于管理和部署。但随着项目的增长,这种结构的缺点逐渐显现,包括但不限于构建时间的增长、代码冲突的频繁、以及难以维护。

  2. 多仓库多模块应用(MultiRepo):为了克服巨石应用的缺点,项目可能被拆分成多个较小的模块,每个模块使用单独的仓库管理。这样做可以提高模块的独立性,便于团队并行开发和维护,但也带来了新的挑战,比如跨仓库的依赖管理、版本同步问题以及工作流程的复杂性增加。

  3. 单仓库多模块应用(MonoRepo):为了解决多仓库管理带来的问题,有些团队和项目转向使用单仓库来管理多个模块。这种方式可以简化跨模块的依赖管理,提高代码共享的效率,并且可以统一构建和测试流程。不过,MonoRepo 也有其挑战,比如需要更精细的权限控制、大规模仓库的性能优化等。

每种方法都有其适用场景,没有绝对的好坏。例如,小到中型项目可能会更倾向于使用 Monolith 或 MultiRepo,而大型项目和大型团队可能会从 MonoRepo 中获益,尤其是当需要频繁地跨模块协作时。在选择最适合自己项目的策略时,需要权衡各种因素,包括团队规模、项目复杂度、构建和测试流程的需求等。

image.png

image.png 一个真正的 Monorepo 不仅仅是将多个项目的代码放在同一个代码库中。它还需要这些项目之间有明确定义的关系。如果这些项目之间没有良好定义的关系,那么就不能称之为 Monorepo。

类似地,如果一个代码库中包含了一个庞大的应用,而没有对其进行分割和封装,那么这只是一个大型的代码库,而不是真正的 Monorepo。即使你给它取一个花里胡哨的名字,也不能改变它的本质。

Monorepo 中的各个项目(或模块、组件)之间应该有清晰、明确的依赖关系和接口定义。这有助于确保模块之间可以高效协作,同时保持一定程度的独立性和可重用性。

Monorepo 优劣

image.png

场景 MultiRepo MonoRepo
代码可见性 ✅ 由于项目被分散在不同的仓库中,可以对每个仓库实施独立的访问控制,这有助于保护敏感代码,减少安全风险。
❌ 由于代码分散在多个仓库中,重用通用代码或库变得更加困难。开发人员可能需要复制代码到他们的仓库中,这会导致重复劳动和维护上的困难。
✅ 所有代码都在一个仓库中,使得代码的共享和重用变得非常方便。开发人员可以轻松访问和使用公共库和工具,促进了代码的一致性和效率。
❌ 虽然可以通过精细的权限控制限制对特定代码部分的访问,但在大型 MonoRepo 中管理这些权限可能会变得复杂和耗时。
依赖管理 ✅ 每个项目可以独立管理自己的依赖版本,这有助于避免因共享依赖导致的版本冲突问题。
❌ 多个项目可能会依赖同一库的不同版本,这可能导致重复的配置工作和维护成本。
❌当共享的库需要更新时,各个项目需要分别进行更新,这可能导致同步和一致性问题。
❌如果项目间存在依赖,管理这些依赖关系可能会变得复杂。
✅ 所有项目共享相同的依赖库版本,这简化了依赖管理,减少了版本冲突的可能性。
✅ 当共享库需要更新时,整个仓库中的所有项目可以同时更新,确保了依赖的一致性。
✅ 由于所有项目使用相同的依赖版本,当发现某个依赖的问题时,可以快速地识别出所有受影响的项目并进行修复。
❌所有项目必须使用相同版本的依赖,这可能限制了某些项目使用特定版本的能力,特别是当某些项目需要使用较新或较旧版本的依赖时。
开发迭代 ✅ 在多仓库模式下,每个仓库可以独立进行迭代,不受其他项目进度的影响。这意味着团队可以根据每个项目的需求和优先级安排迭代计划。
✅ 由于每个项目独立管理,团队可以为每个项目选择最适合的技术栈、工具和流程,提高了迭代过程的灵活性。
❌当需要在多个项目之间进行协作或共享代码时,跨仓库的协作可能会增加沟通和整合的成本。
❌在多仓库模式下,跨项目的依赖管理可能会变得复杂,需要额外的努力来确保依赖项的一致性和兼容性,这可能会拖慢迭代速度。
✅ 所有项目和模块共享同一个仓库,使得团队可以采用统一的工作流程、构建和测试工具,简化了迭代过程。
✅ 当共享库需要更新时,整个仓库中的所有项目可以同时更新,确保了依赖的一致性。
❌ 在 MonoRepo 中,所有项目共享同一个版本历史,这可能会导致版本控制日志变得杂乱无章,使得追踪特定项目的更改变得更加困难。
❌对于非常大的仓库,构建和测试的速度可能会成为问题,尤其是当不需要构建整个仓库的所有部分时。虽然有策略如增量构建和缓存可以缓解这个问题,但需要额外的配置和维护工作。
工程配置 ✅ 每个仓库可以有其独立的构建、测试和部署配置,这允许项目根据自己的特定需求定制化工程配置,提供了高度的灵活性。
✅ 相对于 MonoRepo,单个项目的配置通常更简单、更直接,因为它只需要关注自身的需求,而不是必须考虑到与其他项目的协作和兼容性。
❌随着仓库数量的增加,重复的配置和工具链设置可能导致维护成本增加。每个项目可能需要单独维护构建脚本、依赖管理文件、CI/CD 配置等
❌在多仓库环境中,不同项目之间的配置可能会出现不一致,导致构建、测试和部署流程的差异,增加了团队成员之间协作的复杂性。
✅ 所有项目共享同一个构建系统和工具链,这有助于确保整个代码库的一致性和可维护性,简化了工程配置的管理。
✅ 具和依赖库的版本可以在整个仓库中统一管理,减少了版本冲突的可能性,并确保所有项目都使用了正确的工具和库版本。
❌ 由于所有项目使用相同的依赖版本,当发现某个依赖的问题时,可以快速地识别出所有受影响的项目并进行修复。
随着项目数量和类型的增加,MonoRepo 的配置可能变得复杂,需要更复杂的工具和脚本来支持不同类型的项目和构建流程。
❌ 对于大型 MonoRepo,构建和测试整个仓库可能非常耗时,尽管可以通过各种优化技术(如增量构建和缓存)来缓解这一问题。
构建部署 ✅ 每个仓库可以独立构建和部署,这允许项目团队按照自己的时间表和需求来更新服务,提高了部署的灵活性。
✅ 项目之间的隔离性减少了构建和部署过程中的相互影响,一个项目的更改不会直接影响到其他项目的构建或稳定性。
❌在多仓库结构中,相似的构建和部署流程可能需要在多个项目中重复配置,导致维护成本和工作量增加。
❌当项目之间存在依赖关系时,协调和同步不同仓库的构建和部署变得更加复杂,尤其是在进行大规模更新时。
✅ 所有项目共享同一个构建系统,这有助于简化和标准化构建流程,提高效率。
✅ 在 MonoRepo 中,涉及多个项目的更改可以在一个提交中完成,这简化了回滚和跟踪更改的过程,提高了部署的可靠性。
✅ 由于所有代码都在同一个仓库中,管理和升级跨项目依赖变得更加容易,有助于确保依赖的一致性。
❌ 对于大型 MonoRepo,即使只更改了仓库中的一小部分,也可能需要重新构建整个仓库,导致构建时间显著增加。虽然可以通过增量构建和其他优化措施来缓解,但这需要额外的配置和资源。
随着项目数量和类型的增加,MonoRepo 的配置可能变得复杂,需要更复杂的工具和脚本来支持不同类型的项目和构建流程。
❌ MonoRepo 可能限制了部署粒度,因为所有项目共享相同的构建和部署流程。这可能导致即使只需部署一个小改动,也可能需要重新构建和部署整个代码库中的多个项目。

Monorepo 使用场景

Monorepo(单仓库)模式适用于多种场景,特别是在以下情况下,使用 Monorepo 可以带来显著的好处:

  1. 大型团队协作: 当大型团队在多个相关项目上协作时,Monorepo 可以简化协作流程。由于所有项目都位于同一仓库中,团队成员可以轻松访问和修改跨项目的代码,促进了团队间的沟通和合作。

  2. 微服务架构: 在微服务架构中,系统由多个小型、独立服务组成。使用 Monorepo 可以方便地管理这些服务的代码,确保服务之间的兼容性,并简化跨服务的重构和共享代码。

  3. 多平台/多产品开发 对于跨多个平台(如 Web、iOS、Android)或多个产品线开发的公司,Monorepo 可以提供一个统一的代码基础,使得共享通用库、组件和工具变得简单,同时保持构建和发布流程的一致性。

  4. 共享库和组件 在开发涉及多个共享库或可重用组件的项目时,Monorepo 允许开发人员轻松更新和维护这些共享资源。这有助于提高代码重用率,降低维护成本。

  5. 统一的工具和流程: 对于希望统一代码风格、构建工具、测试框架和部署流程的团队,Monorepo 提供了一个共同的基础设施,有助于标准化开发实践,简化新成员的入职过程。

  6. 原子性更改和重构: 当需要对跨多个项目或模块的代码进行重构或更新时,Monorepo 使得这些更改可以作为一个原子提交进行,降低了部署和回滚的复杂性。

统一配置:合并同类项 - Eslint,Typescript 与 Babel

在 Monorepo 项目中统一配置 ESLint、TypeScript 和 Babel 可以帮助保持代码的一致性,简化项目维护,并提高开发效率。

typescript

我们可以在 packages 目录中放置 tsconfig.settting.json 文件,并在文件中定义通用的 ts 配置,然后,在每个子项目中,我们可以通过 extends 属性,引入通用配置,并设置 compilerOptions.composite 的值为 true,理想情况下,子项目中的 tsconfig 文件应该仅包含下述内容:

{
  "extends": "../../tsconfig.setting.json", // 继承 packages 目录下通用配置
  "compilerOptions": {
    "composite": true, // 用于帮助 TypeScript 快速确定引用工程的输出文件位置
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

eslint

对于 eslint,我们可以使用相同的思想来实现这一规则,在包的 .eslintrc.js 文件中,使用 extends 字段来继承顶层配置,并添加或覆盖规则。

module.exports = {
  extends: "../../.eslintrc.js",
  rules: {
    // 重写或添加规则
  },
};

babel

Babel 配置文件合并的方式与 TypeScript 如出一辙,甚至更加简单,我们只需在子项目中的 .babelrc 文件中这样声明即可:

{
  "extends": "../../.babelrc"
}

当所有准备完毕的时候,我们项目目录应该大致呈如下所示的结构:

├── package.json
├── .babelrc
├── .eslintrc
├── tsconfig.setting.json
└── packages/
    │   ├── tsconfig.settings.json
    │   ├── .babelrc
    ├── @mono/project_1/
    │   ├── index.js
    │   ├── .eslintrc
    │   ├── .babelrc
    │   ├── tsconfig.json
    │   └── package.json
    └───@mono/project_2/
        ├── index.js
        ├── .eslintrc
        ├── .babelrc
        ├── tsconfig.json
        └── package.json

为什么 pnpm 能实现 Monorepo

首先我们来讲一下 pnpm 的核心亮点吧,就是它的软链接和硬链接吧,pnpm 使用一种称为内容寻址存储的方法来保存依赖项。在这种机制下,依赖项的存储位置基于其内容的哈希值,这意味着:

  1. 如果多个项目依赖相同版本的包,这个包在全局存储中只有一份副本,各个项目通过硬链接指向这个副本,从而显著减少了磁盘空间的占用。

  2. 内容寻址机制确保了依赖项的完整性,因为任何对文件内容的更改都会导致哈希值的变化,从而防止了依赖污染和意外更改。

其中一个受大家比较欢迎的就是我们打开 pnpm 官网就能直接看到的内容,那就是安装快:

20240216203746

pnpm 在安装依赖包时,主要经历了以下三个步骤:解析依赖、获取依赖以及链接依赖。这个过程通过优化来确保高效的依赖管理,尤其在处理大型项目或 Monorepo 时。

  1. 解析依赖(Dependency Resolution) 在这个阶段,pnpm 需要确定要安装的每个依赖包的具体版本。它会查看项目的 package.json 文件以及任何现有的锁文件(如 pnpm-lock.yaml),来决定哪些版本的包需要被安装。解析依赖时,pnpm 会遵循以下规则:

    • 版本兼容性:基于 package.json 中指定的版本范围,选择与之兼容的最新版本。
    • 锁文件:如果存在锁文件,pnpm 会优先使用锁文件中锁定的版本,以确保依赖的一致性和项目的可重现性。
  2. 获取依赖(Fetching Dependencies) 一旦确定了需要安装的依赖版本,pnpm 将开始获取这些依赖包。这个过程包括以下几个步骤:

    • 检查全局存储:pnpm 首先会检查其全局存储中是否已经存在所需版本的依赖包。如果已经存在,就不需要从远程仓库下载,直接重用即可。
    • 下载缺失的依赖:对于全局存储中不存在的依赖,pnpm 会从 npm 或其他配置的仓库下载它们。下载的依赖包会被存储在全局存储中,以便将来重用。
    • 内容寻址存储:pnpm 使用内容寻址方式来存储依赖包,即根据包内容的哈希值来确定存储路径。这确保了相同内容的包在全局存储中只有一份副本,节省了磁盘空间。
  3. 链接依赖(Linking Dependencies) 获取依赖包之后,pnpm 需要将这些依赖链接到项目的 node_modules 目录中,使得项目能够使用这些依赖。这个步骤涉及:

    • 创建硬链接和符号链接:对于每个依赖包,pnpm 会在项目的 node_modules 目录中创建指向全局存储中相应包的硬链接。如果是包内部的依赖,还可能创建符号链接来保持正确的依赖结构。
    • pnpm 通过构建一个虚拟的 node_modules 目录来模拟传统的嵌套依赖结构,但实际上依赖之间是通过符号链接相连的。这样做既保持了 npm 生态的兼容性,又避免了重复的依赖副本和深层嵌套的问题。
    • 通过这种链接方式,pnpm 确保了项目只能访问其直接依赖的包,防止了对未声明依赖的意外访问,提高了项目的稳定性和安全性。

通过上述三个步骤,pnpm 实现了对依赖的高效管理,优化了存储空间的使用,加快了依赖安装的速度,同时还保证了项目依赖的一致性和隔离性。

pnpm 在安装依赖时能够并行执行多个任务,比如解析依赖、下载和链接依赖。这种并行处理机制充分利用了现代多核 CPU 的性能,显著减少了安装过程的总时间。

pnpm 安装速度快除了上面提到的这些原因之外,它的另一个优点是它支持增量更新。当你添加或更新项目依赖时,pnpm 只会下载那些实际改变了的包。如果某个包的版本已经存在于全局存储中,pnpm 将重用这个版本,避免了不必要的下载,从而加快了安装过程。

在 Monorepo 中,包之间经常相互依赖。pnpm 通过 Workspace 协议支持这种内部依赖,允许包在其 package.json 中直接引用 Monorepo 中的其他包,如:

"dependencies": {
  "foo": "workspace:^1.0.0"
}

这种方式使得在本地开发时,包之间可以轻松地相互依赖,而不需要发布到 npm 上。pnpm 会自动处理这些内部依赖,并确保正确的链接和版本匹配。

在 workspace 模式下,项目根目录通常不会作为一个子模块或者 npm 包,而是主要作为一个管理中枢,执行一些全局操作,安装一些共有的依赖,每个子模块都能访问根目录的依赖,适合把 TypeScript、eslint 等公共开发依赖装在这里,下面简单介绍一些常用的中枢管理操作。

在项目跟目录下运行 pnpm install,pnpm 会根据当前目录 package.json 中的依赖声明安装全部依赖,在 workspace 模式下会一并处理所有子模块的依赖安装。

安装项目公共开发依赖,声明在根目录的 package.json - devDependencies 中。-w 选项代表在 monorepo 模式下的根目录进行操作。

// 安装
pnpm install -wD xxx
// 卸载
pnpm uninstall -w xxx

执行根目录的 package.json 中的脚本

pnpm run xxx

在 workspace 模式下,pnpm 主要通过 --filter 选项过滤子模块,实现对各个工作空间进行精细化操作的目的。

例如 a 包安装 lodash 外部依赖,-S 和 -D 选项分别可以将依赖安装为正式依赖(dependencies)或者开发依赖(devDependencies):

// 为 a 包安装 lodash
pnpm --filter a add -S lodash // 生产依赖
pnpm --filter a add -D lodash // 开发依赖

指定模块之间的互相依赖。下面的例子演示了为 a 包安装内部依赖 b。

// 指定 a 模块依赖于 b 模块
pnpm --filter a i -S b

pnpm workspace 对内部依赖关系的表示不同于外部,它自己约定了一套 Workspace 协议。下面给出一个内部模块 a 依赖同是内部模块 b 的例子。

{
  "name": "a",
  // ...
  "dependencies": {
    "b": "workspace:^"
  }
}

在实际发布 npm 包时,workspace:^ 会被替换成内部模块 b 的对应版本号(对应 package.json 中的 version 字段)。替换规律如下所示:

{
  "dependencies": {
    "a": "workspace:*", // 固定版本依赖,被转换成 x.x.x
    "b": "workspace:~", // minor 版本依赖,将被转换成 ~x.x.x
    "c": "workspace:^" // major 版本依赖,将被转换成 ^x.x.x
  }
}

参考文献

总结

通过本文我们学习到了 Monorepo 是什么,以及 Monorepo 的演变,进而学习到了为什么 pnpm 能够实现 Monorepo,在后面的内容中会继续分享构建型的 Monorepo 方案。

最后分享两个我的两个开源项目,它们分别是:

这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🥰🥰🥰