Monorepo + pnpm 如何锁定依赖版本
又踩到 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。
这会导致什么问题?
- 单实例的问题,如果这个包类似 React 的库时,会导致产生两个不同的实例,最终导致项目挂掉
- 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,这样就直接避免了上述问题。
但是这个方案有两个问题需要注意:
- 需要手动维护 alias,当项目依赖的包越来越多的时候,这个 alias 也会越来越多。
- 当我们安装的依赖中使用了更高/更低版本的依赖 API 时,由于 alias 的原因,导致无法访问到对应 API 或者运行结果不符合预期,最终导致项目挂掉。
终极方案 dedupe-peer-dependents
pnpm 在升级 8.x 之后,提供了一个新的配置项 dedupe-peer-dependents (opens in a new tab),这个配置可以解决上述问题。
方便懒地看文档的小伙伴,我这里也贴一下这个配置的能力:
-
提高一致性和减少冗余:通过使用 dedupe-peer-dependents 配置项,pnpm 能够在确定 peer dpes 最佳版本时更加高效。这意味着如果多个包共享同一个 peer 依赖,pnpm 会尝试将它们合并为单个版本,从而减少冗余安装并提高整体依赖的一致性。
-
更好地处理复杂的依赖树:在大型项目或 monorepos 中,依赖树可能变得非常复杂,尤其是当涉及到 peer 依赖时。dedupe-peer-dependents 配置项可以帮助 pnpm 在处理这些复杂情况时,更好地优化和简化依赖树。
-
避免潜在的版本冲突:在不使用 dedupe-peer-dependents 的情况下,可能会出现同一 peer 依赖的不同版本共存的情况,这可能导致运行时错误或不兼容问题。启用此配置项有助于减少这类问题的发生。
总之,dedupe-peer-dependents 配置项是 pnpm 为了改进 peer 依赖处理和优化依赖安装效率而引入的一个功能。通过智能地合并和去重 peer 依赖,它可以在保持高效和节省空间的同时,减少依赖管理中的复杂性和潜在问题。