npm 是 Node.JS 的包管理工具,除此之外,社区有一些类似的包管理工具如 yarn、pnpm 和 cnpm,以及集团内部使用的 tnpm。我们在项目开发过程中通常使用以上主流包管理器生成 node_modules 目录安装依赖并进行依赖管理。本文主要探究前端包管理器的依赖管理原理,希望对读者有所帮助。
当我们执行 npm install 命令后,npm 会帮我们下载对应依赖包并解压到本地缓存,然后构造 node_modules 目录结构,写入依赖文件。那么,对应的包在 node_modules 目录内部是怎样的结构呢,npm 主要经历了以下几次变化。
npm 最早的版本中使用了很简单的嵌套模式进行依赖管理。比如我们在项目中依赖了 A 模块和 C 模块,而 A 模块和 C 模块依赖了不同版本的 B 模块,此时生成的 node_modules 目录如下:


可以看到这种是嵌套的 node_modules 结构,每个模块的依赖下面还会存在一个 node_modules 目录来存放模块依赖的依赖。这种方式虽然简单明了,但存在一些比较大的问题。如果我们在项目中增加一个同样依赖 2.0 版本 B 的模块 D,此时生成的 node_modules 目录便会如下所示。虽然模块 A、D 依赖同一个版本 B,但 B 却重复下载安装了两遍,造成了重复的空间浪费。这便是依赖地狱问题。
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
├── C@1.0.0
│ └── node_modules
│ └── B@2.0.0
└── D@1.0.0
└── node_modules
└── B@1.0.0
一些著名的梗图:


npm v3 完成重写了依赖安装程序,npm3 通过扁平化的方式将子依赖项安装在主依赖项所在的目录中(hoisting 提升),以减少依赖嵌套导致的深层树和冗余。此时生成的 node_modules 目录如下:


为了确保模块的正确加载,npm 也实现了额外的依赖查找算法,核心是递归向上查找 node_modules。在安装新的包时,会不停往上级 node_modules 中查找。如果找到相同版本的包就不会重新安装,在遇到版本冲突时才会在模块下的 node_modules 目录下存放该模块子依赖,解决了大量包重复安装的问题,依赖的层级也不会太深。
扁平化的模式解决了依赖地狱的问题,但也带来了额外的新问题。
幽灵依赖主要发生某个包未在 package.json 中定义,但项目中依然可以引用到的情况下。考虑之前的案例,它的 package.json 如右图所示。


在 index.js 中我们可以直接 require A,因为在 package.json 声明了该依赖,但是,我们 require B 也是可以正常工作的。
var A = require('A');
var B = require('B'); // ???
因为 B 是 A 的依赖项,在安装过程中,npm 会将依赖 B 平铺到 node_modules 下,因此 require 函数可以查找到它。但这可能会导致意想不到的问题:

考虑在项目中继续引入的依赖 2.0 版本 B 的模块 D 与而 1.0 版本 B 的模块 E,此时无论是把 B 2.0 还是 1.0 提升放在顶层,都会导致另一个版本存在重复的问题,比如这里重复的 2.0。此时就会存在以下问题:
在前端包管理的背景下,确定性指在给定 package.json 下,无论在何种环境下执行 npm install 命令都能得到相同的 node_modules 目录结构。然而 npm v3 是不确定性的,它 node_modules 目录以及依赖树结构取决于用户安装的顺序。
考虑项目拥有以下依赖树结构,其 npm install 产生的 node_modules 目录结构如右图所示。


假设当用户使用 npm 手动升级了模块 A 到 2.0 版本,导致其依赖的模块 B 升级到了 2.0 版本,此时的依赖树结构如下。


此时完成开发,将项目部署至服务器,重新执行 npm install,此时提升的子依赖 B 版本发生了变化,产生的 node_modules 目录结构将会与用户本地开发产生的结构不同,如下图所示。如果需要 node_modules 目录结构一致,就需要在 package.json 修改时删除 node_modules 结构并重新执行 npm install。


在 npm v5 中新增了 package-lock.json。当项目有 package.json 文件并首次执行 npm install 安装后,会自动生成一个 package-lock.json 文件,该文件里面记录了 package.json 依赖的模块,以及模块的子依赖。并且给每个依赖标明了版本、获取地址和验证模块完整性哈希值。通过 package-lock.json,保障了依赖包安装的确定性与兼容性,使得每次安装都会出现相同的结果。
考虑上文案例,初始时安装生成 package-lock.json 如左图所示,depedencies 对象中列出的依赖都是提升的,每个依赖项中的 requires 对象中为子依赖项。此时更新 A 依赖到 2.0 版本,如右图所示,并不会改变提升的子依赖版本。因此重新生成的 node_modules 目录结构将不会发生变化。


依赖版本兼容性就不得不提到 npm 使用的 SemVer 版本规范,版本格式如下:

在使用第三方依赖时,我们通常会在 package.json 中指定依赖的版本范围,语义化版本范围规定:
语义化版本规则定义了一种理想的版本号更新规则,希望所有的依赖更新都能遵循这个规则,但是往往会有许多依赖不是严格遵循这些规定的。因此一些依赖模块子依赖不经意的升级,可能就会导致不兼容的问题产生。因此 package-lock.json 给每个模块子依赖标明了确定的版本,避免不兼容问题的产生。
Yarn
Yarn 是在 2016 年开源的,yarn 的出现是为了解决 npm v3 中的存在的一些问题,那时 npm v5 还没发布。Yarn 被定义为快速、安全、可靠的依赖管理。
Yarn 生成的 node_modules 目录结构和 npm v5 是相同的,同时默认生成一个 yarn.lock 文件。对于上文例子,生成的 yarn.lock 文件如下:
A@^1.0.0:
version "1.0.0"
resolved "uri"
dependencies:
B "^1.0.0"
B@^1.0.0:
version "1.0.0"
resolved "uri"
B@^2.0.0:
version "2.0.0"
resolved "uri"
C@^2.0.0:
version "2.0.0"
resolved "uri"
dependencies:
B "^2.0.0"
D@^2.0.0:
version "2.0.0"
resolved "uri"
dependencies:
B "^2.0.0"
E@^1.0.0:
version "1.0.0"
resolved "uri"
dependencies:
B "^1.0.0"
可以看到 yarn.lock 使用自定义格式而不是 JSON,并将所有依赖都放在顶层,给出的理由是便于阅读和审查,减少合并冲突。
在 Yarn 的 2.x 版本重点推出了 Plug'n'Play(PnP)零安装模式,放弃了 node_modules,更加保证依赖的可靠性,构建速度也得到更大的提升。
因为 Node 依赖于 node_modules 查找依赖,node_modules 的生成会涉及到下载依赖包、解压到缓存、拷贝到本地文件目录等一系列重 IO 的操作,包括依赖查找以及处理重复依赖都是非常耗时操作,基于 node_modules 的包管理器并没有很多优化的空间。因此 yarn 反其道而行之,既然包管理器已经拥有了项目依赖树的结构,那也可以直接由包管理器通知解释器包在磁盘上的位置并管理依赖包版本与子依赖关系。
执行 yarn --pnp 模式即可开启 PnP 模式。在 PnP 模式,yarn 会生成 .pnp.cjs 文件代替 node_modules。该文件维护了依赖包到磁盘位置与子依赖项列表的映射。同时 .pnp.js 还实现了 resolveRequest 方法处理 require 请求,该方法会直接根据映射表确定依赖在文件系统中的位置,从而避免了在 node_modules 查找依赖的 I/O 操作。

pnp 模式优缺点也非常明显:
pnpm1.0 于 2017 年正式发布,pnpm 具有安装速度快、节约磁盘空间、安全性好等优点,它的出现也是为了解决 npm 和 yarn 存在的问题。
因为在基于 npm 或 yarn 的扁平化 node_modules 的结构下,虽然解决了依赖地狱、一致性与兼容性的问题,但多重依赖和幽灵依赖并没有好的解决方式。因为在不考虑循环依赖的情况下,实际的依赖结构图为有向无环图(DAG),但是 npm 和 yarn 通过文件目录和 node resolve 算法模拟的实际上是有向无环图的一个超集(多出了很多错误祖先节点和兄弟节点之间的链接),这导致了很多的问题。pnpm 也是通过硬链接与符号链接结合的方式,更加精确的模拟 DAG 来解决 yarn 和 npm 的问题。
硬链接(hard link) 节约磁盘空间
硬链接可以理解为源文件的副本,使得用户可以通过不同的路径引用方式去找到某个文件,他和源文件一样的大小但是事实上却不占任何空间。pnpm 会在全局 store 目录里存储项目 node_modules 文件的硬链接。硬链接可以使得不同的项目可以从全局 store 寻找到同一个依赖,大大节省了磁盘空间。
软链接可以理解为快捷方式,pnpm 在引用依赖时通过符号链接去找到对应磁盘目录(.pnpm)下的依赖地址。考虑在项目中安装依赖于 foo 模块的 bar 模块,生成的 node_modules 目录如下所示。


可以看到 node_modules 下的 bar 目录下并没有 node_modules,这是一个符号链接,实际真正的文件位于.pnpm 目录中对应的 <package-name>@version/node_modules/<package-name> 目录并硬链接到全局 store 中。而 bar 的依赖存在于.pnpm 目录下 <package-name>@version/node_modules 目录下,而这也是软链接到 <package-name>@version/node_modules/<package-name> 目录并硬链接到全局 store 中。
而这种嵌套 node_modules 结构的好处在于只有真正在依赖项中的包才能访问,避免了使用扁平化结构时所有被提升的包都可以访问,很好地解决了幽灵依赖的问题。此外,因为依赖始终都是存在 store 目录下的硬链接,相同的依赖始终只会被安装一次,多重依赖的问题也得到了解决。
官网上的这张图清晰地解释了 pnpm 的依赖管理机制

看起来 pnpm 似乎很好地解决了问题,但也存在一些局限。
cnpm 和 tnpm

cnpm 是由阿里维护并开源的 npm 国内镜像源,支持官方 npm registry 的镜像同步。tnpm 是在 cnpm 基础之上,专为阿里巴巴经济体的同学服务,提供了私有的 npm 仓库,并沉淀了很多 Node.js 工程实践方案。
cnpm/tnpm 的依赖管理是借鉴了 pnpm ,通过符号链接方式创建非扁平化的 node_modules 结构,最大限度提高了安装速度。安装的依赖包都是在 node_modules 文件夹以包名命名,然后再做符号链接到 版本号 @包名的目录下。与 pnpm 不同的是,cnpm 没有使用硬链接,也未把子依赖符号链接到单独目录进行隔离。


此外,tnpm 新推出的 rapid 模式使用用户态文件系统(FUSE)对依赖管理做了一些新的优化。FUST 类似于文件系统版的 ServiceWorker,通过 FUSE 可以接管一个目录的文件系统操作逻辑。基于此实现非扁平化的 node_modules 结构可以解决软链接的兼容性问题。限于篇幅原因这里不再详述,感兴趣可以移步真·深入浅出 tnpm rapid 模式 - 如何比 pnpm 快 10 秒。
通过上文探究的主流包管理器依赖管理机制,我们发现无论扁平化或非扁平化 node_modules 结构似乎都不完美,抛弃 node_modules 的 PnP 模式又不兼容当前 Node 的生态,无解。看起来似乎是 Node 与 node_modules 自身有点问题(?)。Node.JS 作者 Ryan 也在 JSConf 上承认 node_modules 是他对 Node 的十大遗憾之一,但已经无法挽回了,随后他推荐了自己的新作 Deno。那让我们看看 JS 的另一大运行时环境 Deno 是如何进行依赖管理的。

在 Deno 不使用 npm、package.json 以及 node_modules,而是将引入源、包名、版本号、模块名全部塞进了 URL 里,通过 URL 导入依赖并进行全局统一缓存,不仅节省了磁盘空间,也优化了项目结构。
import * as log from "https://deno.land/std@0.125.0/log/mod.ts";
因此 Deno 中没有包管理器的概念,对于项目中的依赖管理,Deno 提供了这样一种方案。由开发者创建 dep.ts ,此文件中引用了所有必需的远程依赖关系,并且重新导出了所需的方法和类。本地模块从 dep.ts 统一导入所需方法和类,避免单独使用 URL 导入外部依赖可能造成的不一致的问题。
// dep.ts
export {
assert,
assertEquals,
assertStringIncludes,
} from "https://deno.land/std@0.125.0/testing/asserts.ts";
// index.ts
import { assert } from './dep.ts';
Deno 处理依赖的方式虽然解决了 node_modules 带来的种种问题,但目前体验也并不是很好。首先 URL 引入依赖的方式写法比较冗余繁琐,直接引用网络上文件的安全性也值得商榷;而且需要开发者手动维护 dep.ts 文件,依赖来源不清晰,依赖变更还需要更改引入依赖的本地文件;此外,依赖包的生态也远远不及 Node。
但 Deno 确实提供了另外一种思路,Node 的包管理器似乎只是安装依赖、生成 node_modules 的“纯工具人”,真正查找 resolve 依赖的逻辑还是在 Node 做的,所以包管理器层面也没有太多优化的空间。Yarn 的 Pnp 模式曾试图改变包管理器的地位,但也不敌强大的 Node 生态。因此 Deno 重启炉灶,将 intall 和 resolve 依赖过程合并,多余的 node_modules 与包管理器也就没什么存在的必要了。只是 Deno 当前的方式还不够成熟,期待后续的演进。
虽然目前还没有完美的依赖管理方案,但纵观包管理器的历史发展,是库与开发者互相学习和持续优化的过程,并且都在不断推动着前端工程化领域的发展,我们期待未来会出现更好的解决方案。

