团队成立初期我们采用 npm3 来管理项目依赖,后续我们研发了自己组件库、图表库、工具库,采用了 monorepo 管理,依赖管理也由 npm3 切换成了 yarn(yarn workspace)。不管是 npm3 还是 yarn 都采用扁平化的 node_modules 文件夹方式,以此避免引入层级过深、相同依赖版本重复等问题。
随着公司业务不断壮大,团队支撑的项目越来越多。由于依赖是跟随项目的,导致磁盘空间占用严重。
由于上述原因,开始尝试使用 pnpm 来进行管理。
节约磁盘空间
pnpm 依赖项将存储在一个全局内容可寻址的仓库中(${os.homedir}/.pnpm-store
),具体项目中使用依赖采用硬链接方式,而不是进行复制。对于每个模块的每个版本只保留一个副本。如:本地有10个项目依赖相同 vue 版本,如果使用 npm 或 yarn 时本地磁盘需要有 10 个 vue 的副本;而 pnpm 只有1个。
- 如果你用到了某依赖项的不同版本,那么只会将有差异的文件添加到仓库(公共仓库)。
- 所有文件都会存储在硬盘上的同一位置。 当多个包(package)被安装时,所有文件都会从同一位置创建硬链接,不会占用额外的磁盘空间。 这允许跨项目共享同一版本的依赖。
$ pnpm install
Packages: 1585
Packages are hard linked from the content-addressable store to the virtual store.
Content-addressable store is at: /Users/ligang/.pnpm-store/v3
Virtual store is at: node_modules/.pnpm
Progress: resolved 1585, reused 1585, downloaded 0, added 1585, done
可以发现:
- 内容可寻址存储在
/Users/ligang/.pnpm-store/v3
- 虚拟存储目录
node_modules/.pnpm
- downloaded 0,这样极大的提升了 install 速度
ll node_modules
lrwxr-xr-x 1 ligang staff 44B 9 1 17:59 deepmerge -> .pnpm/deepmerge@3.3.0/node_modules/deepmerge
lrwxr-xr-x 1 ligang staff 72B 9 1 17:59 element-resize-detector -> .pnpm/element-resize-detector@1.2.2/node_modules/element-resize-detector
lrwxr-xr-x 1 ligang staff 58B 9 1 17:59 element-ui -> .pnpm/element-ui@2.13.1_vue@2.6.12/node_modules/element-ui
lrwxr-xr-x 1 ligang staff 39B 9 1 17:59 eslint -> .pnpm/eslint@5.16.0/node_modules/eslint
node_modules 目录下的文件全部被软链到了虚拟存储路径下 .pnpm
。.pnpm/
以平铺的形式储存着所有的包(格式:.pnpm/@/node_modules/
)。.pnpm
目录下的包会硬链到全局仓库中(/Users/ligang/.pnpm-store/v3
)。
关于「硬链」、「软链」可以查看上篇博文。
以项目中依赖 element-ui 为例:
代码语言:javascript复制cd node_modules
ls -li element-ui
8643474522 lrwxr-xr-x 1 ligang staff 58 9 1 17:59 element-ui -> .pnpm/element-ui@2.13.1_vue@2.6.12/node_modules/element-ui
ls -li .pnpm/element-ui@2.13.1_vue@2.6.12/node_modules/
8643424956 drwxr-xr-x 13 ligang staff 416 9 1 17:59 element-ui
node_modules
目录下,element-ui
被软链到了 .pnpm
对应的目录下 element-u
i;.pnpm
目录下,element-ui
是硬链接( link count 13)。
非扁平化的 node_modules 文件夹
回归一下 node_modules 结构历史:
第一阶段:npm@3 之前版本
代码语言:javascript复制node_modules
└─ foo
├─ index.js
├─ package.json
└─ node_modules
└─ bar
├─ index.js
└─ package.json
- 依赖树层级太深,会导致 Windows 上的目录路径过长问题
- 相同包在不同的依赖项中需要时,会存在多个相同副本
第二阶段:npm@3 版本,扁平化处理
主要是解决上述两个问题
代码语言:javascript复制node_modules
├─ foo
| ├─ index.js
| └─ package.json
└─ bar
├─ index.js
└─ package.json
第三阶段:pnpm
由于扁平化算法的极其复杂,以及会存在多项目间相同依赖副本的情况。pnpm 在尝试解决这些问题时,放弃了扁平化处理 node_modules 的方式。而是采用 硬链 软链 方式。
代码语言:javascript复制node_modules
├─ .pnpm
| ├─ foo@1.0.0/node_modules/foo
| | └─ index.js
| └─ bar@2.0.0/node_modules/bar
├─ foo -> .pnpm/foo@1.0.0/node_modules/foo
└─ bar -> .pnpm/bar@2.0.0/node_modules/bar
node_modules 根目录中的包只是一个符号链接。require('foo')
将执行 node_modules/.pnpm/foo@1.0.0/node_modules/foo/indexjs
中的文件(这里是硬链接),而不是 node_modules/foo/index.js
中的文件。
好处
这种布局结构的一大好处是只有真正在依赖项中(package.json dependences
)的包才能访问。使用扁平化的 node_modules 结构,所有提升的包都可以访问。
npm@3/yarn 采用扁平化的方式管理 node_modules
示例:以 chokidar 为例
代码语言:javascript复制"dependencies": {
"chokidar": "^3.5.2"
}
项目中依赖了 chokidar 用于监听文件夹内容变化,通过 npm 安装后结构
依赖包如此之多,正是由于扁平化处理而来。chokidar 依赖包以及其依赖的依赖包都被提取到了一级目录下。这种方式会导致没有明确被依赖的包也可以被引用。
代码语言:javascript复制const isNumber = require('is-number')
console.log(isNumber(123), isNumber('abc'))
上述可以正常引用到!
采用 pnpm 重新安装
执行上面代码,会报错:Error: Cannot find module ‘is-number’
问题
扁平化 node_modules 导致了上述错误。如果存在这种情况,需要切换成 pnpm 我们应该如何处理?
方案1:
通过 pnpm add
添加依赖
方案2:
通过相关 hooks 添加相关的依赖
.pnpmfile.cjs
代码语言:javascript复制module.exports = {
hooks: {
readPackage: (pkg) => {
if (pkg.name === "inspectpack") {
pkg.dependencies['babel-traverse'] = '^6.26.0'
}
return pkg
}
}
}
方案3:
如果缺少依赖太多,可以使用提升选项。此选项官方不推荐。
代码语言:javascript复制pnpm install --shamefully-hoist
由于 cli3 对于 pnpm 支持不够完善(在 cli4 中已完全支持),我们采用了这种方式。 相关 Issue
总结
pnpm 方式的实现精髓
- 通过软链的形式,使得 require 可以正常引用;同时对非真正依赖的项目做隔离(避免引用依赖的混乱)
.pnpm
的存在避免了循环引用和层级过深的问题(都在其第一层)- 硬链使得不同项目相同依赖只存在一个副本,减少磁盘空间
参考链接
- https://www.kochan.io/nodejs/pnpms-strictness-helps-to-avoid-silly-bugs.html
- https://www.kochan.io/nodejs/why-should-we-use-pnpm.html
- https://github.com/vuejs/vue-cli/issues/2703
- https://pnpm.io/zh/faq