pnpm在monorepo的管理之道

pnpm在monorepo项目改造的落地

Posted by lijiahao on June 13, 2022

前段时间对现有项目进行了改造,由原来的单仓库单包管理转换为单仓库多包的monorepo项目管理方式,采用pnpm对项目模块进行管理,本文对在项目改造过程中遇到的种种问题进行记录和分享。

项目背景

现有项目是一个提供给业务前端开发的cli工具,旨在将代码中的开发配置全部进行解耦,与常见的vue-cli不一样的是,这个cli安装在全局而不是分别安装在项目中,开发者只需全局安装一次,就可以在任意位置启动业务项目,每个业务项目只保留src和必要的env配置环境,项目结构看起来更简洁。

项目基于webpack5,配合常见的babel转码,postcss处理,eslint代码检查等,现需要对此cli进行升级,在原来只支持vue2+webpack的基础上支持vite及vue3,如果直接在现在项目中添加vue3的vue-sfc作为依赖,那必将会和vue2的vue-template-compiler版本检查冲突,而且后续的功能迭代也是已模块迭代为主,因此对项目进行monorepo改造势在必行。

npm管理的弊端

在介绍pnpm之前,我们先来了解下npm的使用痛点。

1.Phantom dependencies

Phantom dependencies又称幽灵依赖幻影依赖。在npm@3之前,项目的依赖都有自己的node_modules文件夹,在package.json中指定了所有依赖项,项目的node_modules结构是干净可预测的,以下面一个具体的项目例子来看:

// my-library/package.json
{
  "name": "my-library",
  "version": "1.0.0",
  "main": "lib/index.js",
  "dependencies": {
    "minimatch": "^3.0.4"
  },
  "devDependencies": {
    "rimraf": "^2.6.2"
  }
}

在npm@3之前执行npm install,得到的node_modules结构如下:

node_modules
└─ minimatch
   ├─ minimatch.js
   ├─ package.json
   └─ node_modules
      └─ brace-expansion // minimatch的package.json依赖了brace-expansion
         ├─ index.js
         ├─ package.json
         └─ node_modules
            └─ balanced-match // brace-expansion的package.json依赖了balanced-match
                ├─ index.js
                ├─ package.json
                └─ node_modules // 如果还有更深的依赖关系则会创建更深的目录结构
                    ├─ ...

这样的目录结构优点是依赖关系一目了然,但缺点也很明显:

  1. node_modules目录树结构会很深,在windows上很容易出现文件路径过长的问题
  2. 每个依赖都有自己依赖,因此也会有很多重复的依赖

这个情况从npm@3发生了变化,将node_modules目录扁平化,生成的node_modules(经过简化)如下:

├── node_modules
│   ├── minimatch
│   │   ├── lib
│   │   │   └── path.js
│   │   ├── minimatch.js
│   │   └── package.json
│   ├── balanced-match
│   │   ├── index.js
│   │   └── package.json
│   ├── brace-expansion
│   │   ├── index.js
│   │   └── package.json
│   ├── concat-map
│   │   ├── example
│   │   │   └── map.js
│   │   ├── index.js
│   │   ├── package.json
│   │   └── test
│   │       └── map.js
....

这样解决了npm@3之前的嵌套路径过长的问题,同时模块也可以得到最大程度的复用,但随时引入了新的问题,我们先来看这个项目中一个有效的代码:

var minimatch = require('minimatch');
var expand = require('brace-expansion'); // ???
var glob = require('glob'); // ???

// (使用这些库的代码)

以上代码是可以正常运行有效的,有没有看出来问题?————有两个库 brace-expansionglob 两个库并没在 package.json 文件中声明为依赖。那它们是如何运行的呢?答案是 brace-expansionminimatch 的依赖,globrimraf 的依赖。安装时,NPM 会将 my-library/node_modules 下的文件夹铺平,由于 NodeJS 的 require() 函数不需要考虑 package.json 文件,所以它找到这些库。

我们就把项目中使用到package.json中没有声明的模块称之为Phantom dependencies(幻影依赖)。幻影依赖会带来以下问题:

  1. 出现不兼容版本导致运行失败。比如上面的项目例子,我们只是在项目里声明了minimatch的版本为3,但没有声明brace-expansion的版本,一旦brace-expansion在随后的更新中出现重大的API更新,使用了幽灵依赖的项目运行就可能会出现问题,这也是实际遇到的一些陈年老项目无法运行的重要原因之一。

  2. 依赖缺失。还是上面的项目为例,库 glob 来自于 devDependencies 中,这意味着只有开发 my-library 的开发者才会安装这些库。对于其他人,require("glob") 将会因 glob 未安装而立即抛错。只要我们发布了 my-library, 就会立即听到这个反馈,对吧?其实并不是,实际情况中,由于某些原因(例如自身使用了 rimraf),绝大部分用户都有 glob 这个库,所以看起来可以运行。只有一小部分用户会遇到导入失败的问题,这使得它看起来像是一个难以重现的问题。

幻影依赖在node模块中十分常见,在我实际项目中,幻影依赖问题最严重的就是webpack及其附属开发插件,例如进行配置合并的webpack-merge和打包优化插件terser-webpack-plugin就是webpack官方直接推荐开箱即用无需额外安装的依赖,在实际代码中一旦使用该模块而package.json中没有声明,就是妥妥的幻影依赖了。

2.NPM doppelgangers

NPM doppelgangers翻译过来就是NPM分身,也可以称为依赖分身。npm@3之前的深层次node_modules导致依赖无法重用就是依赖分身问题,到了npm@3后号称使用偏平依赖结构解决依赖复用问题,但同一个包不同版本重复安装的问题依旧存在。先来看一个例子:

先有以下library-a项目,项目的package.json下声明了4个依赖:

{
  "name": "library-a",
  "version": "1.0.0",
  "dependencies": {
    "library-b": "^1.0.0",
    "library-c": "^1.0.0",
    "library-d": "^1.0.0",
    "library-e": "^1.0.0"
  }
}

每个依赖的子依赖如下:

B 和 C 都依赖于 F@1:

{
  "name": "library-b",
  "version": "1.0.0",
  "dependencies": {
    "library-f": "^1.0.0"
  }
}
{
  "name": "library-c",
  "version": "1.0.0",
  "dependencies": {
    "library-f": "^1.0.0"
  }
}

D 和 E 都依赖 F@2:

{
  "name": "library-d",
  "version": "1.0.0",
  "dependencies": {
    "library-f": "^2.0.0"
  }
}
{
  "name": "library-e",
  "version": "1.0.0",
  "dependencies": {
    "library-f": "^2.0.0"
  }
}

在看下面的解释之前,大家可以先脑补下子依赖的公共模块F存在两个不同版本,最后项目根目录的node_modules会如何处理这个F模块?

事实上,在npm@3之后,F会有一个版本会提升到扁平目录,也就是项目node_modules根目录进行模块共享,另一个版本会被安装在对应的依赖node_modules下,例如node_modules 树可以把 F@1 放在树的顶部来实现共享,但是需要把 F@2 拷贝到子目录中:

- library-a/
  - package.json
  - node_modules/
    - library-b/
      - package.json
    - library-c/
      - package.json
    - library-d/
      - package.json
      - node_modules/
        - library-f/
          - package.json  <-- library-f@2.0.0 独立模块
    - library-e/
      - package.json
      - node_modules/
        - library-f/
          - package.json  <-- library-f@2.0.0 独立模块
    - library-f/
      - package.json  <-- library-f@1.0.0 共享模块

当然也有另一种处理方式,也就是F@1和F@2互换,F@2共享,F@1在子目录中:

- library-a/
  - package.json
  - node_modules/
    - library-b/
      - package.json
      - node_modules/
        - library-f/
          - package.json  <-- library-f@1.0.0 独立模块
    - library-c/
      - package.json
      - node_modules/
        - library-f/
          - package.json  <-- library-f@1.0.0 独立模块
    - library-d/
      - package.json
    - library-e/
      - package.json
    - library-f/
      - package.json  <-- library-f@2.0.0 共享模块

至于用哪个版本的F模块作为共享模块,取决于哪个版本模块先安装,不管是共享哪个模块,最终只有一个模块被提升,其余模块还是会像npm@2时代一样在子目录node_modules中处理

这里再回头看具体的项目例子,也就是幻影依赖中的library项目:

// my-library/package.json
{
  "name": "my-library",
  "version": "1.0.0",
  "main": "lib/index.js",
  "dependencies": {
    "minimatch": "^3.0.4"
  },
  "devDependencies": {
    "rimraf": "^2.6.2"
  }
}

最后的依赖分身情况如下:

node_modules
│   ├── brace-expansion
│   │   ├── index.js
│   │   └── package.json
|   ├── minimatch
│   │   ├── lib
│   │   │   └── path.js
│   │   ├── minimatch.js
│   │   └── package.json
│   ├── glob
│   │   ├── common.js
│   │   ├── glob.js
│   │   ├── node_modules
│   │   │   ├── brace-expansion  -> 版本为1.1.11
│   │   │   │   ├── index.js
│   │   │   │   └── package.json
│   │   │   └── minimatch
│   │   │       ├── minimatch.js -> 版本为3.1.2
│   │   │       └── package.json
│   │   ├── package.json
│   │   └── sync.js

因为glob的package.json中声明的brace-expansionminimatch版本比项目的版本低,因此在glob中单独设置了一个node_modules特殊处理这两个模块

3.node_modules文件夹删除时间过长

从上面幻影依赖和依赖分身中可以看到,不管是npm哪种版本,最后的node_modules目录结构层次都会特别深,而且一个依赖会裂变为N个依赖,最后项目根目录下的node_modules小文件也会特别多,因此在删除node_modules时操作系统会预先进行文件夹删除检索,删除时间也会特别长。

pnpm包管理原理

现在来正式介绍pnpm,pnpm通过将模块真实源文件在pnpm store的公共区域,在项目模块进行依赖安装时,通过硬链接和软链接的方式将模块引入到项目的node_modules,从而解决了依赖重复安装的问题,下面将对pnpm的特性一一进行介绍。

1.全局store实现内容地址存储

pnpm将依赖安装在store-dir,可以通过pnpm store path查看store路径:

$ pnpm store path

> /Users/lijiahao/Library/pnpm/store/v3

Mac/linux中默认会设置到{home dir}>/.pnpm-store/v3;windows下会设置到当前盘的根目录下,比如C(C/.pnpm-store/v3)、D盘(D/.pnpm-store/v3)。

home dir实际是当前操作系统的环境变量根目录,也就是~所在的路径,可以通过以下命令查看验证:

$ cd ~
$ pwd

> /Users/lijiahao

值得一提的是,macOS通常不进行分盘,因此pnpm的store在上面通常只有一个路径。但在windows环境下,用户通常不止一个分区(除了C盘还分了其他区),这时候store会在每个磁盘的根目录,也就是C(C/.pnpm-store/v3)、D盘(D/.pnpm-store/v3),这是因为因为pnpm的硬链接模块机制(下面马上就会介绍到),硬链接只能在发生在同一文件系统同一分区上,因此可以想到一种情况,就是挂载一个移动存储(比如U盘),在这个移动存储设备中发生pnpm安装,那么在这个存储设备的根目录也会生成一个pnpm store

可以通过npm config set store-dir命令修改pnpm store地址,但不推荐跨分区设置,因为硬链接只支持该分区链接,设置跨分区store后,pnpm也是把另一个分区的store的内容复制到当前分区,也是占用了两个磁盘文件大小。

执行pnpm i成功安装模块后pnpm也会提示store的路径:

Packages are hard linked from the content-addressable store to  the virtual store.
  Content-addressable store is at: /Users/lijiahao/Library/pnpm/store/v3
  Virtual store is at:             node_modules/.pnpm
Progress: resolved 14, reused 13, downloaded 1, added 14, don

这个输出有两个关键字:

  1. Content-addressable store: 内容地址存储,一种常见的高效内容存储方式,根据文件内容进行存储,可以查看我的另一篇内容寻址存储原理及实际使用场景

  2. Virtual store: 虚拟存储目录。在项目node_modules下会有一个.pnpm的隐藏文件夹,文件夹的内容指向store的硬链接,所有直接和间接依赖项都链接到此目录中。

2.pnpm中的硬链接

2.1 硬链接介绍

在介绍硬链接之前先来了解文件Inode。操作系统会给每个文件分配一个唯一的inode,它包含了文件的元信息(所有者、权限、创建日期、修改日期、文件大小等),在访问文件时,对应的元信息就会被拷贝到内存中实现文件的访问。值得一提的是,不是硬链接创建的文件,不管是文件复制,同名文件或是内容完全相同的文件,它们的inode都是不一样的。

我们可以通过stat filename查看文件inode:

在macOS会显示如下: macos-inode

windows会显示如下: macos-inode

也可以通过ls -i命令只查看文件inode:

$ ls -i

122311848 LICENSE             122361533 package-lock.json
122311849 README.md           122311887 package.json
122311850 README.zh-CN.md     122311888 pnpm-lock.yaml
122362780 dist                122311889 pnpm-workspace.yaml
122311851 examples            122311890 src
122311886 index.d.ts          122311897 tsconfig.json
122354287 node_modules

通常情况下一个inode指向一个文件,但硬链接可以实现多个文件同时指向一个inode,即使文件名不同。可以通过ln <source file> <destination file>命令创建一个文件的硬链接,同时查看inode:

$ ln README.md README-HARDLINK.md
$ ls -i

122311849 README-HARDLINK.md
122311849 README.md

windows下使用fsutil hardlink list <filename>查看硬链接

文件硬链接不管有多少个,都指向的是同一个 inode 节点,这意味着当你修改源文件或者链接文件的时候,都会做同步的修改。 每新建一个硬链接会把节点连接数增加,只要节点的链接数非零,文件就一直存在。因此不管你是删除硬链接还是源文件,文件就一直生效。

通过硬链接, 可以实现通过不同的路径引用方式去找到某个文件,需要注意的是一般用户权限下只能硬链接到文件,不能用于目录

2.2 硬链接在pnpm中的使用

pnpm在项目node_modules下使用硬链接的方式引用模块,硬链接存放在虚拟存储目录.pnpm下:

$ tree ./node_modules/.pnpm 

├── balanced-match@1.0.2
│   └── node_modules
│       └── balanced-match
│           ├── LICENSE.md
│           ├── README.md
│           ├── index.js
│           └── package.json
├── brace-expansion@1.1.11
│   └── node_modules
│       ├── balanced-match -> ../../balanced-match@1.0.2/node_modules/balanced-match
│       ├── brace-expansion
│       │   ├── LICENSE
│       │   ├── README.md
│       │   ├── index.js
│       │   └── package.json

可以看到balanced-matchbrace-expansion模块的源码都放在<module@version>/node_modules/<module>下,带有->标识的是软连接方式(下面会讲到)。可以验证源码是不是从前面提到的pnpm store中硬链接出来的:

$ pnpm store path
/Users/lijiahao/Library/pnpm/store/v3

$ find /Users/lijiahao/Library/pnpm/store/v3 -type f -samefile package.json
/Users/lijiahao/Library/pnpm/store/v3/files/24/144b4624231200c7e50b47649fe94e048d5079b971c9888b6f044232db5e520d07e83c332df57adf578298934ae093888069ce408dd57c400426c9172d601b

因此可以确定pnpm对项目安装依赖的时候,如果某个依赖在 store 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次:

pnpm install --> pnpm store --> CAS found  --> hard link to node_modules
                            |
                            |--> CAS not found --> download to store --> hard link to node_modules

pnpm通过store + hard link的方式解决了npm/yarn的依赖分身问题,甚至不同项目之间的依赖也能等得到很好的复用!

3.软链接模块

硬链接只能链接到文件,但是node_modules是树状结构,文件夹的链接就靠软链接(soft-link)来实现。

软链接(soft-link)和windows中的快捷方式很相似,与硬链接不同的是,软链接可以作用于文件或文件夹,是源文件的一种引用,如果源文件被移动或被删除,软链接就会失效。

通过前面的讲解,我们知道了pnpm在全局通过Store来存储所有的node_modules依赖,并且在.pnpm/node_modules中存储项目的硬链接,通过硬链接来链接真实的文件资源,项目中的node_modules则通过symbolic link链接到.pnpm/node_modules目录中,依赖放置在同一级别避免了循环的软链。

├── index.js
├── node_modules
│   ├── minimatch -> .pnpm/minimatch@5.1.0/node_modules/minimatch
│   └── rimraf -> .pnpm/rimraf@3.0.2/node_modules/rimraf
├── package.json

至于项目依赖的子依赖,也是在.pnpm目录下使用嵌套node_modules然后使用软链接的方式引入子依赖,比如下面的brace-expansion子依赖balanced-match:

$ tree ./node_modules/.pnpm 

├── balanced-match@1.0.2
│   └── node_modules
│       └── balanced-match
│           ├── LICENSE.md
│           ├── README.md
│           ├── index.js
│           └── package.json
├── brace-expansion@1.1.11
│   └── node_modules
│       ├── balanced-match -> ../../balanced-match@1.0.2/node_modules/balanced-match // 子依赖软链
│       ├── brace-expansion
│       │   ├── LICENSE
│       │   ├── README.md
│       │   ├── index.js
│       │   └── package.json

现在可以看到,pnpmnode_modules目录结构也不是完全的扁平化结构,反而有点像npm@2的目录结构,只是用硬链+软链这种巧妙的方式引入模块,完全符合nodejs的模块规范,也避免了幽灵依赖和依赖分身的问题。

pnpm官网也有一个软链接和硬链接的示意图,大家可以根据我上面的解释再好好体会下:

pnpm

项目改造遇到的问题

vue-template-compiler版本混乱问题

因为pnpm使用<module>@<version>的方式存储模块,因此在monorepo项目中可以很好的隔离同一依赖不同版本的包。但个别模块没有设置PeerDependencies的话,pnpm就会把这个模块提升到公共模块使用,在我的项目中有vue@2和vue@3,因此vue-template-compiler被提升后其子依赖vue就会导致找到vue@3而报错。解决办法是在monorepo项目根目录package.json添加pnpm.packageExtensionsvue-template-compiler声明前置依赖:

// package.json
"pnpm": {
  "packageExtensions": {
    "vue-template-compiler": {
      "peerDependencies": {
        "vue": "2.6.11"
      }
    }
  }
}

部分模块被提升

在monorepo项目中根目录package.json通常没有声明依赖,但在实际执行pnpm i将各个项目的依赖安装后,在根node_modules会多出一些package.json里没有声明的依赖。

$ pnpm i
node_modules
  .bin/
  .pnpm/
  @eslint/eslintrc
  eslint
  eslint-scope
  eslint-utils
  eslint-visitor-keys
  .modules.yaml

这是因为pnpm的public-hoist-pattern默认值为['*eslint*', '*prettier*'],因此所有带eslintprettier关键字的模块都会提升到根模块目录中,提升至根模块目录中意味着应用代码可以访问到幻影依赖,详细配置可以查看public-hoist-pattern

pnpm link失败

即使使用monorepo,也有在其他项目中pnpm link进行调试的场景。在实际使用中发现pnpm安装时并没有自动设置pnpm global path,导致在运行pnpm link提示无法link在全局目录,而pnpm推荐使用的pnpm setup修复命令也无法正常运行(可能是个bug),这时候需手动设置pnpm global path

pnpm硬链接存储空间问题

硬链接看着使用了两份存储空间,但由于源文件和各个硬链接都是使用同一份inode,使用的存储空间其实只有一份。

store目录越来越大

随着项目及安装的依赖数量的增加,全局pnpm store目录也难免越来越大,这时候可以通过pnpm store prune将硬链接数量为0的文件进行删除,尽可能腾出空间。

幻影依赖处理

改造成monorepo结构后每个子项目运行时可能会出现xxx module not found问题,这时候大概率是依赖的子依赖存在幻影依赖问题,这时候可以通过pnpm.packageExtensions或手动将依赖添加到项目的package.json中解决。

包发布管理问题

pnpm主打依赖管理,但对于workspace内的包发布管理支持很弱,pnpm文档在发布工作流有推荐使用changesetsrush进行包发布管理,但我个人认为如果想把发布管理简单化可以直接使用lerna的发布管理,目前lerna已经不再维护,且不支持workspace协议,在monorepo本地调试发布存在缺陷,这里建议使用lerna-lite替代lerna来处理monorepo中的本地依赖关系。

总结

本文先从npm当前依赖管理痛点进行分析,引出幽灵依赖依赖分身这两个npm大痛点,pnpm解决了这个痛点并给开发者更好的npm依赖管理体验,个人也认为pnpm更符合未来npm/yarn的包管理方式,最后再罗列了一些本人在具体monorepo项目中使用pnpm遇到的问题和解释,希望读者可以在实际应用中举一反三。

(完)


原创不易,如果觉得这篇文章对你有帮助,不如赏杯咖啡吧
微信
支付宝