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

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 中获益,尤其是当需要频繁地跨模块协作时。在选择最适合自己项目的策略时,需要权衡各种因素,包括团队规模、项目复杂度、构建和测试流程的需求等。

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

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

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

Monorepo 优劣

场景 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 文件应该仅包含下述内容:

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

eslint

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

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

babel

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

json 复制代码
{
  "extends": "../../.babelrc"
}

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

java 复制代码
├── 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 官网就能直接看到的内容,那就是安装快:

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 中的其他包,如:

json 复制代码
"dependencies": {
  "foo": "workspace:^1.0.0"
}

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

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

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

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

bash 复制代码
// 安装
pnpm install -wD xxx
// 卸载
pnpm uninstall -w xxx

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

bash 复制代码
pnpm run xxx

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

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

bash 复制代码
// 为 a 包安装 lodash
pnpm --filter a add -S lodash // 生产依赖
pnpm --filter a add -D lodash // 开发依赖

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

bash 复制代码
// 指定 a 模块依赖于 b 模块
pnpm --filter a i -S b

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

json 复制代码
{
  "name": "a",
  // ...
  "dependencies": {
    "b": "workspace:^"
  }
}

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

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

参考文献

总结

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

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

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

相关推荐
索然无味io34 分钟前
XML外部实体注入--漏洞利用
xml·前端·笔记·学习·web安全·网络安全·php
m0_748232921 小时前
ERROR:This version of pnpm requires at least Node.js vXXX 的解决方案
node.js
ThomasChan1231 小时前
Typescript 多个泛型参数详细解读
前端·javascript·vue.js·typescript·vue·reactjs·js
爱学习的狮王1 小时前
ubuntu18.04安装nvm管理本机node和npm
前端·npm·node.js·nvm
东锋1.31 小时前
使用 F12 查看 Network 及数据格式
前端
zhanggongzichu1 小时前
npm常用命令
前端·npm·node.js
anyup_前端梦工厂1 小时前
从浏览器层面看前端性能:了解 Chrome 组件、多进程与多线程
前端·chrome
chengpei1471 小时前
chrome游览器JSON Formatter插件无效问题排查,FastJsonHttpMessageConverter导致Content-Type返回不正确
java·前端·chrome·spring boot·json
我命由我123452 小时前
NPM 与 Node.js 版本兼容问题:npm warn cli npm does not support Node.js
前端·javascript·前端框架·npm·node.js·html5·js
每一天,每一步2 小时前
react antd点击table单元格文字下载指定的excel路径
前端·react.js·excel