前阵子突然刷到了tnpm,然后学习了一下其中的优化npm install的原理,虽然对对业务没什么帮助,但是学习一些优化思想还是很有好处的。学习的过程中,刚好来总结一下这几年npm、yarn、pnpm的有点和缺点。
.npmrc
文件 > 用户级的 .npmrc
文件 > 全局级的 .npmrc
> npm
内置的 .npmrc
文件获取配置;package-lock.json
文件:
package-lock.json
和 package.json
声明的依赖是否一致:
package.json
来处理依赖关系;package.json
递归构建依赖树关系,构建过程:
npm
远程仓库进行下载pacote
解压到node_modules
pacote
解压到node_modules
pacote
解压到node_modules
package-lock.json
文件。前面提到了npm3
以后采用了扁平化的结构,但是这是结构也是有弊端的,假设我们现在需要安装:
node_modules
└──A
└──node_modules
└──B V1.0
└──C
└──node_modules
└──B V2.0
└──D
└──node_modules
└──B V2.0
└──E
从前面的安装机制不难猜出,由于A
依赖包内部依赖了B V1.0
,所以,A
依赖包和B V1.0
都会被安装到根目录,在安装C
依赖包时,由于跟目录已经有B V2.0
,所以B V2.0
会被安装在C
的node_modules
中,D
同理。即node_modules
的结构大概如下:
node_modules
├──A
├──B V1.0
└──C
└──node_modules
└──B V2.0
└──D
└──node_modules
└──B V2.0
└──E
同样的 package.json
文件,install
依赖后可能不会得到同样的 node_modules
目录结构。
还是之前的例子,A 依赖 [email protected],C 依赖 [email protected],依赖安装后究竟应该提升 B 的 1.0 还是 2.0。
node_modules
├── [email protected]
├── [email protected]
└── [email protected]
└── node_modules
└── [email protected]
└── [email protected]
└── node_modules
└── [email protected]
node_modules
├── [email protected]
│ └── node_modules
│ └── [email protected]
├── [email protected]
└── [email protected]
└── [email protected]
取决于用户的安装顺序。
重复模块:,它指的是模块名相同且 semver(语义化版本) 兼容。每个 semver 都对应一段版本允许范围,如果两个模块的版本允许范围存在交集,那么就可以得到一个兼容版本,
可以非法访问没有声明过依赖的包。虽然我们在dependencies
中没有直接写B
模块,但是我们可以直接require('B');
这就是phantom dependency
。一旦将该库被发布,因为用户安装这个库的时候并不会安装B
模块,所以会报错。
扁平化算法本身复杂性很高,耗时较长。
Yarn
优化了npm3
的一些问题:依赖安装速度慢,不确定性。
在 npm
中安装依赖时,安装任务是串行的,会按包顺序逐个执行安装,这意味着它会等待一个包完全安装,然后再继续下一个。
为了加快包安装速度,yarn
采用了并行操作,在性能上有显著的提高。而且在缓存机制上,yarn
会将每个包缓存在磁盘上,在下一次安装这个包时,可以脱离网络实现从磁盘离线安装。
在依赖安装时,会根据 package.josn 生成一份 yarn.lock 文件,记录了依赖,以及依赖的子依赖,依赖的版本,获取地址与验证模块完整性的 hash。即使是不同的安装顺序,相同的依赖关系在任何的环境和容器中,都能得到稳定的 node_modules 目录结构,保证了依赖安装的确定性。
还是没有解决幽灵依赖和依赖分身的问题。
npm后来也借助了package-lock.json来解决不确定性的问题。V5版本之后也加入了缓存策略,所以随着npm的升级,yarn的很多优点并不是很明显了。
加速下载相关源文件。原理上来说,cnpm
做的事情,就是给大家换了个registry
。
今年6月份淘宝源换了地址,所以之后使用cnpm需要更新或者更换一下最新的
registry
地址:原淘宝 npm 域名即将停止解析
无论是 npm
还是 yarn
,都具备缓存的功能,大多数情况下安装依赖时,其实是将缓存中的相关包复制到项目目录中 node_modules
里。涉及到IO操作,都是很耗费时间的。
而 yarn PnP
则不会进行拷贝这一步,而是在项目里维护一张静态映射表 pnp.cjs
。
pnp.cjs
会记录依赖在缓存中的具体位置,所有依赖都存在全局缓存中。同时自建了一个解析器,在依赖引用时,帮助 node 从全局缓存目录中发现依赖,而不是查找 node_modules
。
这样就避免了大量的 I/O 操作同时项目目录也不会有 node_modules
目录生成,同版本的依赖在全局也只会有一份,依赖的安装速度和解析速度都有较大提升。
node_modules
了,但是 Webpack,Babel
等各种前端工具都依赖 node_modules
。虽然很多工具比如 pnp-webpack-plugin
已经在解决了,但难免会有兼容性风险。前面提到了npm3
采用了扁平化结构,这里存在几个问题:
而pnpm
都对上述问题,做了改进。
pnpm i express
查看node_modules
文件夹:
.pnpm
.modules.yaml
express
这里的express
是一个软连接,里面没有node_modules
目录,而真正的文件位置在.pnpm-store
文件夹里。
▾ node_modules
▾ .pnpm
▸ [email protected]
▸ [email protected]
...
▾ [email protected]
▾ node_modules
▸ accepts -> ../[email protected]/node_modules/accepts
▸ array-flatten -> ../[email protected]/node_modules/array-flatten
...
▾ express
▸ lib
History.md
index.js
LICENSE
package.json
Readme.md
这种结果可以与package.json
声明的依赖基本保持一致,还减少了资源的占用率。通过这种依赖方式的管理还解决了依赖提升的安全问题。
因为 .pnpm
中都是通过硬链接来链接到同一份源码文件,当我们在某个项目中修改了这个包的文件时,所有项目中这个包都会被修改,这导致无法做到修改的项目隔离。
前端开发过程中,经常会遇到第三方开源库有 BUG 的情况,通常我们有以下处理方式。
pacth-package
给本地npm
包打补丁。然而pacth-package
并不支持pnpm。
蚂蚁集团 npm 工程师零弌在 SEE Conf 2022 支付宝体验科技大会上给分享了 一种秒级安装 npm 的方式:tnpm(蚂蚁集团鲁班奖),文中提出了npm install的痛点及优化方案:
不考虑缓存的情况下, 在执行 npm install
的时候,我们会按顺序、递归地去获取当前依赖的包信息、并将其对应的tar
下载下来,即这样HTTP请求的数量会越来越多,会导致依赖树生成的时间逐渐增多。
对此,可以 通过聚合的方式来减少 HTTP 请求量。
将项目的 package.json
发送到服务端,在服务端运行 @npmcli/arborist
来生成依赖树。将 arborist
访问 registry
的 HTTP
接口,直接劫持到我们的 registry
服务。通过内存缓存/分布式缓存/DB 来加速依赖树的生成过程。
@npmcli/arborist
是npm底层检查和管理node_modules
树的包。
文中以他安装的包为例子,当拉取tar包到本地之后,解压之后其中涉及的IO操作有:创建文件夹、写入文件、bin文件的软连接、对bin的读写权限设置。即共涉及13次IO操作。
由于npm install
的时候是从仓库拉取对应依赖包的tar
包,缓存的时候也是tar包,所以优化方式从tar
入手。如果不需要解压tar
包即都不需要上面这些IO操作:
tar
可以很简单的在尾部添加文件。因此我们可以将两个 tar
合并在一起。
写入两个包的流程将会变成:
fs.createFile
:创建出公共的文件fs.appendFile
:将第一个包写入fs.appendFile
:将第二个包写入从 26 次 IO 减少到了 3 次 IO操作。
虽然现在下载安装飞快,但是装完的东西没法用。tar
完全不是 JS
文件,没法直接用 JS
去 require
,没法在 shell
里操作,更没法在 IDE 里编辑。一切习惯都被打破了。接下来我们就需要解决 tar
的读写问题。
研究一下通过 JS 去require
的过程:
fs.readFile
这个 API 去发起一个文件读请求。libuv
中的 uv_req_t
数据结果libuv
会调用 libc
中的 read
方法。read
方法会发起系统调用,来访问内核中的文件系统去读取文件。PnP 采用了 zip
包的形式保存。通过劫持 node
的 require
方法来实现 zip
包的读取,通过开发 IDE
插件的方式来支持在 IDE
中读取。但是社区中有大量的实现是通过 fsAPI
去遍历 node_modules
目录,开发者也会使用 shell
去进行一些依赖操作。对于现有的使用习惯破坏较大。
解决完安装速度之后,我们还要解决最终的磁盘空间问题,现在 npm 安装之后占用了太多空间,黑洞之称实至名归。
NPM 采用全局 tar
的缓存来加速下载过程,减少重复的下载。但是每次解压还是占用了太多时间。
pnpm
采用文件硬链的形式来减少写入量,但是硬链代表全局指向了同一个文件,比如两个项目依赖了同一个包,如果其中一个在 debug
进行了一些改动,会影响到另一个包,造成意料之外的影响。
Overlay 还有一个特性是 COW(Copy On Write),在修改底层文件的时候会将底层的文件拷贝到上层目录中。因此我们可以使用同一份缓存,来支持全局所有的项目。
Corepack是一个实验性工具,在 Node.js v16.13 版本中引入.
可以在 package.json中配置:
"packageManager": "[email protected]"
// 声明的包管理器,会自动下载对应的 yarn,然后执行
yarn install
// 用非声明的包管理器,会自动拦截报错
pnpm install
Usage Error: This project is configured to use yarn
试验阶段存在的问题:
在它运行之前,它会检测你的 yarn.lock
/ pnpm-lock.yaml
/ package-lock.json
以了解当前的包管理器,并运行相应的命令。
使用 `ni` 在项目中安装依赖时:
假设你的项目中有锁文件 `yarn.lock`,那么它最终会执行 `yarn install` 命令。
假设你的项目中有锁文件 `pnpm-lock.yaml`,那么它最终会执行 `pnpm i` 命令。
假设你的项目中有锁文件 `package-lock.json`,那么它最终会执行 `npm i` 命令。
npm i -g @antfu/ni
ni
如有错误,欢迎指出,感谢阅读~