前端开发包管理器探究

发布于 2023-10-08 12:40:58 字数 21522 浏览 24 评论 0

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 函数可以查找到它。但这可能会导致意想不到的问题:

  • 依赖不兼容:my-library 库中并没有声明依赖 B 的版本,因此 B 的 major 更新对于 SemVer 体系是完全合法的,这就导致其他用户安装时可能会下载到与当前依赖不兼容的版本。
  • 依赖缺失:我们也可以直接引用项目中 devDepdency 的子依赖,但其他用户安装时并不会 devDepdency,这就可能导致运行时会立刻报错。

多重依赖(doppelgangers)

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

  • 破坏单例模式:模块 C、D 中引入了模块 B 中导出的一个单例对象,即使代码里看起来加载的是同一模块的同一版本,但实际解析加载的是不同的 module,引入的也是不同的对象。如果同时对该对象进行副作用操作,就会产生问题。
  • types 冲突:虽然各个 package 的代码不会相互污染,但是他们的 types 仍然可以相互影响,因此版本重复可能会导致全局的 types 命名冲突。

不确定性(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 版本规范,版本格式如下:

  • 主版本号:不兼容的 API 修改
  • 次版本号:向下兼容的功能性新增
  • 修订号:向下兼容的问题修正


在使用第三方依赖时,我们通常会在 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

  • 文件格式不同,npm v5 使用的是 json 格式,yarn 使用的是自定义格式
  • package-lock.json 文件里记录的依赖的版本都是确定的,不会出现语义化版本范围符号(~ ^ *),而 yarn.lock 文件里仍然会出现语义化版本范围符号
  • package-lock.json 文件内容更丰富,实现了更密集的锁文件,包括子依赖的提升信息
    • npm v5 只需要 package.lock 文件就可以确定 node_modules 目录结构
    • yarn.lock 无法确定顶层依赖,需要 package.json 和 yarn.lock 两个文件才能确定 node_modules 目录结构。node_modules 目录中 package 的位置是在 yarn 的内部计算出来的,在使用不同版本的 yarn 时可能会引起不确定性。

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

  • 优:摆脱 node_modules,安装、模块速度加载快;所有 npm 模块都会存放在全局的缓存目录下,避免多重依赖;严格模式下子依赖不会提升,也避免了幽灵依赖(但这可能会导致某些包出现问题,因此也支持了依赖提升的宽松模式:<)。
  • 缺:自建 resolver 处理 Node require 方法,执行 Node 文件需要通过 yarn node 解释器执行,脱离 Node 现存生态,兼容性不太好

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 似乎很好地解决了问题,但也存在一些局限。

  • 忽略了 package-lock.json。npm 的锁文件旨在反映平铺的 node_modules 布局,但是 pnpm 默认创建隔离布局,无法由 npm 的锁文件格式反映出来,而是使用自身的锁文件 pnpm-lock.yaml。
  • 符号链接兼容性。存在符号链接不能适用的一些场景,比如 Electron 应用、部署在 lambda 上的应用无法使用 pnpm。
  • 子依赖提升到同级的目录结构,虽然由于 Node.js 的父目录上溯寻址逻辑,可以实现兼容。但对于类似 Egg、Webpack 的插件加载逻辑,在用到相对路径的地方,需要去适配。
  • 不同应用的依赖是硬链接到同一份文件,如果在调试时修改了文件,有可能会无意中影响到其他项目。

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 当前的方式还不够成熟,期待后续的演进。

结语

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

参考

  • node_modules 困境(https://zhuanlan.zhihu.com/p/137535779)npm:
  • How Npm Works(https://npm.github.io/how-npm-works-docs/index.html)
  • Yarn: Plug'n'Play(https://yarnpkg.com/features/pnp)
  • pnpm: 基于符号链接的 node_modules 结构(https://pnpm.io/zh/symlinked-node-modules-structure)
  • tnpm: 真·深入浅出 tnpm rapid 模式 - 如何比 pnpm 快 10 秒(https://zhuanlan.zhihu.com/p/455809528)
  • deno: Linking to third party code(https://deno.land/manual@v1.18.2/linking_to_external_code)

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

开始看清了

暂无简介

0 文章
0 评论
23 人气
更多

推荐作者

内心激荡

文章 0 评论 0

JSmiles

文章 0 评论 0

左秋

文章 0 评论 0

迪街小绵羊

文章 0 评论 0

瞳孔里扚悲伤

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文