在现代前端开发中,包管理器是不可或缺的核心工具。随着 JavaScript 生态的快速发展,开发者经历了从 npm 一统天下到 Yarn 挑战格局,再到 pnpm 创新突破的技术演进。这里将对三种主流包管理器(npm/Yarn/pnpm)进行全方位对比,分析其设计原理、性能表现和适用场景。
剖析package.json
现代前端工程中不管使用那种打包工具,都一定有一个该项目的描述文件,这个文件就是package.json,这个文件有很多描述当前工程的字段,我将其汇总如下图展示:
如上面图示,必填的字段有name和version,这两个属性组成一个npm模块的唯一标识
npm install原理详解
npm的发展
npm@2
在早期的npm(npm2)安装依赖时,处理方式非常的简单粗暴,就是以递归的形式,严格按照package.json的结构以及子依赖包的package.json结构进行安装,直至子依赖包不在依赖其他模块。比如说有一个项目要-app依赖了两个模块moduleA 和moduleB,而模块A是一个纯js模块不依赖其他模块,但moduleB又依赖了moduleB1和moduleB2那最后的目录结构就是这样的:
这样的方式优点是层级结构明显,node_module的结构和package.json的目录结构一致,并且可以保证每次安装的目录结构都是一样的。但是如果我们的项目非常的庞大,依赖的模块很多很复杂,那么嵌套的层就会非常的深,如下图所示
除此之外,如果不同层级的模块依赖了相同的模块,就会导致大量的冗余。在windows系统中,文件路径的最大长度是260个字符,如果层级过深就会导致不可预知的问题。
npm@3
为了解决上面的问题,NPM在3.x版本中做了一次比较大的更新。将嵌套的结构改成扁平的结构,安装模块时不管是直接依赖还是子依赖的依赖,优先将其安装在node_module目录下。还是上面的例子,安装之后我们会得到下面的目录结构
如果此时我们有新的模块依赖了moduleB2的某个版本此时就会检查,如果当前的moduleB2的版本符合我们的要求就会跳过,否则就会在当前模块下的node_module下安装新的moduleB2的模块。如下图所示:
这样不仅没有完全解决老的问题(依赖还是会嵌套的越来越深)并且还会引入一个新的问题,由于在执行npm install
时是按照package.json
的依赖顺序依次解析的,如果moduleA
和moduleB
的解析顺序就决定了目录结构,如果此时先解析了moduleA
那么目录结构可能是这样的:
此外,我们在实际开发中有时为了使用最新版本只会锁定大的版本,这就导致某些依赖的小版本更新后也会造成依赖的改动,这种依赖结构的不确定性可能会给程序带来不可预知的问题。
npm@5+
为了解决npm install
不确定性的问题,在npm@5
的这个版本中新增了一个package-lock.json
文件,而安装的方式还是沿用了之前npm3
的扁平方式。但是因为有了package-lock.json
可以锁定依赖结构的,只要项目目录下有这个文件,每次执行npm install
后生成的node_module
结构一定都是相同的。
package.lock.json
的结构如下:
最外面的两个属性 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 中的依赖冲突之后,才会有这个属性。
其他npm6+
在这之后npm也一直在更新,比如npm6引入的npm audit,npm7的workspaces和peer依赖自动安装,这里汇总后续的每个npm的特点如下:
版本 | 最大特点 |
---|---|
npm 5 | 引入 package-lock.json,提升安装速度和稳定性 |
npm 6 | npm audit 引入安全性检查,支持 npm ci,提升性能 |
npm 7 | 引入工作空间支持、自动安装 peerDependencies,全新锁文件格式 |
npm 8 | 性能优化、错误处理改进、增强的安全性功能 |
npm 9 | 性能提升、增强的工作空间支持、npm exec 命令和更好的漏洞检测 |
使用建议
所以我们一般在项目应用开发中一般把package-lock.json
文件提交,固定项目的文件结构,以避免带来不必要的麻烦。
当我们开发一个npm
工具包时,我们开发的npm
包是要被其他应用系统集成进去的,就像上面我们说到的扁平机制,如果锁定了依赖包的版本就没有办法和其他模块的依赖包共享符合版本号的依赖了,就会造成不必要的冗余,所以此时不应该把package-lock.json
发布出去(npm默认也是不会吧package-lock.json
发布出去就是这个原因)
npm缓存机制
当我们执行npm install
或npm update
命令下载依赖后,如果这台机器第一次安装这个模块,除了会将依赖包安装在node_module
中之外,也会在本地的缓存目录中缓存一份。我们可以通过npm config get cache
查询缓存目录,比如在mac下可以看到如下:
在这个目录下又存在两个目录:content-v2、index-v5,content-v2 目录用于存储 tar包的缓存,而index-v5目录用于存储tar包的 hash。
npm也提供了一些命令来管理缓存数据
- npm cache add:官方解释说这个命令主要是 npm 内部使用,但是也可以用来手动给一个指定的 package 添加缓存;
- npm cache clean:删除缓存目录下的所有数据,为了保证缓存数据的完整性,需要加上 --force 参数;
- npm cache verify:验证缓存数据的有效性和完整性,清理垃圾数据;
基于缓存数据,npm 提供了离线安装模式,分别有以下几种: - –prefer-offline: 优先使用缓存数据,如果没有匹配的缓存数据,则从远程仓库下载;
- –prefer-online: 优先使用网络数据,如果网络数据请求失败,再去请求缓存数据,这种模式可以及时获取最新的模块;
- –offline: 不请求网络,直接使用缓存数据,一旦缓存数据不存在,则安装失败;
文件完整性
在我们下载依赖包结束时会进行文件完整性的校验,以此来确保我们下载的包是完整的。那这个过程是怎样的呢?
在下载依赖包之前,一般都会拿到npm对该依赖包计算的hash值,我们可以通过npm info
来查看某个文件包的hash
值,比如我们查看一下axios
的信息:
当用户下载完成时,会在本地计算一次文件的hash
值如果两个hash
值相同则说明下载的依赖包是完整的,如果不同就会重新下载。
完整流程
-
检查 .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 中的依赖有冲突;
- 如果没有冲突,直接跳过获取包信息、构建依赖树过程,开始在缓存中查找包信息,后续过程相同
整个过程如下图所示:
Yarn
yarn (Yet Another Resource Navigator)是 Facebook 开发的一个新的包管理器。它的开发是为了提供 NPM 当时缺乏的更高级的功能(例如版本锁定,但后续的功能npm都补充上了),同时也使其更安全、更可靠和更高效。
Yarn 现在更像是 NPM 的替代品,由于 Yarn 没有预装 Node.js,因此需要显式安装:
npm install yarn -g
yarn的特点:
- 即插即用:从 Yarn 版本 2 开始,不再使用 node_modules 文件夹。相反,它会生成一个映射项目依赖关系的 .pnp.cjs 文件。这会导致更优化的依赖树和更快的项目启动和包安装;
- 零安装:此功能与 Plug’n’Play 结合使用,后者使用.pnp.cjs 文件来映射离线缓存中的包。这使您可以快速检索和安装已保存的软件包;
- 检查器:Yarn 带有一个内置的检查器,用于下载和安装包;
yarn
在下载时使用了并行安装项目依赖,这在大型应用中的安装速度显著的快很多。
比如看一个简单的yarn4初始化的项目
可以看到这个项目中安装了axios
模块但没有使用node_module
进行管理,而是使用了一个.pnp.cjs的文件进行管理
Yarn 和 NPM 的区别
Yarn | NPM |
---|---|
使用 yarn add 命令来安装依赖项 | 使用 npm install 命令安装依赖项 |
使用并行安装依赖项 | 按顺序安装依赖项 |
使用yarn.lock作为锁定文件 | 使用package-lock.json作为锁定文件 |
支持即插即用功能,它会生成一个 .pnp.cjs 文件,其中包含项目的依赖关系图 | 不支持 |
在安装大文件时更快 | 安装大文件时更慢 |
支持零安装功能,允许您离线安装依赖项,几乎没有延迟 | 不支持 |
在下载包时,它会利用包的许可信息在后台运行安全检查,以避免下载危险的脚本或导致依赖问题 | 在 NPM 的早期版本中,安全性是一个主要问题。从版本 6 开始,每次安装包时,NPM 都会进行安全审计以避免漏洞并确保没有不兼容的依赖项 |
校验和验证包 | 使用存储在 package-lock.json 文件中的 SHA-512 进行验证 |
Yarn 也支持 NPM 创建的 package-lock.json 文件,方便将版本数据从 NPM 迁移到 Yarn
yarn和npm3+的问题
在npm@3+ 和 yarn中,通过扁平化处理,解决依赖无法被共用,依赖层级太深的问题,所有的依赖都被平铺在node_modules中的一级目录。但是多个版本的包只有一个(最先安装的一个) 被提升上来,其余版本的包还是会嵌套安装到各自的依赖当中,之前提到的路径过长和重复安装的问题没有彻底解决。除此之外还有幽灵依赖的问题。比如本地没有显实的依赖axios
这个模块,但是依赖了其他模块二其他模块依赖了axios
这个模块,这就导致也会将axios
模块安装到用户的node_module
下,这导致用户在代码中实际也能使用axios
模块的功能,显然这和我们的预期是不符合的。这就是幽灵依赖
Pnpm
pnpm,即performant npm,高性能的npm。相比起目前主流的包管理器,pnpm是速度快、节省磁盘空间的包管理器。
Pnpm性能对比
我们先看一下官网提供的性能对比数据:
可以看出,与目前主流的包管理器npm、yarn相比,无论有无cache、有无lockfile、有无node_modules,pnpm的安装速度都有明显的优势
Pnpm在磁盘中的存储
pnpm
之所以这么快的最主要的原因是,pnpm通过hard link(硬连接)机制,节省磁盘空间并提升安装速度。
hard link
(硬链接):多个文件名指向同一索引节点(Inode)。硬链接的作用 之一是允许一个文件拥有多个有效路径名,这样用户就可以建 立硬链接到重要的文件,以防止“误删”源数据
如果原始文件被删除,只要硬链接还存在,数据仍然可访问,文件并没有真正被删除。只有所有的硬链接都删除后,文件数据才会被清除
举个例子,如果有100各项目都依赖同一个三方模块,如果使用npm
或者是yarn
的话
那这个模块要被重复安装100次相同的“副本”。而使用 pnpm,package将被存放在一个统一的位置(如 Mac是 ~/.pnpm-store )。当安装软件包时,其包含的所有文件都会硬链接自此位置,而不会占用额外的硬盘空间。这让你可以在项目之间方便地共享相同版本的package。因此,不会有上面提到的重复安装相同包的问题了。
当我们使用pnpm
在本地一个项目安装axios
时可以发现有个reused字段表之前已经安装过相同的包了。
而当安装版本不同的同名软件包时,仅会添加版本之间不同 的文件到存储器中,而不会因为一个文件的修改而保存package的所有文件。
同时该命令提供了一个选项,使用方法为pnpm store prune,它提供了一种用于删除一些不被全局项目所引用到的package的功能。如果需要,开发者可以用这个命令来管理已有的package。
Pnpm对node_Module的优化
除了节省磁盘空间,pnpm还有另一个重要特点是,建立非扁平的node_modules目录,并在引用依赖的时候通过sybolic link机制找到对应.pnpm目录的地址。我们看一下使用pnpm
管理项目的node_module
的目录结构
可以看到在node_module
中都是平铺的形式管理三方依赖的模块,并且axios
和lodash
在node_module
中这两个文件夹的右侧都有一个小箭头,这是一个软连接实际是链接到了.pnpm下这样的路径:.pnpm中对应的模块。
sybolic link(软链接,也叫符号链接):类似于windows系统中的快捷方式,与硬链接不同,软链接就是一个 普通文件,只是数据块内容有点特殊,文件用户数据块中存放的内容是另一文件的路径名的指 向,通过这个方式可以快速定位到软连接所指向的源文件实体。
这样,pnpm 实现了相同模块不同版本之间隔离和复用。而且,node_modules目录是非扁平的,不存在由于提升带来的幽灵依赖问题。比如我们可以看在.pnpm
目录下的axios
和lodash
都有各自的node_module
小结
- pnpm 通过 hard link 在全局里面搞个 store 目录来存储 node_modules 依赖里面的 hard link 地址。这样节省了磁盘空间,并提升安装速度
- node_modules目录是非扁平的,在引用依赖的时候,则是通过 sybolic link 去找到对应虚拟磁盘目录下(.pnpm 目录)的依赖地址。这样,避免了之前npm和yarn扁平/非扁平安装带来的一系列问题;
整个流程如下图所示:
对比
特性 | npm | yarn | pnpm |
---|---|---|---|
安装方式 | 自动随 Node.js 安装 | 需要单独安装 | 需要单独安装 |
包缓存 | 支持缓存,但缓存机制较为简单 | 支持缓存,安装时从缓存中获取加速 | 支持强大的磁盘缓存机制,避免重复下载和浪费空间 |
安装速度 | 相对较慢,尤其在大量依赖时 | 更快,尤其是有缓存时 | 更快,采用硬链接方式减少重复依赖的存储 |
依赖管理 | 使用 node_modules 存储依赖 | 使用 node_modules 存储依赖,优化了依赖的管理方式 | 使用硬链接和符号链接来创建依赖树,提高存储效率 |
工作空间(Workspaces) | 从 npm 7 开始支持工作空间 | 原生支持工作空间,用于管理 monorepos | 支持工作空间,适用于 monorepos 项目 |
锁文件 | package-lock.json | yarn.lock | pnpm-lock.yaml |
安装依赖的精确性 | 有时依赖版本可能不一致(尤其在没有锁文件的情况下) | 确保锁文件的精确性,安装一致 | 高精度的依赖版本控制,确保每个开发环境一致 |
并发下载 | 支持并发下载,但效率较低 | 支持并发下载,速度更快 | 使用并发下载并且非常高效 |
兼容性 | 与大多数现有工具兼容 | 高度兼容 npm,支持较多工具 | 兼容 npm 和 Yarn,支持多种工具 |
安装的存储优化 | 存储空间相对浪费,所有模块都存储在 node_modules 下 | 有一定优化,减少重复存储 | 使用硬链接存储,节省磁盘空间,避免重复依赖存储 |
社区支持 | 最大,几乎所有 JavaScript 项目都支持 | 良好,尤其在 Facebook 和 JavaScript 社区中受欢迎 | 较新,但支持增长迅速,逐渐得到许多大项目的青睐 |
Monorepo项目选择包管理器
Monorepo的思想是严格统一,多个项目放在一个仓库里面。比较知名的像Antd、babel、npm7都是在用Monorepo方式进行管理的。Monorepo常见目录结构如下
- packages- project1- src- index.ts- package.json- project2- src- index.ts- package.json- project3- src- index.ts- package.json
- package.json
- tsconfig.json
这样有以下几点好处:
- 统一工作流:由于所有的项目放在一个仓库当中,复用起来非常方便,如果有依赖的代码变动,那么用到这个依赖的项目当中会立马感知到。并且所有的项目都是使用最新的代码,不会产生其它项目版本更新不及时的情况,对开发调试而言都带来了方便
- 降低基建成本:所有项目复用一套标准的工具和规范,无需切换开发环境,如果有新的项目接入,也可以直接复用已有的基建流程,比如 CI 流程、构建和发布流程。这样只需要很少的人来维护所有项目的基建,维护成本也大大减低
- 团队协作也更加容易 ,一方面大家都在一个仓库开发,能够方便地共享和复用代码,方便检索项目源码,另一方面,git commit 的历史记录也支持以功能为单位进行提交,之前对于某个功能的提交,需要改好几个仓库,提交多个 commit,现在只需要提交一次,简化了 commit 记录,方便协作
当我们开发维护一个monorepo
的项目时比较合适的包管理工具是pnpm
,因为pnpm
具有npm
和yarn
没有的独特优势,具体有以下几点:
4. 支持 Workspaces:pnpm 原生支持 workspaces,可以自动管理多个子项目的依赖,确保所有的 package 依赖关系正确无误。
- 高效的存储机制:pnpm 采用硬链接和符号链接来存储依赖,而不是像 npm 或 yarn 那样直接复制 node_modules,从而极大地节省了磁盘空间。例如:
如果多个 package 依赖相同的 react,pnpm 只会在全局存储中保存一份,而不同的 package 只会创建指向它的链接。
这样比 npm 和 yarn 节省磁盘空间,并且加快安装速度。 - 自动处理 package 之间的依赖:pnpm 能够自动解析 monorepo 内部的依赖,例如 package-a 依赖 package-b,pnpm 可以直接链接,而不需要手动指定路径。
避免了手动 npm link 或 yarn workspaces run 这样的复杂操作。 - 严格的依赖管理:pnpm 的 node_modules 采用严格模式,避免了 npm 和 yarn 可能导致的幽灵依赖问题(即 package.json 没有声明依赖但仍然可以使用)。
如何在 pnpm 中启用 Workspaces
需要使用pnpm
管理多包项目非常简单。只需要按照下面步骤即可
创建 pnpm Monorepo
在根目录下创建 pnpm-workspace.yaml:
packages:- "packages/*" # 这里会自动识别 packages 目录下的所有子项目
然后在 package.json 中启用 workspaces:
{"name": "my-monorepo","private": true,"devDependencies": {"pnpm": "^8.0.0"}
}
创建多个子项目
创建 packages/package-a/package.json:
{"name": "package-a","version": "1.0.0","main": "index.js","dependencies": {"lodash": "^4.17.21"}
}
创建 packages/package-b/package.json,并让 package-b 依赖 package-a:
{"name": "package-b","version": "1.0.0","main": "index.js","dependencies": {"package-a": "workspace:*"}
}
安装依赖
pnpm install
pnpm 会自动解析 workspace:* 依赖,确保 package-b 能够正确依赖 package-a。
依赖会被存储在 node_modules/.pnpm 下,并通过符号链接管理。
对比各包管理器在monorepo下的作用
特性 | pnpm workspaces | yarn workspaces | npm workspaces |
---|---|---|---|
存储方式 | 硬链接+符号链接(节省空间) | 直接安装在 node_modules 里 | 直接安装在 node_modules 里 |
安装速度 | 🚀 最快 | 中等 | 最慢 |
依赖隔离 | 严格(无幽灵依赖) | 可能有幽灵依赖 | 可能有幽灵依赖 |
支持 workspace:* 语法 | ✅ 支持 | ✅ 支持 | ✅ 支持 |
自动解析内部依赖 | ✅ 自动解析 | ✅ 自动解析 | ✅ 自动解析 |
适合 Monorepo | ✅ 非常适合 | ✅ 适合 | ✅ 适合 |
缓存机制 | 强大的缓存机制,避免重复安装依赖 | 默认缓存,效率较高 | 默认缓存,效率较低 |
依赖版本控制 | 高精度版本控制,严格管理依赖 | 高精度版本控制 | 高精度版本控制 |
硬链接优化 | ✅ 高效硬链接管理 | ❌ 不支持 | ❌ 不支持 |