回溯
开门见山,pnpm 从渐入视野到使用已经进入成熟期。本文不会探讨 pnpm 的使用和 monorepo 方案,如需从零开始了解,可参见:
随着 lerna 的维护速度逐渐放缓(几个月才 commit 一次),vite / vue3 仓库迁移至 pnpm ,使用 pnpm 管理 monorepo 已是目前的最佳解之一。
但是其中不免存在一些问题。
问题
pnpm 解的最优性
我们来探讨一个问题,正常情况下,使用 pnpm 去管理 workspace 是多见于基础组件库或者工具库,他们之间很少去使用第三方依赖或者自身就是依赖生产制造者,使用 pnpm 只是提供一个工作空间的 更快、更便捷 的解而已。
那放眼到我们业务中使用,我们会选择 workspace + monorepo 方案往往是希望同时管理很多项目,而很多项目间的依赖又极其错综复杂,比如一个项目依赖 webpack4 ,另一个依赖 webpack5 ,这在 lerna 这种隔离不严格的 hoist 工具内,经常会产生依赖版本冲突,造成多层依赖上游链路混用 “隐形依赖” 的版本问题,最终导致项目跑不起来或者运行失败,最典型的一个例子就是 cra 的项目无法在 lerna 内 packages 内创建。
所以 pnpm 给我们提供了这个解法,就是 严格的依赖隔离管理 ,由于每个依赖严格的限制了自己只能使用符合自己版本的依赖,避免了 隐形依赖 的问题,从而实现了极其复杂的依赖版本交错场景下的适配性。
隔离的副作用
有没有想过一种场景,@scope/components
子包作为组件库,里面使用了 antd@^4.16.0
作为基础,而 @scope/app
作为主应用,使用了 antd@^4.16.13
作为基础,那么他又要去复用 @scope/components
的组件,此时 antd
版本获取该怎么办?
在早期 lerna 系的完全 hoist 思维里,我们认为 antd
实际上只需要取一个即可,那就是只取我们主应用 @scope/app
的 antd
实例,因为我们会在 @scope/components
里将 antd
指定为开发依赖,不进入生产:
// packages/components/package.json
{
"name": "@scope/components",
"version": "1.0.0",
"main": "dist/index.js",
"devDependencies": {
"antd": ">=4.16.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"peerDependencies": {
"antd": ">=4.16.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}
以对外发包的思维继续思考,本地开发使用 antd 没毛病,那发布到 npm 去给其他人使用时,由于只会安装生产依赖,所以不会安装 antd ,再配合 peerDependencies
提示,即可完美达成我们的目的。
但在 pnpm 的 workspace 内,devDependencies
是实实在在安装到 packages/components/node_modules
的,不然你如何开发和编译?( 比如 tsx )
因为只要存在 node_modules
,就会识别到进入严格的隔离,导致你的主应用使用的是 packages/app/node_modules/antd
,而组件库使用的是 packages/components/node_modules/antd
,从而造成多实例,引发 message
不成队列、封装的 ConfigProvider
等 Context 上下文不在一个实例,打包体积倍增等致命问题。
这还了得!其实这就是 pnpm 的 peerDependencies
困境,那如何解决?
解决
在这个问题上我们的解只有一个方向,就是保证 唯一实例 。
alias 定位法
给主应用 @scope/app
的 webpack 配置加上 alias 强制定位 antd
、react
、react-dom
:
resolve: {
alias: {
'antd': path.resolve(__dirname, 'node_modules/antd'),
'react': path.resolve(__dirname, 'node_modules/react'),
'react-dom': path.resolve(__dirname, 'node_modules/react-dom'),
// ...如果你有用到 react-router-dom 还需定位 react-router-dom 和 react-router
},
},
如此一来即可保证单实例,顺利解决上面的问题。
全局提升法
使用这种方式需要对 pnpm hoist 有一定理解:
-
当不同子包都使用同样版本的某个依赖时,该依赖会被默认提升至顶层
node_modules
,子包内的该依赖会软链到顶层,全局唯一实例。 -
当子包间存在某依赖不同版本的使用时,会进行隔离处理放至
.pnpm
(一个 pnpm 存放依赖的 store),子包分别软链对应的版本实现隔离。
乍一看第一种情况几乎不可能,因为我们的项目繁多依赖版本号不可能可控。但是我们就是要手动提升他!
配置 pnpm 强制提升:
# .npmrc
# 强制提升所有 antd 到全局,保证唯一实例
public-hoist-pattern[]=antd
# 不配置这个选项时候的默认值,我们要手动把他加上
public-hoist-pattern[]=*types*
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=@prettier/plugin-*
public-hoist-pattern[]=*prettier-plugin-*
关于这个选项的说明可见:public-hoist-pattern
当手动配置时,其默认值需要我们主动给他加回去,其实这里默认提升了 eslint 系依赖到全局还是免去我们很多重复安装的功夫的,另外我们还可以提升 prettier
、lodash
等万年依赖。(还是不建议提升 react ,因为有可能版本不一致)
所以提升了 antd
,那么我们就可以使用魔法了:
// packages/components/package.json
{
"name": "@scope/components",
"version": "1.0.0",
"main": "dist/index.js",
"devDependencies": {
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"typescript": "^4.1.2"
},
"peerDependencies": {
"antd": ">=4.16.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}
此时由于存在 @types/*
包,react 可以顺利通过 typescript 编译,对外发布包时由于存在 peerDependencies
,也不影响完整性,在使用时,大家都会去找最上层的 node_modules/antd
,实现了全局单实例。
总结
综合以上解法来看,我们甚至可以同时使用这两种搭配,但实际上,我们无论怎么解决,当 @scope/components
的第三方依赖变得更多时,需要涉及一些生产包 dependencies
时,终究还是要安装到该组件库包内,而此时主应用又安装了相同的不同版本依赖,又会造成多实例重复打包问题(但没有 react 和 antd 这种致命),所以又要不断添加这些的 alias 或进行提升。
当然,我写个自动识别 @scope/app
的 package.json
的脚本,看看他依赖了哪些 @scope
开头的依赖,然后去找这些依赖的 package.json
里的 dependencies
再 alias 批量定位回来可以不?可以是可以,但很麻烦。
事物的两面性就是这样,pnpm 官方关于 peerDependencies
的 issue 探讨也是久而未决,因为社区有影响力使用 pnpm 的库都是上文中所描述的那两种基础库,即没什么第三方依赖的纯净库,由于不是业务库,也无需面临严重的版本冲突,自然这个问题对 pnpm 来说就不再那么重要了。
退一步来说,因为这个问题我们回退到 lerna + yarn worksapce 的时代?
答案是绝对的 No ,已经没有人可以忍受版本不透明,隔离出现太多版本冲突,cra 都无法创建项目的时代了,所以请拥抱变化寻找更优解吧!