Jin's blog

Monorepo + pnpm 如何锁定依赖版本

前端Monorepopnpm

又踩到 monorepo 的坑了,记录一下解决过程,如果你同样在使用 monorepo + pnpm workspace 有可能踩到相同的坑,本文帮你分析一下原因以及分享一下解决思路。

背景

项目技术栈是 monorepo + pnpm workspace,目录结构如下(简化版):

-- apps
  -- app1
     -> deps: 
        "zustand": "4.4.7"
        "kits": workspace:*
-- packages
  -- kits // 基础工具库
     -> deps:
        "immer": "9.0.19"
        "zustand": "4.4.7"

app1 和 kits 依赖了同一个版本的 zustand,且 app1 依赖了基础工具库 kits。因为 zustand 写死了版本,所以理论上 app1 和 kits 应该使用的是同一个 zustand 实例。

但是在最终安装后的 lock 文件中却不是这么显示的:

  /zustand/4.4.7_7u6mpky5dbb5b3hgdescs5ficq:
    resolution: {integrity: sha512-QFJWJMdlETcI69paJwhSMJz7PPWjVP8Sjhclxmxmxv/RYI7ZOvR5BHX+ktH0we9gTWQMxcne8q1OY8xxz604gw==}
    engines: {node: '>=12.7.0'}
    peerDependencies:
      '@types/react': '>=16.8'
      immer: '>=9.0'
      react: '>=16.8'
    peerDependenciesMeta:
      '@types/react':
        optional: true
      immer:
        optional: true
      react:
        optional: true
    dependencies:
      '@types/react': 17.0.2
      react: 17.0.2
      use-sync-external-store: 1.2.0_react@17.0.2
    dev: false

  /zustand/4.4.7_kxrt7warzaufc7baubsowugnri:
    resolution: {integrity: sha512-QFJWJMdlETcI69paJwhSMJz7PPWjVP8Sjhclxmxmxv/RYI7ZOvR5BHX+ktH0we9gTWQMxcne8q1OY8xxz604gw==}
    engines: {node: '>=12.7.0'}
    peerDependencies:
      '@types/react': '>=16.8'
      immer: '>=9.0'
      react: '>=16.8'
    peerDependenciesMeta:
      '@types/react':
        optional: true
      immer:
        optional: true
      react:
        optional: true
    dependencies:
      '@types/react': 17.0.2
      immer: 9.0.19
      react: 17.0.2
      use-sync-external-store: 1.2.0_react@17.0.2
    dev: false

最终在 lock 文件中显示的是两个不同的 zustand 版本,app1 和 kits 使用了不同 hash 值的 4.4.7 版本的 zustand。

这会导致什么问题?

  1. 单实例的问题,如果这个包类似 React 的库时,会导致产生两个不同的实例,最终导致项目挂掉
  2. app1 在打包的时候会把两个 zustand 都打包进去,导致打包体积增大

问题分析

为什么相同版本的依赖会有不同的 hash 值?

这就要聊到 pnpm 对 peerDeps 的处理方式。我们可以看一下这篇官方文章:Peers 是如何被处理的

我这里把重点部分摘抄一下:

- foo-parent-1
  - bar@1.0.0 
  - baz@1.0.0
  - foo@1.0.0  // peer 依赖 baz
- foo-parent-2
  - bar@1.0.0
  - baz@1.1.0
  - foo@1.0.0  // peer 依赖 baz

在上面的示例中, foo@1.0.0 已安装在 foo-parent-1 和 foo-parent-2 中。 这两个包都有依赖包 baz 和 bar, 但是它们却依赖着不同版本的 baz。 因此, foo@1.0.0 有两组不同的依赖项:一组具有 baz@1.0.0 ,另一组具有 baz@1.1.0。 若要支持这些用例,pnpm 必须有几组不同的依赖项,就去硬链接几次 foo@1.0.0

但是,如果 foo 有 peer 依赖(peer dependencies),那么它可能就会有多组依赖项,所以我们为不同的 peer 依赖项创建不同的解析

node_modules
└── .pnpm
    ├── foo@1.0.0_bar@1.0.0+baz@1.0.0
    │   └── node_modules
    │       ├── foo
    │       ├── bar   -> ../../bar@1.0.0/node_modules/bar
    │       ├── baz   -> ../../baz@1.0.0/node_modules/baz
    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux
    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh
    ├── foo@1.0.0_bar@1.0.0+baz@1.1.0
    │   └── node_modules
    │       ├── foo
    │       ├── bar   -> ../../bar@1.0.0/node_modules/bar
    │       ├── baz   -> ../../baz@1.1.0/node_modules/baz
    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux
    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh
    ├── bar@1.0.0
    ├── baz@1.0.0
    ├── baz@1.1.0
    ├── qux@1.0.0
    ├── plugh@1.0.0

重点就是上面这句话:所以我们为不同的 peer 依赖项创建不同的解析

因为 peerDeps 的原因,导致了不同的依赖会有不同的 hash 值。

而本文最上面的例子也是相同的原因:

-- apps
  -- app1
     -> deps: 
        "zustand": "4.4.7"
            -> peerDeps: 
                "immer": ">=9.0"
        "kits": workspace:*
-- packages
  -- kits // 基础工具库
     -> deps:
        "immer": "9.0.19"
        "zustand": "4.4.7"
            -> peerDeps: 
                "immer": ">=9.0"

因为 zustand 其实有个 peerDeps 依赖了 immer,在 kits 中的 deps 中因为手动安装了 immer 所以 pnpm 就对 kits 中的 zustand 创建了一个新的依赖 zustand@4.4.7_immer@9.0.19。而在 app1 中的 zustand 因为没有手动安装 immer,所以被解析为 zustand@4.4.7

这最终导致产生了两个不同的 zustand 实例,也就是本文最开始描述的背景问题。

解决方案

尝试 pnpm overrides

上来我就直接 overrides 索了一把,但是结论是一点用都没有。

overrides 只能把依赖的版本号锁定成指定版本,但是不能保证不同项目之间依赖的是同一个版本。

Webpack/Rspack 等,可以用 alias 解决

我这里用 Webpack 举例。 我们可以直接通过配置 Webpack alias 的方式让项目中的所有 zustand(or 其他依赖) 都指向同一个 zustand,这样就直接避免了上述问题。

但是这个方案有两个问题需要注意:

  • 需要手动维护 alias,当项目依赖的包越来越多的时候,这个 alias 也会越来越多。
  • 当我们安装的依赖中使用了更高/更低版本的依赖 API 时,由于 alias 的原因,导致无法访问到对应 API 或者运行结果不符合预期,最终导致项目挂掉。

终极方案 dedupe-peer-dependents

pnpm 在升级 8.x 之后,提供了一个新的配置项 dedupe-peer-dependents,这个配置可以解决上述问题。

方便懒地看文档的小伙伴,我这里也贴一下这个配置的能力:

  • 提高一致性和减少冗余:通过使用 dedupe-peer-dependents 配置项,pnpm 能够在确定 peer dpes 最佳版本时更加高效。这意味着如果多个包共享同一个 peer 依赖,pnpm 会尝试将它们合并为单个版本,从而减少冗余安装并提高整体依赖的一致性。

  • 更好地处理复杂的依赖树:在大型项目或 monorepos 中,依赖树可能变得非常复杂,尤其是当涉及到 peer 依赖时。dedupe-peer-dependents 配置项可以帮助 pnpm 在处理这些复杂情况时,更好地优化和简化依赖树。

  • 避免潜在的版本冲突:在不使用 dedupe-peer-dependents 的情况下,可能会出现同一 peer 依赖的不同版本共存的情况,这可能导致运行时错误或不兼容问题。启用此配置项有助于减少这类问题的发生。

总之,dedupe-peer-dependents 配置项是 pnpm 为了改进 peer 依赖处理和优化依赖安装效率而引入的一个功能。通过智能地合并和去重 peer 依赖,它可以在保持高效和节省空间的同时,减少依赖管理中的复杂性和潜在问题。