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 是如何被处理的 (opens in a new tab)

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

- 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 (opens in a new tab) 的方式让项目中的所有 zustand(or 其他依赖) 都指向同一个 zustand,这样就直接避免了上述问题。

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

终极方案 dedupe-peer-dependents

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

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

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