likes
comments
collection
share

monorepo项目的依赖管理你做对了吗?

作者站长头像
站长
· 阅读数 13

前言

根据 2021 年的 JS 现状调查,JavaScript 中的依赖管理是开发人员的第一大痛点,紧随其后的第二位是代码架构。

monorepo项目的依赖管理你做对了吗?

当开发者尝试有效的去管理 monorepo 项目中的依赖时,最终都会面临着两个问题。

monorepo 在某些场景下可以提供更好的 DX,并且在代码的共享复用、基础建设和代码规范的统一上具有优势,然而选择 monorepo 架构也是有代价的,随着项目规模的增长,可能会使开发和维护变得越来越困难。

本篇文章主要会介绍 monorepo 项目的痛点之一——依赖管理。

依赖管理的痛点

对于大部分 monorepo 项目来说,每个项目下都使用一个单独的 package.json 来维护依赖可能是唯一可能的依赖关系维护策略,每个项目负责定义自己在开发期间需要哪些依赖,每个项目的构建命令负责将依赖中的部分包捆绑到最终的产物中。

这种策略下有几个优势:

  • 独立性

    每个项目可以独立管理自己的依赖关系和版本,这意味着每个项目可以选择使用不同版本的依赖项,而不会影响其他项目。

  • 可维护性

    每个项目的 package.json 文件相对较小,更容易维护和管理。开发者可以更容易地理解和处理与项目相关的依赖项。

当然这种策略下也存在着一些棘手的问题,开发者一旦不注意就会导致整个项目报错。

幻影依赖

当开发者使用 npm 或者 Yarn Classic 来管理依赖时,最终会在项目根目录下的 node_modules 中安装所有的依赖,比如说有一个项目使用 lodash,另一个项目使用了 ramda,最终的目录结构如下:

workspace
├── node_modules
│   ├── lodash
│   └── ramda
├── projectA (dependencies: {lodash: '4.17.21'})
└── projectB (dependencies: {ramda: '0.28.0'})

尽管 projectA 的 package.json 中仅依赖了 lodash,但是 projectA 仍然可以访问 ramda,因此如果在 projectA 中导入 ramda 项目是能够正常运行的,但是这会带来三个问题,

  • 一旦 projectA 作为 npm 包发布并被安装,代码就会出错,因为 projectA 本身缺少 ramda 依赖。
  • 一旦 projectB 对 ramda 进行升级,那可能会导致 projectA 在运行时出错,因为 projectA 还是用的是 ramda 旧版本的 API。
  • 如果 ramda 是作为 projectB 的 devDependencies,可能本地开发的时候 projectA 能正常运行,但是一旦上到生产环境就会出错,因为 ramda 最终不会被打包到最终的产物中。

对于这种问题,在没有使用任何 monorepo 工具(Nx、bit)的情况下,推荐两种方法:

  • 使用 eslint 检查

    eslint 提供了一条 import/no-extraneous-dependencies 检查规则,启用后 eslint 将禁止开发者导入未在 package.json 中声明的外部模块。

    因此如果在 projectA 中导入 ramda,linter 检查就会失败,开发者必须手动安装 ramda 作为依赖项才能解决问题。

  • 使用 pnpm/Yarn berry

    解决这个问题的另一种方式是更改依赖的安装方式,pnpm 和 Yarn berry 都没有幻影依赖的问题,pnpm 使用软连接来优化依赖管理,使用 pnpm 后目录结构如下:

    workspace
    ├── node_modules
    │   └── .pnpm
    │       ├── lodash@4.17.21/node_modules/lodash
    │       └── ramda@0.28.0/node_modules/ramda
    ├── projectA
    │   └── node_modules
    │       └── lodash --> ../../node_modules/.pnpm/lodash@4.17.0/node_modules/lodash
    └── projectB
        └── node_modules
            └── ramda --> ../../node_modules/.pnpm/ramda@0.28.0/node_modules/ramda
    

    在这种情况下,projectA 无法访问ramda ,如果 projectA 尝试导入ramda,代码将在本地开发期间报错。

    对于 Yarn berry,可以使用它的 Plug'n'Play 功能,它会覆盖 Node 的解析算法,防止它解析幻像依赖项。

依赖版本管理

在 monorepo 项目中出现相同依赖多个版本的问题其根源在于每一个项目都单独维护自己的 package.json,如果每个项目都是由不同的团队进行协作开发维护,一旦没有一套统一的开发规范就很容易造成各项项目的依赖版本不一致

比如说存在以下依赖关系:

monorepo项目的依赖管理你做对了吗?

使用 npm/yarn 最终依赖安装可能存在两种结果:

workspace
├── node_modules
│   ├── lodash@^1
├── projectA
└── projectB
│    └── node_modules
│        └── lodash@^2
└── projectC
    └── node_modules
        └── lodash@^2

或者

workspace
├── node_modules
│   ├── lodash@^2
├── projectA
│    └── node_modules
│        └── lodash@^1
└── projectB
└── projectC

这取决于使用的依赖管理工具及其依赖处理模式,不管哪种情况都会出现两个问题:

  • 重复安装同一个依赖的同一个版本。
  • 安装同一个依赖的多个版本。

重复安装依赖会出现哪些问题?

对与重复安装依赖的问题可以从两个方向去分析:

  • 依赖本身不允许多个版本共存

    最直接的例子就是 react,react 在官网中强调最新的 react-hooks 特性就要求使用 hooks 时必须要在同一个 react 上下文

  • 依赖本身允许多个版本共存

    对于允许多个版本共存的依赖来说,重复安装会导致依赖冗余,这会占用额外的磁盘空间,并且在构建和部署过程中可能会增加时间和资源的消耗。

    同时多版本共存下代码的共享也是非常困难的,如果 projectA 和 projectB 使用两个不同版本的依赖,那他们的共享代码应该正对哪个版本的依赖进行编写?无论怎么回答,系统都会引入错误,并且这切错误通常发生在运行时并且很难定位。

因此在 monorepo 项目中统一依赖版本是非常有必要的。

如何避免重复安装依赖?

monorepo 项目中重复安装依赖的问题其根源在于每一个项目都单独维护自己的 package.json,因此不同的 monorepo 管理工具和库针对这个问题采用不同的方法来解决,下面是主要介绍 Nx、Bit 和 Syncpack 是提供的解决方案。

  • Nx

    Nx 被誉为下一代构建系统,它具有一流的 monorepo 支持和强大的生态系统,目前已有 19.4k star。

    Nx 管理依赖的策略是在根目录下的 package.json 文件去定义所有的依赖,从而强制所有的项目都使用依赖的同一版本,进而避免上面列出的问题。

    这里要注意的是,对于需要发布到 npm 中的项目,Nx 也提供了一套模式,详细介绍可查看这篇文档

    当然这种策略也有开发者对依赖的协调更新表示担忧,如果两个不同的团队在一个存储库下开发 React 应用,他们就需要就何时升级 React 达成一致,这是一个合理的担忧,但却不是 Nx 该考虑的。

    如果开发人员无法合作,那是不是可以将项目拆分到单独的仓库中去维护。另一方面,如果团队能够达成一致,那么同时升级整个存储库的工作量就会比在几个月或几年内多次执行相同的升级过程要少得多。

  • Bit

    bit 是一个用于前端开发可组合软件的工具链,它包含了依赖管理、代码生成等功能,·提供一种更具可扩展性、协作型和一致性的分布式开发形式,目前已经有 17k 的 star。

    bit 解决重复依赖的策略与 Nx 类似,使用 bit 创建的 monorepo 项目中没有 package.json 文件,开发者需要使用 bit install 去安装依赖,所有的依赖都由 bit 在它提供的 workspace.jsonc 文件中自动维护。

    比如下面这个例子:

    workspace
    ├── projectA (import lodash from 'lodash')
    └── projectB (import ramda from 'ramda')
    

    全局执行 bit install lodash ramda 后 bit 会自动安装依赖并在全局的 workspace.jsonc 文件中记录:

    "teambit.dependencies/dependency-resolver": {
        "packageManager": "teambit.dependencies/pnpm",
        "policy": {
          "dependencies": {
            "ramda": "0.28.0",
    				"lodash": "4.17.21"
          }
        }
      },
    

    当然 bit 并不是 npm 的替代品,它还远不如此,他的目标是减少开发者在依赖管理中的负担,开发者不需要关心哪个项目使用哪个依赖项,也不关心依赖项是开发依赖项还是运行时依赖项,这些都由 bit 自动化处理。pnpm 作者在看待在 monorepo 项目中使用 pnpm 来管理依赖的问题上也极力推荐使用 bit + pnpm(bit 在后台默认使用 pnpm) 。

  • Syncpack

    Syncpack 是一个用于处理 monorepo 中依赖项的工具,它旨在解决依赖项版本问题。它的策略不是减少 package.json 来达到统一管理的目的,而是是给开发者提供一个可以全局配置各个依赖版本的配置文件,在此配置文件中,可以指定哪些依赖项需要同步版本,以及如何同步它们的版本,目前已有 1k star。

    比如我们在根目录下创建一个 .syncpackrc.js 文件:

    const config = {
      versionGroups: [
        {
          dependencies: ['react'], // 指定需要同步的依赖
          packages: ['**'], // 所有的package都需要同步
          pinVersion: '18.0.2', // react版本需要统一为18.0.2
        },
      ],
    };
    
    module.exports = config;
    

    之后运行 npx syncpack fix-mismatches 这将自动遍历指定项目的 package.json 文件,检查哪些依赖项需要同步,并将它们的版本更新为配置文件中指定的版本。

总结

正因为 monorepo 是一个在未来可能会不断膨胀的蓝图,所以统一的建设及开发规范才格外的重要,开发者不能一味的去享受 monorepo 架构带来的便利而忽视了其存在的一些隐藏问题,其中依赖管理就是比较重的一环,错误的依赖管理方式可能会导致整个项目崩溃并难以定位问题。

目前社区各大 monorepo 管理工具针对依赖管理给出了相应的方案,开发者可以根据项目的情况选择合适的方案。

参考资料