包管理是前端开发必不可少的工具,当下主流包管理工具包括:
npm、yarn、pnpm。每个工具的诞生都是特定环境下的产物,用于帮助我们解决工作中的实际问题,后者站在巨人的肩膀上继续前进
一、npm - 最早的Node包管理工具
npm 作为Node内置包管理工具,出现就备受关注,成为前端开发离不开的利器之一
1. npm instal 做了什么?
npm install 用来给当前项目安装所需依赖包
检查配置: 读取 npm config 和 .npmrc 配置
.npmrc是有权重的:项目级 > 用户级 > 全局配置 > npm内置
确定依赖版本,构建依赖树: 检查是否存在 package-lock.json
存在进行版本比对,处理方式和npm版本有关,根据最新npm版本处理规则,版本能兼容按照 package-lock 版本安装 , 反之按照 package.json 版本安装
不存在根据 package.json 确定依赖包信息
检查缓存: 判断是否存在缓存
存在将对应缓存解压到 node_modules 下,生成 package-lock.json
不存在则下载资源包,验证包完整性并添加至缓存,之后解压到 node_modules 下,生成 package-lock.json
2. npm 如何进行依赖管理?
假设当前项目有两个依赖包A、B,且同时都依赖了某两个相同的依赖包C、D,其中D包被依赖版本有所不同
// package.json from my project
"dependencies": {
A: "1.0.0",
B: "1.0.0"
}
// package.json from package A
"dependencies": {
C: "1.0.0",
D: "1.0.0"
}
// package.json from package B
"dependencies": {
C: "1.0.0",
D: "2.0.0"
}
嵌套安装
在 npm 3.x 之前采用嵌套安装,通过递归的形式,根据依赖树的层级结构依次下载,在执行 npm install 后 node_modules 如下图所示
├── node_modules
│ ├── A@1.0.0
│ │ └── node_modules
│ │ │ └── C@1.0.0
│ │ │ └── D@1.0.0
│ ├── B@1.0.0
│ │ └── node_modules
│ │ │ └── C@1.0.0
│ │ │ └── D@2.0.0
对于这种安装方式很好的反映了依赖树的层级结构,且每次安装目录结构相同,但也存在如下缺陷:
- 依赖地狱,依赖太深导致路径过长,在
Windows系统中,文件路径最大长度为260个字符,嵌套层级过深可能导致不可预知的问题; - 同一个包被重复安装,导致
node_modules体积过大,比如上述中所说的C@1.0.0包同时被A、B所依赖,会分别在两者的node_modules中下载,导致重复安装;
扁平安装
为了解决上述弊病,从npm 3.x 之后改为扁平化安装,执行 npm install 后,无论是直接依赖还是子依赖,皆优先安装在 node_modules 根目录,安装到相同模块时,根据node require机制,会逐级往上寻找node_modules,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的node_modules下安装该模块。
基于上述结论我们知道了扁平化安装具有下列优势:
- 解决了包重复安装的问题;
- 依赖层级也不会太深;
但是,这种方式虽然解决了之前的问题,但是扁平化结构也衍生出了新的问题,其中D@1.0.0模块和D@2.0.0模块都有可能被优先安装在node_modules根目录下,如下图所示
// D@2.0.0 被优先安装
├── node_modules
│ ├── A@1.0.0
│ │ └── node_modules
│ │ │ └── D@1.0.0
│ ├── B@1.0.0
│ ├── C@1.0.0
│ ├── D@2.0.0
// D@1.0.0 被优先安装
├── node_modules
│ ├── A@1.0.0
│ ├── B@1.0.0
│ │ └── node_modules
│ │ │ └── D@2.0.0
│ ├── C@1.0.0
│ ├── D@1.0.0
我们得出如下结论:
-
依赖结构不确定性,每次执行
npm install后同一个子依赖包被优先安装版本可能不同; -
幽灵依赖导致非法访问,想必大家做项目开发的时候,都遇到过
package.json并没有直接依赖某个包,却可以被项目直接引用,这就是所谓的幽灵依赖,幽灵依赖可能导致依赖丢失或者版本兼容差异; -
分身依赖问题
- 不同版本的依赖被重复打包,增加了产物体积;
- 无法共享库实例,引用的得到的是两个独立的实例;
- 重复 TypeScript 类型,可能会造成类型冲突;
-
扁平化算法复杂性较高;
3. lockfile出现
从npm 5.x开始,执行npm install时会自动生成一个 package-lock.json 用于记录依赖树信息,它精确描述了node_modules 目录下所有包的树状结构
字段解释
package-lock.json包含了version、resolved、integrity、dev、requires、dependencies这几个字段
-
version:唯一版本号 -
resolved:包的安装源 -
integrity:用于验证包是否失效的完整性hash值,由两部分组成: 加密hash函数-摘要dgest,加密函数有两种sha512或者sha1,dgest等于base64(hashfn(content)) -
dev:是否为开发时依赖项 -
requires:当前包的dependencies依赖项 -
dependencies:当前包的node_modules依赖树(比如:某个子依赖包存在多版本时,当前包下生成的node_modules结构)验证资源完整性过程:
- 获取
integrity字段,得到加密hash算法fn和摘要dgest - 使用
fn对获取到的资源内容进行加密,然后对加密后的结果使用base64编码,得到摘要dgest2 - 如果
dgest===dgest2,证明资源没有被篡改
- 获取
不足之处
- 没有解决扁平化带来的算法复杂性、幽灵依赖等本质问题;
npm install时会拉取当前大版本下的最新依赖包,当依赖包有小版本更新时,导致协同开发者依赖树不一致,并造成团队协作时package-lock.jsongit冲突问题;
二、yarn - 推动包管理工具的步伐
yarn 的诞生一定程度上解决了历史上 npm 某些能力的不足,包括上述提到的依赖一致性、资源完整性,以及安装过程速度较慢等等问题。yarn 的出现也促进了 npm 继续前进的步伐
1. yarn install 执行过程?
执行 yarn install 后会经过五个阶段:
-
检查(checking) 检查系统运行环境,包括OS、CPU、engines等信息
-
解析包(resolving packages) 首先根据项目
package.json中dependencies、devDependencies、optionalDependencies字段形成首层依赖集合,之后对嵌套依赖逐级进行递归解析(将解析过和正在解析的包用一个 Set 数据结构来存储,保证同一个版本范围内的包不会被重复解析),结合 yarn.lock 和 Registry 获取包的具体版本、下载地址、hash值、子依赖等信息(过程中遵循依照 yarn.lock 优先原则)最终确定依赖版本信息、下载地址 -
获取包(fetching packages) 首先判断缓存目录中有没有缓存资源,其次读取文件系统,都不存在则从Registry进行下载
-
链接包(linking dependencies) 复制缓存至项目
node_modules目录首先解析
peerDependencies信息,之后基于扁平化原则(yarn扁平化规则不同于npm,使用频率较大的版本会安装到顶层目录,这个过程称为dedupe),从缓存复制依赖至当前项目node_modules目录, -
构建包(building fresh package) 依赖包存在二进制文件进行构建
这个过程会执行
install相关钩子,包括preinstall、install、postinstall相信大家都有安装
node-sass频繁报错的经历,Sass 引擎最初使用 Ruby 实现,所以需要 Ruby 作为运行环境支持,在WebAssembly、Dart没有兴起前,Node 执行原生代码需要node-gyp进行本地构建,它可以将 binding.node 格式的二进制文件构建为可被执行的代码。node-sass就需要对Saas二进制资源进行额外下载,并依赖node-gyp进行构建,具体历史不做赘述
2. 对比 npm
介绍完有关 npm、 yarn 的基础知识,我们对比二者之间使用差异,做一个直观的总结
-
采用扁平化结构,不同的是对于重复模块,yarn默认会基于使用频率决定谁该被安装在顶层目录(npm可以通过
npm dedupe实现该功能) -
lockfile文件,
package-lock.json自带版本锁定和依赖结构,一旦依赖改动影响范围较大,yarn.lock只包含版本锁定,并不确定依赖结构,需要结合package.json确定依赖结构 -
缓存机制
-
缓存结构
npm安装依赖后通常会在用户目录下.npm/_cacache里进行缓存,内容如下:content-v2存储tar包的缓存index-v5存储tar包的hash,根据lock文件中intergrity,version,name生成唯一key,对应该目录中缓存记录,从而找到tar包的hash
yarn缓存按照平铺形式生成,可以通过yarn cache dir查询缓存目录,内容包括压缩包、.yarn-metadata.json、解压文件、bin文件等,条目命名规则遵循{slug}/node_modules/{packageName},其中slug由版本、哈希值、uid构成 -
缓存命令
npm命令npm cache clean: 清除缓存, 为了保证缓存数据的完整性, 一般会加上--force参数npm cache verify: 验证缓存数据的有效性和完整性, 清理垃圾数据
yarn命令yarn cache clean: 清理缓存yarn cache ls: 列出当前缓存的包的列表yarn cache dir: 显示缓存的目录yarn config set yarn-offline-mirror ./npm-packages-offline-cache设置离线镜像目录,通过设置yarn config set yarn-offline-mirror-pruning true保持目录及时更新
-
缓存规则
npm和yarn提供以下模式--perfer-offline: 优先使用缓存, 如果没有则从远程仓库下载--perfer-online: 优先使用网络数据, 如果网络请求失败, 再使用缓存数据--offline: 不请求网络, 直接使用缓存数据, 一旦缓存不存在, 就安装失败
-
-
Workspace 概念,
yarn提出工作区的概念,更方便的实现Monorepo仓库管理,工作区内链接所有包方便直接调用,并统一维护依赖树,代码结构清晰、易于维护 -
包下载机制,
yarn缓存了每个下载过的包,再次使用时无需重复下载。 同时利用并行下载以最大化资源利用率,安装速度更快
三、pnpm - 更现代的包管理工具
pnpm 代表 performant(高性能的)npm,如pnpm 官方介绍,它是:速度快、节省磁盘空间的软件包管理器,可以看出pnpm 在解决依赖包的安装效率、节省磁盘空间利用都有显著提升,同时它也支持workspace满足Monorepo包管理方式。
我们可以先看下来自 pnpm benchmarks的对比数据,感受下pnpm带来的性能提升
| action | cache | lockfile | node_modules | npm | pnpm | Yarn | Yarn PnP |
|---|---|---|---|---|---|---|---|
| install | 48.4s | 14.7s | 16.6s | 23.1s | |||
| install | ✔ | ✔ | ✔ | 2s | 1.2s | 2.3s | n/a |
| install | ✔ | ✔ | 10.4s | 3.7s | 6.5s | 1.5s | |
| install | ✔ | 15.6s | 6.6s | 11.1s | 5.9s | ||
| install | ✔ | 27.8s | 12.6s | 11.6s | 17.1s | ||
| install | ✔ | ✔ | 2.5s | 2.8s | 6.8s | n/a | |
| install | ✔ | ✔ | 2s | 1.2s | 7.3s | n/a | |
| install | ✔ | 2.5s | 8s | 11.8s | n/a | ||
| update | n/a | n/a | n/a | 2s | 9.2s | 15.1s | 28.9s |
1.硬链接与软链接
pnpm基于CAS 内容寻址存储的方式管理依赖,pnpm 会将依赖储存在目录 ~/.pnpm-store 下,之后项目安装时pnpm会检查全局stroe目录下是否已经存在对应依赖,存在即创建从全局存储到项目 node_modules/.pnpm 文件夹的硬链接,硬链接指向磁盘上原始文件所在的同一位置,然后通过软链接的方式在node_modules下创建package.json指定的依赖。
node_modules
└─ .pnpm
└─ ...
└─ react
如上图,假设我们有一个项目依赖了react包,其中react是指向.pnpm 的软链接,.pnpm中每个文件都是来自于公共store的硬链接,链接到真实文件资源,打开目录你会发现它们都携带着各自的版本号,这样设计的目的是为了解决分身依赖的问题。
基于上述包管理方式,我们可以得出:
-
节省磁盘空间
当使用 npm 或 Yarn 时,如果你有 100 个项目使用了某个依赖(dependency),就会有 100 份该依赖的副本保存在硬盘上。 而在使用 pnpm 时,依赖会被存储在内容可寻址的存储中;
-
下载速度更快
基于硬链接与软链接的包管理方式,
pnpm可以更加高效快速的下载依赖包 -
避免幽灵依赖问题,访问更加安全
使用 npm 或 Yarn Classic 安装依赖项时,所有包都被提升到模块目录的根目录。 因此,项目可以访问到未被添加进当前项目的依赖。未在
package.json声明的依赖理论是无法访问的,它属于幽灵依赖,一旦使用后面可能产生不可估量的副作用,pnpm使用软链的方式将项目的直接依赖添加进模块文件夹的根目录,让包的使用更加安全可控;
2.Workspace
过去我们可能通过yarn wrokspace或者 Lerna 来管理 monorepo 仓库,pnpm 内置了对单体仓库的支持, 你可以创建一个 workspace 以将多个项目合并到一个仓库中。一个 workspace 的根目录下必须有 pnpm-workspace.yaml 文件, 也可能会有 .npmrc 文件。
-
workspace协议
默认情况下,如果可用的 packages 与已声明的可用范围相匹配,pnpm 将从工作区链接这些 packages。 例如,如果
bar中有"foo":"^1.0.0"的这个依赖项,则foo@1.0.0链接到bar。 但是,如果bar的依赖项中有"foo": "2.0.0",而foo@2.0.0在工作空间中并不存在,则将从 npm registry 安装foo@2.0.0。 这种行为带来了一些不确定性。幸运的是,pnpm 支持 workspace 协议
workspace:。 当使用此协议时,pnpm 将拒绝解析除本地 workspace 包含的 package 之外的任何内容。 因此,如果您设置为"foo": "workspace:2.0.0"时,安装将会失败,因为"foo@2.0.0"不存在于此 workspace 中。当 link-workspace-packages 选项 设置为
false时,这个协议尤其有用。 在这种情况下,仅当使用workspace:协议声明依赖,pnpm 才会从此 workspace 链接所需的包。 -
通过别名引用
假设你在 workspace 中有一个名为
foo的包, 通常你会像这样引用:"foo": "workspace:*"。如果要使用其他别名,那么以下语法也将起作用:
"bar": "workspace:foo@*"。在发布之前,别名被转换为常规名称。 上面的示例将变为:
"bar": "npm:foo@1.0.0"。 -
通过相对路径
假如 workspace 中有两个包:
+ packages + foo + barbar中可能有foo的依赖:"foo": "workspace:../foo", 在发布之前,这些将转换为所有包管理器支持的常规版本规范。 -
发布workspace
workspace 包打包到归档(无论它是通过
pnpm pack,还是pnpm publish之类的发布命令)时,我们将动态替换这些workspace:依赖:- 目标 workspace 中的对应版本(如果使用
workspace:*,workspace:~, orworkspace:^) - 相关的 semver 范围(对于任何其他范围类型)
看一个例子,假设我们的 workspace 中有
foo、bar、qar、zoo并且它们的版本都是1.5.0,如下:{ "dependencies": { "foo": "workspace:*", "bar": "workspace:~", "qar": "workspace:^", "zoo": "workspace:^1.5.0" } }将会被转化为:
{ "dependencies": { "foo": "1.5.0", "bar": "~1.5.0", "qar": "^1.5.0", "zoo": "^1.5.0" } }这个功能允许你发布转化之后的包到远端,并且可以正常使用本地 workspace 中的 packages,而不需要其它中间步骤。包的使用者也可以像常规的包那样正常使用,且仍然可以受益于语义化版本。
- 目标 workspace 中的对应版本(如果使用
3.与npm、yarn功能对比
通过下图我们可以清晰地看到pnpm与npm、yarn的差异
| 功能 | pnpm | Yarn | npm |
|---|---|---|---|
| 工作空间支持(monorepo) | ✔️ | ✔️ | ✔️ |
隔离的 node_modules | ✔️ - 默认 | ✔️ | ❌ |
提升的 node_modules | ✔️ | ✔️ | ✔️ - 默认 |
| Plug'n'Play | ✔️ | ✔️ - 默认 | ❌ |
| 零安装 | ❌ | ✔️ | ❌ |
| 修补依赖项 | ❌ | ✔️ | ❌ |
| 管理 Node.js 版本 | ✔️ | ❌ | ❌ |
| 有锁文件 | ✔️ - pnpm-lock.yaml | ✔️ - yarn.lock | ✔️ - package-lock.json |
| 支持覆盖 | ✔️ | ✔️ - 通过 resolutions | ✔️ |
| 内容可寻址存储 | ✔️ | ❌ | ❌ |
| 动态包执行 | ✔️ - 通过 pnpm dlx | ✔️ - 通过 yarn dlx | ✔️ - 通过 npx |
4.pnpm的局限
下面是来自pnpm官网的描述
npm-shrinkwrap.json和package-lock.json被忽略。 与 pnpm 不同,npm可以多次安装相同的name@version,并且具有不同的依赖项组合。 npm 的锁文件旨在反映平铺的node_modules布局,但是,由于 pnpm 默认创建隔离布局,它无法由 npm 的锁文件格式反映出来。 但是,如果您希望将锁定文件转换为 pnpm 的格式,请看 pnpm import。- Binstubs(在
node_modules/.bin中的文件)总是 shell 文件,而不是指向 JS 文件的符号链接。 创建 shell 文件是为了帮助支持插件的 CLI 的程序在特殊的node_modules结构中能够正确地找到它们的插件。 这是很少有的问题,如果您希望文件是 JS 文件,请直接引用原始文件,如 #736 所示。