npm的发展历史
npm早期版本:递归
在早期的npm版本(npm v2)中,npm 处理依赖的方式简单粗暴,以递归的形式,严格按照 package.json 结构以及子依赖包的 package.json 结构将依赖安装到他们各自的 node_modules 中,直到有子依赖包不在依赖其他模块。
这样造成的后果是,多个包之间可能会出现同样的递归依赖,如果项目过大,那么必然会形成一棵巨大的依赖树,依赖包会出现重复,形成嵌套地狱。
"嵌套地狱"的缺点:
- 首先,项目的依赖树的层级过于深,如果有问题不利于排查和调试
- 在依赖的分支中,可能会出现同样版本的相互依赖的问题
这样的重复安装依赖包会带来什么后果呢?
- 首先,会使得安装的结果占据了大量的空间资源,造成了资源的浪费
- 同时,因为安装的依赖重复,会造成在安装依赖时,安装时间过长
- 在 Windows 系统中,文件路径最大长度为260个字符,嵌套层级过深可能导致不可预知的问题
(例如在
windows系统下删除node_modules文件,出现删除不掉的情况).
npm在 3.X版本扁平结构
为了解决以上递归管理依赖带来的问题,npm在 3.X版本里做了一次更新,引入了扁平化管理(dedupe)的方式。dedupe是dedeplicated的缩写,即duplicates were remove,把重复的移除。
扁平化管理的思路:
-
首先遍历package.json文件下
dependencies和devDependencies字段里的依赖,作为依赖树的根节点; -
在每个根节点依赖下面都会有其依赖的依赖,作为其子节点;
-
npm开启多进程从每个根节点开始逐步往下寻找更深层次的节点。
-
安装模块时,不管其是直接依赖还是子依赖的依赖,优先将其安装在 node_modules 根目录。在遍历这些依赖的时候,如果发现有重复的依赖模块就直接将其丢弃。
重复:模块名相同且semantic version兼容; 这里的兼容,是指语义化版本都会有一段版本允许范围,如果两个依赖的版本号是在这个范围交际里就说明是兼容; 比如依赖X依赖于依赖Y@^1.0.0,而依赖Z依赖于依赖Y@^1.1.0,则Y@^1.1.0就是兼容版本
看起来这种机制极大程度上解决了 npm v1/v2 重复安装与嵌套层级过深的问题,但它实际上依然不是完美方案,依然存在如下问题:
1、幽灵依赖(phantom dependencies)
幽灵依赖,指的是业务代码中能够引用到 package.json 指定依赖以外的包。
以上面提到过得依赖关系为例:
package.json中实际只写明了项目依赖 A包 和 C 包,但是由于 hoist 机制,B包 被提升到了node_modules的第一层目录中,那么依照 node 依赖查找的方式,在我们的业务代码中是可以直接引用 B 包的。虽然乍一看也没有比较大的问题,但是B 的版本管理是不在我们的感知之内的。也许某个时期使用了 B 包的某个方法看起来没有什么问题,等到下次 A 包有更新,相应的 A 包引用的 B版本也有了 breaking change 的更新,那么我们在原本代码中使用 B 的方法可能就出现报错。
2、依赖分身 (doppelgangers)
为了说明这个问题,以如下所示的依赖关系为例:
Root -> A_v1 -> B_v1
-> C_v1 -> B_v2
-> D_v1 -> B_v2
此时 node_modules 目录结构变为:
在依赖分析过程中,检查到 A v1 依赖了 B v1,因此将 B v1 提升到了顶层。再检查到 C v1 依赖了 B v2 时,发现顶层已经存在了 B v1,因此 B v2 无法提升到顶层,那么只能接着放在 C v1 之下,同样 D v1 依赖的 B v2 也只能放到 D v2 的下面。
C v1 和 D v1 的依赖都是 B v2 版本,不存在任何差别,但是却依然被重复安装了两遍,这个现象就叫做 doppelgangers,中文一般称为“依赖分身”,也被叫做 “双胞胎陌生人”问题。
3、依赖不幂等 (Non-Determinism)
先不考虑依赖分身的现象,可以转过来思考一下 B v2 明明有两个却没有提升到顶层,仍然还是 B v1 在顶层,是什么决定的这个关系呢。
安装顺序很重要
正常来说,如果是 package.json 里面写好了依赖包,那么 npm install 安装的先后顺序则由依赖包的字母顺序进行排序,那如果是使用 npm install 对每个包进行单独安装,那就看手动的安装顺序了。
这里网上大部分说法是这里的安装顺序主要是根据 package.json 里面的顺序,放在前面的包依赖的内容会被先提出来,实际上 npm 其实会调用一个叫做 localeCompare 的方法对依赖进行一次排序,实际上就是字典序在前面的 npm 包的底层依赖会被优先提出来。
如果是先安装的 C v1 ,然后再安装的 A v1,那么提升到顶层的就是 B v2 了。
如果情况再复杂一点,项目又依赖了 E v1 的包:
Root -> A_v1 -> B_v1
-> C_v1 -> B_v2
-> D_v1 -> B_v2
-> E_v1 -> B_v1
那么目录结构就会变成:
假设之后的迭代过程中, A v1 包被手动升级成 A v2,其依赖项变成了 B v2,那么本地的依赖树结构就变成了:
因为是直接升级的A版本,而不是删掉 node_modules 进行重新安装,而由于 E v1 存在,那么 B v1 不会被从 node_modules 中删掉,因此 A v2 的依赖包 B v2 仍然得不到提升,而是依然放在 A v2 之下。
但是,当这版代码上传到服务器上进行部署时,依赖进行重新安装,由于 A v2 的依赖会被最先安装,所以服务器上的依赖树结构则为如下:
因此可见,本来就因为 SemVer 机制导致的依赖不幂等问题被进一步放大了。
4、锁文件
npm v3中为了解决依赖不幂等的问题,提出了相应的解决方法的,那就是 npm-shrinkwrap.json 文件。
在 npm v3 版本中,需要手动运行 npm shrinkwrap 才会生成 npm-shrinkwrap.json 文件,之后每次改动版本依赖,都无法自动更新 npm-shrinkwrap.json 文件,仍然需要手动运行更新,因此这个特性对于开发者来说有一定的成本(开发者可能不知道该特性,或者没有每次及时更新)。
npm 5.X版本
受到 yarn.lock 的启发,npm 在 v5 版本中设计了我们现在比较熟悉的 package-lock.json 文件,此时锁文件就是自动生成和更新了。
在2016年 yarn 推出 yarn.lock 后 npm 在2017年才推出了 package-lock.json
yarn的出现
当npm处于v3时期的时候,一个叫yarn的包管理工具横空出世。它是一个由Facebook、Google、Exponent和Tilde构建的新的JavaScript包管理器。在2016年, npm还没有package-lock.json文件,安装的时候速度很慢,稳定性很差,yarn的出现很好的解决了一下的一些问题:
-
确定性: 通过yarn.lock等机制,即使是不同的安装顺序,相同的依赖关系在任何的环境和容器中,都可以以相同的方式安装。(那么,此时的npm v5之前,并没有package-lock.json机制,只有默认并不会使用的 npm-shrinkwrap.json)
-
采用模块扁平化的安装模式: 将不同版本的依赖包,按照一定的策略,归结为单个版本;以避免创建多个版本造成工程的冗余(目前版本的npm也有相同的优化)
-
网络性能更好:
yarn采用了请求排队的理念,类似于并发池连接,能够更好的利用网络资源;同时也引入了一种安装失败的重试机制 -
采用缓存机制,实现了离线模式 (目前的npm也有类似的实现)
npm的缓存机制
npm查看本地缓存的命令:
npm config get cache
从图中我们可以看出npm配置缓存的位置在 /Users/xxx/.npm(mac os 的默认的缓存的位置)当中。
npm cache 实现原理
pacote 子系统
pacote 子系统用于管理软件包的元数据,包括版本号、依赖关系、许可证信息等。
当 npm 需要下载或更新一个软件包时,它会首先调用 pacote 库来获取软件包的元数据。pacote 库通过与 npm registry 交互来获取软件包的元数据,包括软件包的名称、版本和依赖项。pacote 还负责解析软件包的依赖项,并在必要时递归下载这些依赖项。pacote 库在下载软件包时使用 HTTP 协议进行传输,支持压缩和断点续传功能。
在下载软件包之后,pacote会将其缓存到本地缓存目录 .npm/_cacache/content-v2 和 .npm/_cacache/index-v5 中。
- content-v2 目录用于存储已经下载的包的二进制文件。
- index-v5:该目录用于存储已经下载的包的元数据信息。
等到 npm 在执行安装时,可以根 package-lock.json 中存储的 integrity、version、name 生成一个唯一的 key 对应到 index-v5 目录下的缓存记录,从而找到 tar 包的 hash,然后根据 hash 再去找缓存的 tar 包直接使用。
make-fetch-happen 子系统
从 npm v5 开始,npm 的缓存子系统从 pacote 切换到了 make-fetch-happen。
在此之前,npm 使用 pacote 缓存已安装软件包的元数据和版本信息,而使用 npm-registry-fetch 进行网络请求。
而在 npm v5 中,npm 统一使用 make-fetch-happen 来处理网络请求和缓存,从而简化了 npm 的代码和架构。
npm install 执行的完整流程
-
检查依赖项:当用户执行 npm install 命令时,npm 会首先检查 package.json 文件中的依赖项,确定需要下载的包的名称和版本号。
-
解析依赖关系:npm 会根据依赖项,从 package.json 文件中解析出所有需要下载的包的名称、版本号和依赖关系,并生成依赖关系树(dependency tree)。
-
下载包:npm 会从 npm 仓库中下载需要的包,如果包已经存在于本地缓存中,则会直接从缓存中读取,避免重复下载。下载的包会被保存到本地的 .npm 目录中。
-
安装依赖:npm 会将下载的包解压缩,并将其安装到项目的 node_modules 目录中。在安装过程中,npm 会根据包的元数据文件 package.json 安装包的依赖项。如果有多个版本的包存在,npm 会根据语义化版本规则(Semver)选择合适的版本。
-
生成 shrinkwrap 文件:如果项目中存在 shrinkwrap 文件(npm-shrinkwrap.json 或 package-lock.json),npm 会根据安装的包信息生成新的 shrinkwrap 文件,以确保在后续安装过程中使用相同的版本。
-
执行预安装脚本:在包安装完成后,npm 会执行包中的预安装脚本(preinstall script),以完成一些必要的配置和初始化工作。
-
执行 postinstall 脚本:在预安装脚本执行完成后,npm 会执行包中的 postinstall 脚本,以完成一些额外的配置和初始化工作。
具体流程我们用下面的图更加直观的展示: