包管理工具npm、Yarn、Pnpm
前言
- 本文将对比近些年主流的包管理工具
- 目前主要有npm、yarn、pnpm
介绍
npm(2010年)
名称来源
- npm不是
node package manager
的缩写 - npm是
npm is not an acronym
的递归缩写 - 所以npm翻译成汉语就是
npm不是一个首字母缩略词
,这就类似于计算机领域比较著名的GUN
,它就是GNU's Not UNIX
递归缩写 - npm 官方辟谣:
缺点
- **
node_modules
体积膨胀:**由于依赖嵌套和重复安装,一个简单的项目可能产生数百兆的node_modules
目录 - **安装效率低下:**重复的依赖下载和磁盘写入操作导致安装速度慢
- **依赖结构复杂:****「扁平化」**算法可能导致依赖关系难以预测
- 磁盘空间浪费:相同的依赖包在不同项目中「重复存储」
Yarn(2016年)
名称来源
- Yarn的名称来源于缩写“Yet Another Resource Negotiator”,直译为“又一个资源协调者”,名称中的“Yet Another”体现了对既有工具(如npm)的迭代优化,强调其作为资源协调者的定位。。
- Facebook 团队面对
npm
在大型项目中的种种问题,如安装不确定性、性能低下等,其设计初衷是改进npm的依赖管理机制。
缺点
- 兼容性问题:部分npm生态工具(如
npx
)与Yarn的协作可能受限,需通过额外插件适配; - 磁盘占用优化有限:虽采用扁平化依赖结构,但仍无法彻底解决多项目重复存储问题;
- 幽灵依赖:下文会提到;
- 配置复杂性:需维护
yarn.lock
文件以确保依赖确定性,增加了多环境协作的维护成本。
Pnpm(2017年)
名称来源
- Pnpm全称为“Performance Node Package Manager”,直译为“高性能Node包管理器”。名称直接体现了其核心技术优势:通过硬链接与符号链接机制,显著提升安装速度并减少磁盘占用。
缺点
- 生态兼容性:部分依赖本地文件路径的包(如
Electron
)可能因链接机制导致运行异常; - 学习成本:非扁平化的依赖存储模式与传统工具差异较大,需开发者适应新调试逻辑;
- 社区规模较小:相比Yarn和npm,插件与工具链支持相对有限。
嵌套结构 (npm3以前的版本)
在 npm
的早期版本(npm3以前的版本), npm
处理依赖的方式简单粗暴,以递归的形式,严格按照 package.json
结构以及子依赖包的 package.json
结构将依赖安装到项目各自的 node_modules
中。直到有子依赖包不在依赖其他模块。
举个例子,我们的模块 my-app
现在依赖了两个模块:buffer
、ignore
:
{
"name": "my-app",
"dependencies": {
"buffer": "^5.4.3",
"ignore": "^5.1.4",
}
}
ignore
是一个纯 JS
模块,不依赖任何其他模块,而 buffer
又依赖了下面两个模块:base64-js
、 ieee754
。
{
"name": "buffer",
"dependencies": {
"base64-js": "^1.0.2",
"ieee754": "^1.1.4"
}
}
那么,执行 npm install
后,得到的 node_modules
中模块目录结构就是下面这样的:
这样的方式优点很明显, node_modules
的结构和 package.json
结构一一对应,层级结构明显,并且保证了每次安装目录结构都是相同的。
如果你依赖的模块非常之多,你的 node_modules
将非常庞大,嵌套层级非常之深:
嵌套结构-问题总结
- **磁盘空间占用:**每个依赖都会安装自己的依赖,导致了大量的重复,特别是在多个包共享同一依赖的场景下。在不同层级的依赖中,可能引用了同一个模块,导致大量冗余。
- **深层嵌套问题:**在
Windows
系统中,文件路径最大长度为260个字符,嵌套层级过深可能导致不可预知的问题。 - **安装和更新缓慢:**每次安装或更新依赖时,npm 需要处理和解析整个依赖树,过程非常缓慢。
扁平结构 (npm3+)
为了解决以上问题,NPM
在 3.x
版本做了一次较大更新。其将早期的嵌套结构改为扁平结构:
- 安装模块时,不管其是直接依赖还是子依赖的依赖,优先将其安装在
node_modules
根目录。
还是上面的依赖结构,我们在执行 npm install
后将得到下面的目录结构:
此时我们若在模块中又依赖了 base64-js@1.0.1
版本:
{
"name": "my-app",
"dependencies": {
"buffer": "^5.4.3",
"ignore": "^5.1.4",
"base64-js": "1.0.1",
}
}
- 当安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的
node_modules
下安装该模块。
此时,我们在执行 npm install
后将得到下面的目录结构:
对应的,如果我们在项目代码中引用了一个模块,模块查找流程如下:
- 在当前模块路径下搜索
- 在当前模块
node_modules
路径下搜素 - 在上级模块的
node_modules
路径下搜索 - ...
- 直到搜索到全局路径中的
node_modules
假设我们又依赖了一个包 buffer2@^5.4.3
,而它依赖了包 base64-js@1.0.3
,则此时的安装结构是下面这样的:
所以 npm 3.x
版本并未完全解决老版本的模块冗余问题,甚至还会带来新的问题。
试想一下,你的APP假设没有依赖 base64-js@1.0.1
版本,而你同时依赖了依赖不同 base64-js
版本的 buffer
和 buffer2
。由于在执行 npm install
的时候,按照 package.json
里依赖的顺序依次解析,则 buffer
和 buffer2
在 package.json
的放置顺序则决定了 node_modules
的依赖结构:
先依赖buffer2
:
先依赖buffer
:
另外,为了让开发者在安全的前提下使用最新的依赖包,我们在 package.json
通常只会锁定大版本,这意味着在某些依赖包小版本更新后,同样可能造成依赖结构的改动,依赖结构的不确定性可能会给程序带来不可预知的问题。
扁平结构-问题总结
- 模块冗余
- 放置顺序则决定了
node_modules
的依赖结构 - 小版本更新后,可能造成依赖结构的改动
- 幽灵依赖
- 在 package.json 中未定义的依赖,但项目中依然可以正确地被引用到。
- 项目只安装了 A 和 C,A依赖B。由于 B 在安装时被提升到了和 A 同样的层级,所以在项目中引用 B 还是能正常工作的。如果某天某个版本的 A 依赖不再依赖 B 或者 B 的版本发生了变化,那项目本身未在package.json声明就直接使用依赖B就会无法找到。
Lock文件 (npm5+)
为了解决 npm install
的不确定性问题,在 npm 5.x
版本新增了 package-lock.json
文件,而安装方式还沿用了 npm 3.x
的扁平化的方式。
package-lock.json
的作用是锁定依赖结构,即只要你目录下有 package-lock.json
文件,那么你每次执行 npm install
后生成的 node_modules
目录结构一定是完全相同的。
例如,我们有如下的依赖结构:
{
"name": "my-app",
"dependencies": {
"buffer": "^5.4.3",
"ignore": "^5.1.4",
"base64-js": "1.0.1",
}
}
在执行 npm install
后生成的 package-lock.json
如下:
{
"name": "my-app",
"version": "1.0.0",
"dependencies": {
"base64-js": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz",
"integrity": "sha1-aSbRsZT7xze47tUTdW3i/Np+pAg="
},
"buffer": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz",
"integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==",
"requires": {
"base64-js": "^1.0.2",
"ieee754": "^1.1.4"
},
"dependencies": {
"base64-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
}
}
},
"ieee754": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
},
"ignore": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz",
"integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A=="
}
}
}
我们来具体看看上面的结构:
最外面的两个属性 name
、version
同 package.json
中的 name
和 version
,用于描述当前包名称和版本。
dependencies
是一个对象,对象和 node_modules
中的包结构一一对应,对象的 key
为包名称,值为包的一些描述信息:
version
:包版本 —— 这个包当前安装在node_modules
中的版本resolved
:包具体的安装来源integrity
:包hash
值,基于Subresource Integrity
来验证已安装的软件包是否被改动过、是否已失效requires
:对应子依赖的依赖,与子依赖的package.json
中dependencies
的依赖项相同。dependencies
:结构和外层的dependencies
结构相同,存储安装在子依赖node_modules
中的依赖包。
这里注意,并不是所有的子依赖都有 dependencies
属性,只有子依赖的依赖和当前已安装在根目录的 node_modules
中的依赖冲突之后,才会有这个属性。
例如,回顾下上面的依赖关系:
我们在 my-app
中依赖的 base64-js@1.0.1
版本与 buffer
中依赖的 base64-js@^1.0.2
发生冲突,所以 base64-js@1.0.1
需要安装在 buffer
包的 node_modules
中,对应了 package-lock.json
中 buffer
的 dependencies
属性。这也对应了 npm
对依赖的扁平化处理方式。
所以,根据上面的分析, package-lock.json
文件 和 node_modules
目录结构是一一对应的,即项目目录下存在 package-lock.json
可以让每次安装生成的依赖目录结构保持相同。
另外,项目中使用了 package-lock.json
可以显著加速依赖安装时间。
我们使用 npm i --timing=true --loglevel=verbose
命令可以看到 npm install
的完整过程,下面我们来对比下使用 lock
文件和不使用 lock
文件的差别。在对比前先清理下npm
缓存。
不使用 lock
文件:
使用 lock
文件:
可见, package-lock.json
中已经缓存了每个包的具体版本和下载链接,不需要再去远程仓库进行查询,然后直接进入文件完整性校验环节,减少了大量网络请求。
使用建议
开发系统应用时,建议把 package-lock.json
文件提交到代码版本仓库,从而保证所有团队开发者以及 CI
环节可以在执行 npm install
时安装的依赖版本都是一致的。
在开发一个 npm
包 时,你的 npm
包 是需要被其他仓库依赖的,由于上面我们讲到的扁平安装机制,如果你锁定了依赖包版本,你的依赖包就不能和其他依赖包共享同一 semver
范围内的依赖包,这样会造成不必要的冗余。所以我们不应该把package-lock.json
文件发布出去( npm
默认也不会把 package-lock.json
文件发布出去)。
缓存 (npm5+)
在执行 npm install
或 npm update
命令下载依赖后,除了将依赖包安装在node_modules
目录下外,还会在本地的缓存目录缓存一份。
通过 npm config get cache
命令可以查询到:在 Linux
或 Mac
默认是用户主目录下的 .npm/_cacache
目录。
在这个目录下又存在两个目录:content-v2
、index-v5
,content-v2
目录用于存储 tar
包的缓存,而index-v5
目录用于存储tar
包的 hash
。
npm 在执行安装时,可以根据 package-lock.json
中存储的 integrity、version、name
生成一个唯一的 key
对应到 index-v5
目录下的缓存记录,从而找到 tar
包的 hash
,然后根据 hash
再去找缓存的 tar
包直接使用。
我们可以找一个包在缓存目录下搜索测试一下,在 index-v5
搜索一下包路径:
grep "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz" -r index-v5
然后我们将json格式化:
{
"key": "pacote:version-manifest:https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz:sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=",
"integrity": "sha512-C2EkHXwXvLsbrucJTRS3xFHv7Mf/y9klmKDxPTE8yevCoH5h8Ae69Y+/lP+ahpW91crnzgO78elOk2E6APJfIQ==",
"time": 1575554308857,
"size": 1,
"metadata": {
"id": "base64-js@1.0.1",
"manifest": {
"name": "base64-js",
"version": "1.0.1",
"engines": {
"node": ">= 0.4"
},
"dependencies": {},
"optionalDependencies": {},
"devDependencies": {
"standard": "^5.2.2",
"tape": "4.x"
},
"bundleDependencies": false,
"peerDependencies": {},
"deprecated": false,
"_resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz",
"_integrity": "sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=",
"_shasum": "6926d1b194fbc737b8eed513756de2fcda7ea408",
"_shrinkwrap": null,
"bin": null,
"_id": "base64-js@1.0.1"
},
"type": "finalized-manifest"
}
}
上面的 _shasum
属性 6926d1b194fbc737b8eed513756de2fcda7ea408
即为 tar
包的 hash
, hash
的前几位 6926
即为缓存的前两层目录,我们进去这个目录果然找到的压缩后的依赖包:
以上的缓存策略是从 npm v5 版本开始的,在 npm v5 版本之前,每个缓存的模块在 ~/.npm 文件夹中以模块名的形式直接存储,储存结构是{cache}/{name}/{version}。
npm
提供了几个命令来管理缓存数据:
npm cache add
:官方解释说这个命令主要是npm
内部使用,但是也可以用来手动给一个指定的 package 添加缓存。npm cache clean
:删除缓存目录下的所有数据,为了保证缓存数据的完整性,需要加上--force
参数。npm cache verify
:验证缓存数据的有效性和完整性,清理垃圾数据。
基于缓存数据,npm 提供了离线安装模式,分别有以下几种:
--prefer-offline
:优先使用缓存数据,如果没有匹配的缓存数据,则从远程仓库下载。--prefer-online
:优先使用网络数据,如果网络数据请求失败,再去请求缓存数据,这种模式可以及时获取最新的模块。--offline
:不请求网络,直接使用缓存数据,一旦缓存数据不存在,则安装失败。
整体流程
好了,我们再来整体总结下上面的流程:
检查
.npmrc
文件:优先级为:项目级的.npmrc
文件 > 用户级的.npmrc
文件> 全局级的.npmrc
文件 > npm 内置的.npmrc
文件检查项目中有无
lock
文件。无lock文件:
从
npm
远程仓库获取包信息根据package.json构建依赖树,构建过程:
- 构建依赖树时,不管其是直接依赖还是子依赖的依赖,优先将其放置在
node_modules
根目录。 - 当遇到相同模块时,判断已放置在依赖树的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的
node_modules
下放置该模块。 - 注意这一步只是确定逻辑上的依赖树,并非真正的安装,后面会根据这个依赖结构去下载或拿到缓存中的依赖包
- 构建依赖树时,不管其是直接依赖还是子依赖的依赖,优先将其放置在
在缓存中依次查找依赖树中的每个包
不存在缓存:
- 从
npm
远程仓库下载包 - 校验包的完整性
- 校验不通过:
- 重新下载
- 校验通过:
- 将下载的包复制到
npm
缓存目录 - 将下载的包按照依赖结构解压到
node_modules
- 将下载的包复制到
- 从
存在缓存:将缓存按照依赖结构解压到
node_modules
将包解压到node_modules
生成lock文件
有lock文件:
- 检查
package.json
中的依赖版本是否和package-lock.json
中的依赖有冲突。 - 如果没有冲突,直接跳过获取包信息、构建依赖树过程,开始在缓存中查找包信息,后续过程相同
流程图
- 横版
- 竖版
上面的过程简要描述了 npm install
的大概过程,这个过程还包含了一些其他的操作,例如执行你定义的一些生命周期函数,你可以执行 npm install package --timing=true --loglevel=verbose
来查看某个包具体的安装流程和细节。
yarn
yarn
是在2016
年发布的,那时npm
还处于V3
时期,那时候还没有package-lock.json
文件,就像上面我们提到的:不稳定性、安装速度慢等缺点经常会受到广大开发者吐槽。此时,yarn
诞生:
上面是官网提到的 yarn
的优点,在那个时候还是非常吸引人的。当然,后来 npm
也意识到了自己的问题,进行了很多次优化,在后面的优化(lock
文件、缓存、默认-s...)中,我们多多少少能看到 yarn
的影子,可见 yarn
的设计还是非常优秀的。
yarn
也是采用的是 npm v3
的扁平结构来管理依赖,安装依赖后默认会生成一个 yarn.lock
文件,还是上面的依赖关系,我们看看 yarn.lock
的结构:
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
base64-js@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.0.1.tgz#6926d1b194fbc737b8eed513756de2fcda7ea408"
integrity sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=
base64-js@^1.0.2:
version "1.3.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
buffer@^5.4.3:
version "5.4.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115"
integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==
dependencies:
base64-js "^1.0.2"
ieee754 "^1.1.4"
ieee754@^1.1.4:
version "1.1.13"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
ignore@^5.1.4:
version "5.1.4"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf"
integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==
可见其和 package-lock.json
文件还是比较类似的,还有一些区别就是:
package-lock.json
使用的是json
格式,yarn.lock
使用的是一种自定义格式yarn.lock
中子依赖的版本号不是固定的,意味着单独又一个yarn.lock
确定不了node_modules
目录结构,还需要和package.json
文件进行配合。而package-lock.json
只需要一个文件即可确定。
yarn
的缓存策略看起来和 npm v5
之前的很像,每个缓存的模块被存放在独立的文件夹,文件夹名称包含了模块名称、版本号等信息。使用命令 yarn cache dir
可以查看缓存数据的目录:
yarn
默认使用prefer-online
模式,即优先使用网络数据,如果网络数据请求失败,再去请求缓存数据。
Yarn-问题总结
- 依赖存储效率低下
- 仍然采用扁平化的
node_modules
结构 - 不同项目的相同依赖包会被重复存储,造成磁盘空间浪费
- 在
monorepo
项目中,即使使用workspace
功能,依赖重复问题依然存在
- 仍然采用扁平化的
- 幽灵依赖
- 由于扁平化处理,项目可以直接使用未声明在
package.json
中的依赖 - 这种隐式依赖可能导致潜在的问题和不可预测的行为
- 由于扁平化处理,项目可以直接使用未声明在
- 安装性能
- 虽然有并行下载,但大量文件复制和链接操作仍然耗时
- 大型项目的首次安装和清理重装仍然较慢
- 磁盘空间占用
- 即使是小型项目,
node_modules
目录也可能占用数百 MB 空间 - 对于维护多个项目的开发者来说,磁盘空间消耗巨大
- 即使是小型项目,
yarn与npm对比
版本时间线
- 2010 年:npm 发布,支持 Node.js。
- 2016 年:发布纱线。它显示出比 npm 更高的性能。它还生成一个yarn.lock文件,使 repos 的共享和精确复制变得更加容易和可预测。
- 2017 年:npm 5 发布。它提供自动生成package-lock.json文件以响应yarn.lock.
- 2018 年:npm 6 发布,提高了安全性。现在 npm 在安装依赖项之前检查安全漏洞。
- 2020 年:Yarn 2 和 npm 7 发布。这两个软件包都带有很棒的新功能,我们将在本教程后面看到。
- 2021 年:Yarn 3 发布并进行了各种改进。
速度和性能
每当 Yarn 或 npm 需要安装包时,它们都会执行一系列任务。在 npm 中,这些任务是按包顺序执行的,这意味着它会等待一个包完全安装,然后再继续下一个。相比之下,Yarn 并行执行这些任务,从而提高了性能。
虽然这两个管理器都提供缓存机制,但 Yarn 似乎做得更好一些。通过实现零安装范例,我们将在功能比较部分看到,它几乎可以立即安装软件包。它缓存每个包并将其保存在磁盘上,因此在此包的下一次安装中,您甚至不需要互联网连接,因为该包是从磁盘离线安装的。
尽管 Yarn 有一些优势,但 Yarn 和 npm 在它们的最新版本中的速度相当。所以我们不能在这里定义一个干净的赢家。
安全性比较
对 npm 的主要批评之一是关于安全性。以前的 npm 版本有几个严重的安全漏洞。
从npm6 开始,npm 会在安装过程中审核软件包并告诉您是否发现了任何漏洞。我们可以通过npm audit针对已安装的软件包运行来手动执行此检查。如果发现任何漏洞,npm 会给我们安全建议。
正如你在上面的截图中看到的,我们可以运行npm audit fix
来修复包漏洞,如果可能的话,依赖树将被修复。
Yarn 和 npm 都使用加密哈希算法来确保包的完整性。
选择哪个包管理器
作为一般指南,让我总结以下建议:
- 如果您对当前的工作流程感到满意,不想安装额外的工具,并且您没有大量磁盘空间,请选择 npm。
- 如果您想要一些很棒的功能,例如
Plug'n'Play(即插即用)
,您需要一些 npm 中缺少的功能,并且您有足够的磁盘空间,请选择 Yarn。
如果你仍然很难在 npm 和 Yarn 之间做出明确的决定,那么你可以查看pnpm
,它试图结合两个包管理器的优点,是包管理池中的第三大鱼。
- 2010年
npm
诞生第一版 - 时隔4年的2014年
cnpm
诞生 - 2015年
npm
发布v3版,改掉了v2版本嵌套目录问题,将依赖扁平化 - 其实2016年
pnpm
就已经诞生,只是功能还不齐全,不被人熟知 - 2016年
npm@4
和yarn
同年同月发布,此时的yarn
轰动一时,赢来大众喜爱,yarn
各指标远超npm@4
- 隔半年的2017年5月
npm@5
版本发布,各项功能提升。像是参考了一波yarn
,差距缩小。 - 2017年7月
npm@5.2
发布,npx
命令诞生 - 2018年5月
npm@6
发布,性能提升、npm init <pkg>
命令
命令对比
npx husky-init && npm install # npm
npx husky-init && yarn # Yarn 1
yarn dlx husky-init --yarn2 && yarn # Yarn 2+
pnpm dlx husky-init && pnpm install # pnpm
npm i
npm instal
i
in
ins inst insall
PNPM( performant<高性能的> npm
)
优势
提高安装速度
依赖解析。 仓库中没有的依赖都被识别并获取到仓库。
目录结构计算。
node_modules
目录结构是根据依赖计算出来的。链接依赖项。 所有以前安装过的依赖项都会直接从仓库中获取并链接到
node_modules
。npm、yarn的resolving(解析依赖树)>fetching(下载tar包)>wrting(解压包)每个步骤都是所有包放在一起进行的需要互相等待
pnpm每个包都有单独的resolving(解析依赖树)>fetching(下载tar包)>wrting(解压包),无需互相等待
高效利用磁盘空间,共享依赖
依赖处理方式:依赖包 ---(软链接)--- > .pnpm ----(硬链接) ---> 全局的 Store
硬链接(Hard Link)
硬链接指通过索引节点来进行连接。在Linux的文件系统中,保存在磁盘分区中的文件不管是什么类型都给它分配一个编号,称为索引节点号(Inode Index)。在Linux中,多个文件名指向同一索引节点是存在的。一般这种连接就是硬连接。硬连接的作用是允许一个文件拥有多个有效路径名,这样用户就可以建立硬连接到重要文件,以防止“误删”的功能。其原因如上所述,因为对应该目录的索引节点有一个以上的连接。只删除一个连接并不影响索引节点本身和其它的连接,只有当最后一个连接被删除后,文件的数据块及目录的连接才会被释放。也就是说,文件真正删除的条件是与之相关的所有硬连接文件均被删除。
pnpm 通过使用全局的
.pnpm-store
来存储下载的包,使用硬链接来重用存储在全局存储中的包文件,这样不同项目中相同的包无需重复下载,节约磁盘空间。
软链接(Symbolic Link)
另外一种连接称之为符号连接,也叫软连接。软链接文件有类似于Windows的快捷方式。它实际上是一个特殊的文件。在符号连接中,文件实际上是一个文本文件,其中包含的有另一文件的位置信息。
pnpm 将各类包的不同版本平铺在
node_modules/.pnpm
下,对于那些需要构建的包,它使用符号链接连接到存储在项目中的实际位置。这种方式使得包的安装非常快速,并且节约磁盘空间。
pnpm 内部使用
基于内容寻址
的文件系统来存储磁盘上所有的文件:- 不会重复安装同一个包。使用
npm/yarn
的时候,如果100个包依赖lodash
,那么就可能安装了100次lodash
,磁盘中就有100个地方写入了这部分代码。但是pnpm
会只在一个地方写入这部分代码,后面使用会直接使用hard link
- 即使一个包的不同版本,pnpm 也会极大程度地复用之前版本的代码。举个例子,比如 lodash 有 100 个文件,更新版本之后多了一个文件,那么磁盘当中并不会重新写入 101 个文件,而是保留原来的 100 个文件的
hardlink
,仅仅写入那一个新增的文件
。
- 不会重复安装同一个包。使用
安全(解决幽灵依赖)
- 之前在使用 npm/yarn 的时候,由于 node_module 的扁平结构,如果 A 依赖 B, B 依赖 C,那么 A 当中是可以直接使用 C 的,但问题是 A 当中并没有声明 C 这个依赖。因此会出现这种非法访问的情况。 但 pnpm 只有直接依赖会平铺在 node_modules 下,子依赖不会被提升,不会产生幽灵依赖,很好地解决了这个问题,保证了安全性。
避免依赖重复安装
- pnpm 会在全局的 store 目录里存储项目
node_modules
文件的hard links
。因为这样一个机制,导致每次安装依赖的时候,如果是个相同的依赖,有好多项目都用到这个依赖,那么这个依赖实际上最优情况(即版本相同)只用安装一次。
- pnpm 会在全局的 store 目录里存储项目
使用符号链接创建严格的依赖结构
node_modules 结构历史
npm@3 之前版本
- 依赖树层级太深,会导致 Windows 上的目录路径过长问题
- 相同包在不同的依赖项中需要时,会存在多个相同副本
npm@3 及之后版本,扁平化处理
- 依赖结构的不确定性
- 扁平化算法本身的复杂性很高,耗时较长
- 项目中仍然可以非法访问没有声明过依赖的包
- 这就是为什么会产生依赖结构的
不确定
问题,也是lock 文件
诞生的原因,无论是package-lock.json
(npm 5.x才出现)还是yarn.lock
,都是为了保证 install 之后都产生确定的node_modules
结构。 - npm/yarn 本身还是存在
扁平化算法复杂
和package 非法访问
的问题,影响性能和安全
pnpm
由于扁平化算法的极其复杂,以及会存在多项目间相同依赖副本的情况。pnpm 在尝试解决这些问题时,放弃了扁平化处理 node_modules 的方式。而是采用 硬链+软链 方式。
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
中的文件。示例:
安装一个
express
依赖,会在 node_modules 中形成这样两个目录结构:node_modules/express/... node_modules/.pnpm/express@4.17.1/node_modules/xxx
其中第一个路径是 nodejs 正常寻找路径会去找的一个目录,如果去查看这个目录下的内容,会发现里面连个 node_modules 文件都没有
基于pnpm的node_modules结构简约,项目里主动引入的依赖包都会被安装到node_modules根目录, 非一级依赖包的子子孙孙都不会被打平到node_modules下了
疑问
如果包存储在全局存储中,为什么我的 node_modules 使用了磁盘空间?
pnpm 创建从全局存储到项目下
node_modules
文件夹的 硬链接。 硬链接指向磁盘上原始文件所在的同一位置。 因此,例如,如果您的项目中foo
并且它占用 1MB 的空间,那么看起来它在项目的node_modules
文件夹中占用了与全局存储相同的 1MB 的空间。 但是,该 1MB 是磁盘上两个不同位置的相同空间 。 所以foo
总共占用 1MB,而不是 2MB。能用于Windows吗
短回答:当然 长答案:在 Windows 上使用符号链接至少可以说是有问题的,但是,pnpm 有一个解决方法。 对于 Windows,我们用junctions/交接点/结点替代。
为什么Windows不直接用硬链接?
Windows硬链接只适用于文件, 对于拥有复杂结构的包来说无法满足, 所以选择了junction point/交接点/结点https://blog.csdn.net/u010977122/article/details/86523123
Windows删除包含.pnpm的node_modules报错
rm : xxxxxx\node_modules.pnpm\xxxxxxx 是 NTFS 交接点。请使用 Force 参数删除或修改该对象。
请使用: rm -Force .\node_modules
项目同时npm+pnpm
同时存在package-lock.json和pnpm-lock.json
每次pnpm i的时候可以 :
使用npm i --package-lock-only只更新package-lock.json文件
npm ERR! code ERESOLVE npm ERR! ERESOLVE unable to resolve dependency tree
ERESOLVE与npm@7有关的问题很常见,因为npm7.x对某些事情比npm6.x更严格。
通常,最简单的解决方法是将--legacy-peer-deps标志传递给npm(e.g.,npm i --legacy-peer-deps),或者使用npm@6。
如果这不能立即起作用,也许可以先删除node_modules和package-lock.json。它们将被重新创建。
使用yarn generate-lock-entry只更新yarn.lock.json文件
因为build打包的时候没有配置使用pnpm而是继续使用了npm
pnpm 项目的依赖治理方案
冗余依赖治理
遗留的未使用依赖、重复声明的依赖、过时的依赖版本
- 执行
pnpm why <package-name>
,用来找出项目中一个特定的包被谁所依赖,给出包的依赖来源。 - 全局搜索包名,检查是否有被引入。
- 了解包的作用,判断项目中是否存在包的引用。
- 删除包,执行
pnpm i
后,分别运行、打包项目,查看是否有明显问题。
重叠依赖治理
对于 monorepo 而言,依赖的管理就比较复杂了,这边可以通过人肉 + 脚本的方式进行治理。按开奖
- 此部分网上资料不少,就不展开了
使用 pnpm 搭建 monorepo项目
Monorepo单一代码仓库
Monorepo 是一种项目开发与管理的策略模式,被称为"单一代码仓库"(Monolithic Repository)。在 Monorepo 模式中,所有相关的项目和组件都被存储在一个统一的代码仓库中,而不是分散在多个独立的代码仓库中,这些项目之间还可能会有依赖关系。
Monorepo开发模式优点
- 保留 multirepo 的主要优势
- 管理所有项目的版本控制更加容易和一致,降低了不同项目之间的版本冲突。
- 可以统一项目的构建和部署流程,降低了配置和维护多个项目所需的工作量。
- 代码复用
- 模块独立管理
- 分工明确,业务场景独立
- 代码耦合度降低
Monorepo开发模式缺点
- Monorepo 可能随着时间推移变得庞大和复杂,导致构建时间增长和管理困难,git clone、pull 的成本增加。
- 权限管理问题:项目粒度的权限管理较为困难,容易产生非owner管理者的改动风险。
- 解决方案:使用代码所有权文件指定、利用CI/CD流程设置访问控制权限、分支策略、Git钩子等等
步骤
**初始化项目:**通过
pnpm init
初始化项目,生成package.json
文件**配置工作空间:**在项目的根目录下创建
pnpm-workspace.yaml
文件,用于定义哪些目录下的包属于这个 monorepo:# pnpm-workspace.yaml packages: - 'packages/*' # 所有子包存放目录 - 'apps/*' # 应用程序目录
添加子项目:
在子包存放目录
packages/
目录下创建子项目,并初始化package.json
文件。例如,添加一个名为utils
的工具库:mkdir -p packages/utils cd packages/utils pnpm init -y
编辑
packages/utils/package.json
来指定其名称、版本和其他相关信息:{ "name": "utils", "version": "0.0.0", "main": "./index.ts", "module": "./index.ts" }
在
apps/
目录下执行pnpm init -y
初始化package.json
文件。
安装依赖:
- 公共依赖项,可以直接在根目录的
package.json
中添加,并使用-w
参数将它们安装到整个工作区范围内:pnpm add <dependency-name> -w
- 子项目依赖项,则可以在相应的子项目目录内执行安装命令,或者通过
--filter
参数指定要影响的包:pnpm --filter <package-name> add <dependency-name>
,例如:pnpm --filter apps add lodash
- 公共依赖项,可以直接在根目录的
**跨项目依赖:**为了让一个子项目依赖另一个子项目,可以在子项目的
package.json
中添加依赖声明。例如:从当前的工作区中查找最新的utils
版本:{ "dependencies": { "utils": "workspace:*" } }
**构建与运行脚本:**利用
pnpm recursive
来批量执行所有子项目的命令:
至此,最基础的基于pnpm的monorepo项目就搭建完成了
总结
- npm:Node.js的官方包管理工具,兼容性强,生态系统庞大,但安装速度较慢,依赖管理不够严格,存在重复依赖问题。
- yarn:由Facebook开发,旨在解决npm的性能和安全性问题,通过引入锁定文件确保安装一致性,提高了安装速度。
- pnpm:主打性能优化,通过硬链接和符号链接复用依赖,显著减少磁盘空间占用并提高安装速度,适合管理大量依赖的大型项目。
这三个包管理工具各有优劣,选择时需根据项目需求和团队习惯进行权衡。