导航
作者:藤椒金汤力链接:https://juejin.cn/post/7245201923506094140来源:稀土掘金著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
这篇文章主要是对 npm 的深入解析,2010 年 1 月,一款名为 npm 的包管理器诞生,时至今日,npm 已经从前端领域最早、而且最初只是为 Node.js 设计的包管理器演变成目前最大、生态最为健全的现代包管理工具。
那么,他的架构是什么呢?他的嵌套结构是如何发生的呢?他的缓存机制是什么呢?在执行 npm install 的过程中又发生了什么呢?这里对它的架构及npm install 包安装原理进行一个深入解析。
在官方文档中,npm 包含 3 个组成部分:
具体如下图所示:

从产品功能看,npm 包括 npm 网站、npm 源和 npm CLI 三个部分;从软件架构看,npm 又可以分为客户端和服务端两块。
npm 源可以大致分为官方源、镜像源和私有源三种。对应的 npm 网站为官方网站、镜像网站和私有网站。不同的 npm 源的路径是相同的,域名是不同的,所以一般用域名来指代某个特定的 npm 源。通过配置 registry 选项为各种 npm 源域名,npm cli 可以在不同的 npm 源之间无缝切换。
三种源之间的关系如下:

三种源示例如下:
| 分类 | npm 源 | npm 网站 |
|---|---|---|
| 官方域名 | registry.npmjs.org | npmjs.org |
| 国内镜像域名 | r.cnpmjs.org | cnpmjs.org |
| 淘宝镜像域名 | registry.npm.taobao.org | npm.taobao.org |
| 字节跳动私有域名 | bnpm.byted.org | web-bnpm.byted.org |
npm 官方源即 npm 官方提供的 npm 源。
由于官方源只有一个,全世界都要访问,网络链路长,并发量大,访问速度很难得到保证。由于官方源使用了 CDN 技术,整体访问速度还是可以的。为了提升访问速度,国内就出现了一些镜像源。镜像源与官方源一样,都是可以公开访问的。镜像源会定时从官方源同步软件包数据,一般通过 npm replicate api 实现。注意,镜像源一般只同步软件包数据,不同步用户数据等其他数据,也不维护登录态;镜像源一般只能读,不能写,比如不能发布软件包。因此,镜像源一般只实现软件包读取相关的 npm registry api。
在使用 npm 的过程中,大公司一般希望自己的软件包不对外开放。为了达到这个目的,需要实现一个 npm 私有源。npm 私有源不仅要完成对软件包等数据的存储,还需要实现一套完整的 npm registry api。公司成员在使用 npm 私有源时,也希望可以访问公有源中的软件包。所以,npm 私有源一般会定时从 npm 公有源或 npm 镜像源同步软件包数据。另外,大公司一般会开发一个与 npm 私有源对应的 npm 私有网站。
npm 私有源几乎拥有 npm 公有源的全部功能。npm 私有源和 npm 公有源是完全独立的。为了便于管理,npm 私有源一般使用私有的用户鉴权系统。因此,两种源之间的登录态是完全独立的。如果希望发布在 npm 私有源上的软件包被外部用户访问,必需重新发布到 npm 官方源。
除了 npm 源可以换,npm CLI 也是可以换的。Yarn 是一个后起之秀。Yarn 不仅支持 npm 源,还支持 Bower 源。相比 npm 官方 CLI: npm/cli, Yarn 更年轻,得到的关注更多,发展得也更快。Yarn 的优化特性反过来推动了 npm/cli 的发展。
当然,这些优化特性大部分在依赖包数目比较大时才能体现出来。

npm 在最早期的 v1/v2 中的工作模式跟现在有很大区别,最主要的就是 node_modules 的目录管理,它采用了一种最直接的嵌套式结构,以下面的依赖关系为例:
Root -> A -> B
-> C -> B
项目依赖于包 A 和 C,并且 A 和 C 都依赖于包 B,这个时候项目的 node_modules 目录结构如下图所示:

如上图所示应用中 node_modules 的目录是层层嵌套的,这样的目录结构其实很符合我们的直觉,依赖包的安装和目录结构都十分清晰且可预测,但是却引来两个极为严重的问题:
在上面的示例中就可以很明显的看出来,B 包被 A 依赖,同时也被 C 所依赖,因此 B 包就分别在 A 和 C 之下分别被安装了一次。此种设计结构直接导致了node_modules 体积过度膨胀,这也是臭名昭著的 Dependency Hell 问题。

假设上面的示例中 B 包还依赖于 D 包,D 包还依赖于 E 包这样 node_modules 的目录结构会一直持续下去直到最终到某个没有其他依赖的包为止,因此稍微复杂一点的项目 node_modules 目录的嵌套层级往往会非常深。而 Windows 以及一些应用工具无法处理超过 260 个字符的文件和文件夹路径,嵌套层级过深则会导致相应包的路径名很容易就超出了能处理的范围 ,因此会导致一系列问题。比如在想删除相应的依赖包时,系统就无法处理了。(详情可以查看:Node's nested node_modules approach is basically incompatible with Windows)
除此之外,npm 还存在一些问题被人诟病:
因此面对上述问题,特别是依赖包重复安装,经过社区的反复讨论,npm v3 几乎进行了重写。
针对 npm v1/v2 暴露的问题在 npm v3 中提出的解决方案就是扁平化,上面的示例在 npm v3 扁平化改造之后变得完全不同:

npm v3 在处理 A 的依赖 B 时,会根据扁平化的核心 hoist 机制会将其提升到顶级依赖,然后再处理 C 包,然后发现 C 依赖的 B 包已经被安装了,就不用再重复安装了。
看起来这种机制极大程度上解决了 npm v1/v2 重复安装与嵌套层级过深的问题,但它实际上依然不是完美方案,依然存在如下问题:
幽灵依赖,指的是业务代码中能够引用到 package.json 指定依赖以外的包。拿上面提到过得依赖关系为例:

package.json 中实际只写明了项目依赖 A包 和 C 包 ,但是由于 hoist 机制,B包 被提升到了 node_modules的第一层目录中,那么依照 node 依赖查找的方式,在我们的业务代码中是可以直接引用 B 包的。虽然乍一看也没有比较大的问题,但是 B 的版本管理是不在我们的感知之内的。也许某个时期使用了 B 包的某个方法看起来没有什么问题,等到下次 A 包有更新,相应的 A 包引用的 B版本也有了 breaking change 的更新,那么我们在原本代码中使用 B 的方法可能就出现报错。
为了说明这个问题我们在上面的实例引入版本,具体依赖关系如下所示:
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,中文一般称为“依赖分身”,也被叫做 “双胞胎陌生人”问题。
先不考虑依赖分身的现象,可以转过来思考一下 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