关于依赖管理的真相 — 前端包管理器探究

441次阅读  |  发布于2年以前

前言

npm是Node.JS的包管理工具,除此之外,社区有一些类似的包管理工具如yarn、pnpm和cnpm,以及集团内部使用的tnpm。我们在项目开发过程中通常使用以上主流包管理器生成node_modules目录安装依赖并进行依赖管理。本文主要探究前端包管理器的依赖管理原理,希望对读者有所帮助。

npm

当我们执行npm install命令后,npm会帮我们下载对应依赖包并解压到本地缓存,然后构造node_modules 目录结构,写入依赖文件。那么,对应的包在node_modules目录内部是怎样的结构呢,npm主要经历了以下几次变化。

1、npm v1/v2 依赖嵌套

npm最早的版本中使用了很简单的嵌套模式进行依赖管理。比如我们在项目中依赖了A模块和C模块,而A模块和C模块依赖了不同版本的B模块,此时生成的node_modules目录如下:

依赖地狱(Dependency Hell)

可以看到这种是嵌套的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

一些著名的梗图:

2、npm v3 扁平化

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

为了确保模块的正确加载,npm也实现了额外的依赖查找算法,核心是递归向上查找node_modules。在安装新的包时,会不停往上级node_modules中查找。如果找到相同版本的包就不会重新安装,在遇到版本冲突时才会在模块下的 node_modules 目录下存放该模块子依赖,解决了大量包重复安装的问题,依赖的层级也不会太深。

扁平化的模式解决了依赖地狱的问题,但也带来了额外的新问题。

幽灵依赖(Phantom dependency)

幽灵依赖主要发生某个包未在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函数可以查找到它。但这可能会导致意想不到的问题:

多重依赖(doppelgangers)

考虑在项目中继续引入的依赖2.0版本B的模块D与而1.0版本B的模块E,此时无论是把B 2.0还是1.0提升放在顶层,都会导致另一个版本存在重复的问题,比如这里重复的2.0。此时就会存在以下问题:

不确定性(Non-Determinism)

在前端包管理的背景下,确定性指在给定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。

3、npm v5 扁平化+lock

在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目录结构将不会发生变化。

兼容性

语义化版本(Semantic Versioning)

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

在使用第三方依赖时,我们通常会在package.json中指定依赖的版本范围,语义化版本范围规定:

语义化版本规则定义了一种理想的版本号更新规则,希望所有的依赖更新都能遵循这个规则,但是往往会有许多依赖不是严格遵循这些规定的。因此一些依赖模块子依赖不经意的升级,可能就会导致不兼容的问题产生。因此package-lock.json给每个模块子依赖标明了确定的版本,避免不兼容问题的产生。

Yarn

Yarn 是在2016年开源的,yarn 的出现是为了解决 npm v3 中的存在的一些问题,那时 npm v5 还没发布。Yarn 被定义为快速、安全、可靠的依赖管理。

1、Yarn v1 lockfile

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 lock vs. npm lock

2、Yarn v2 Plug'n'Play

在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模式优缺点也非常明显:

pnpm

pnpm1.0于2017年正式发布,pnpm具有安装速度快、节约磁盘空间、安全性好等优点,它的出现也是为了解决npm和yarn存在的问题。

因为在基于npm或yarn的扁平化node_modules的结构下,虽然解决了依赖地狱、一致性与兼容性的问题,但多重依赖和幽灵依赖并没有好的解决方式。因为在不考虑循环依赖的情况下,实际的依赖结构图为有向无环图(DAG),但是npm和yarn通过文件目录和node resolve算法模拟的实际上是有向无环图的一个超集(多出了很多错误祖先节点和兄弟节点之间的链接),这导致了很多的问题。pnpm也是通过硬链接与符号链接结合的方式,更加精确的模拟DAG来解决yarn和npm的问题。

1、非扁平化的node_modules

硬链接(hard link)节约磁盘空间

硬链接可以理解为源文件的副本,使得用户可以通过不同的路径引用方式去找到某个文件,他和源文件一样的大小但是事实上却不占任何空间。pnpm 会在全局 store 目录里存储项目 node_modules 文件的硬链接。硬链接可以使得不同的项目可以从全局 store 寻找到同一个依赖,大大节省了磁盘空间。

符号链接(symbolic link)创建嵌套结构

软链接可以理解为快捷方式,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的依赖管理机制

2、局限性

看起来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 秒。

其他

Deno

通过上文探究的主流包管理器依赖管理机制,我们发现无论扁平化或非扁平化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当前的方式还不够成熟,期待后续的演进。

结语

虽然目前还没有完美的依赖管理方案,但纵观包管理器的历史发展,是库与开发者互相学习和持续优化的过程,并且都在不断推动着前端工程化领域的发展,我们期待未来会出现更好的解决方案。

参考

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8