引子

你现在有一个包含许多软件包的单体仓库。你的公用组件包components引入了一个第三方组件包(假设是antd),你的两个客户端包app-1app-2也引入了antd,当然也引入了公用组件包components。你在app-2中使用了第三方组件包中的一个Context的Provider(假设你在做一个多端的React应用),你把代码在components包下的这个Context的Consumer嵌套在这个Provider的下边。正常来讲,这个Consumer应当能够读到Provider提供的Context值。bug出现了,这个Provider并没能读到Context的值,就好像它的父级根本没有Provider一样。然而同样的代码在app-1中就跑的通,这是为什么呢?

今天这篇文章将会讲述一件由pnpm的依赖解析机制引发的问题。pnpm解决了安装npm包的许多痛点,然而其一些安装策略有可能会导致一些奇怪的行为,并且一时间很难发现。如果你还不知道pnpm是啥,这里有一份bing提供的概述(come on现在可是2023年):

pnpm是一款快速、节省磁盘空间的软件包管理器。它使用一个内容可寻址的文件系统来存储所有模块目录中的所有文件。这样,当你有多个项目使用同一个软件包时,你只需要在磁盘上存储一份该软件包的副本。pnpm还支持单体仓库(monorepo),并且创建的node_modules默认并非扁平结构,因此代码无法对任意软件包进行访问。

bing

上文提到的问题其实原因很简单:app-2components引入了不同的Context。你的源码里,这个Context是从同一个第三方包导出,但是bundler眼里不一定是。bundler在引入包时会考虑版本问题,这是一种兼容策略,但是同时存在不同版本的包对打包并不友好,有时还会引发不正常的行为,就如本文所说的这个问题。由于这些包里的变量是在不同的作用域中创造的,它们互相无法匹配(最近站里还会上一篇Firefox的ArrayBuffer !== ArrayBuffer的奇怪问题大概也是这种感觉),这就会引起诸如React的Context的机制失效。

幸好,在Chrome Dev Tools中很容易发现此类问题。如果你开了source map,你可以在Souce选项卡中找到node_modules文件夹,在下面检查是否有同名的文件夹。当出现这种情况是,往往就是有同个依赖的不同版本了。

因为版本标识符不同而导致的依赖冲突算是很常见的了,社区中已经有了很多解决方法,bing提供了一个叫syncpack的npm包可以解决这个问题,pnpm中也可以通过在package.json中定义overrides字段强行统一项目中某个包的版本解析来解决问题。

问题

然而本文要讲的问题中,这个第三方依赖包在我们的三个包中的版本描述符都是一致的。但是项目里却存在两个安装副本,一个与app-2连接,一个与app-1components连接,这就解释了为什么同样的代码在app-1中就能正常工作,但是在app-2中就不行。这就很奇怪了。

依赖问题先看lock。打开pnpm-lock.yaml后(下方只是一个简化版本的),可以看到安装了两个版本的antd:

apps/app-1:
    specifiers:
      'antd': ^2 
      '@ant-design/icons': 4.x
    dependencies:
      '@formily/antd': 2.0.0_hcylku7hkvnsrl6yomvjip2pge
      '@ant-design/icons': 4.8.0_biqbaboplfbrettd7655fr4n2y

features/components:
    specifiers:
      'antd': ^2
    dependencies:
      '@formily/antd': 2.0.0_hcylku7hkvnsrl6yomvjip2pge
apps/app-2:
    specifiers:
      'antd': ^2
    dependencies:
      '@formily/antd': 2.0.0_n2devs7wrktscgtcsysfvlhuey

/antd/2.0.0_hcylku7hkvnsrl6yomvjip2pge:
    resolution: {integrity: sha512-oiOSGBuWzjbk2A5PeDNnobTbHWxnhsdIKz4tvD0OcZl4p0zjRIosI3dLVUsUO1381lcBPugnRYFq2iFd+8P2iQ==}
    peerDependencies:
      '@ant-design/icons': 4.x

/antd/2.0.0_n2devs7wrktscgtcsysfvlhuey:
    resolution: {integrity: sha512-oiOSGBuWzjbk2A5PeDNnobTbHWxnhsdIKz4tvD0OcZl4p0zjRIosI3dLVUsUO1381lcBPugnRYFq2iFd+8P2iQ==}

在用diff工具对比了app-2、app-1、componentspackage.json后才发现,唯独app-2没有安装我们的第三方依赖的一个peerDependency,因此app-2的antd的后缀与app-1、components都不同。

这时才弄清楚,pnpm在安装依赖时,除了该软件包的版本,还会考虑它的peerDependencies。如果漏装,那么后缀名就会不一样。pnpm中这个后缀有时会是相关的peerDependency(如0.29.2_react@18.2.0),有时会是上述这种难懂的形式(并且删除lock重装后会重新生成)这个奇怪的字符串让我排了好久的bug。

解决

在给app-2补充了缺少的peerDependency后,本文的问题成功解决,可喜可贺。

不过好消息是,6.0版本的lock文件(pnpm 8中会生成这个版本的lock)似乎有一种完全不同的格式:7.3.11(react-dom@18.2.0)(react@18.2.0)。相信在这种格式下peerDependency引发的多个包副本的问题更容易得到解决。