【第2430期】幽灵依赖

前言

某次无意中看到这个词。今日前端早读课文章由360@Tapir翻译授权分享。

@Tapir,前端工程师,现就职于 360。热爱新技术,平时会翻译翻译喜欢的文章,发布在知乎和掘金。兴趣爱好广泛,喜欢 音乐、主机游戏 和 ACG,最近在学习政治学。

正文从这开始~~

一些历史和理论

大家都知道 软件包 可以依赖 别的包,由此产生的 依赖图( dependency graph) 从 计算机科学 角度来说 是一种 有向无环图 。不同于树这种数据结构,一个 有向无环图 允许存在重合的菱形分支。例如,库 A 可能引入 库 B 和 库 C ,但是之后 B 和 C 可能都会引入 D ,这四个包之间就创建一个了 “菱形依赖”。按照传统,程序语言的 模块解析器 会通过遍历 图的 边(eage) 来 查找引入的包,而(在另一个体系下)这些包本身会在一个中心化的存储库中找到,以便于在多个项目中共享。

由于历史原因,NodeJS 和 NPM 采取了不同的方式 来在磁盘上物理的表示依赖图:NPM 使用 真实软件包的文件夹副本 来作为 图的顶点,用 子文件夹关系 来表示 图的边。但 文件夹树 的 分支 不能通过重合来完成 菱形。为了解决这个问题,NodeJS 添加了一个 特殊的解析规则 ,其效果就是引入额外的 图边(指向所有父文件夹的直接子文件夹)。从计算机科学角度来看,这条规则 通过两种方式 松动了文件系统的 树形数据结构 :

  • 它现在可以表示一些(但不是全部)有向无环图。

  • 我们可以取到一些额外的边,而这些边没有在包的依赖声明中定义。这些额外的边被称作 “幽灵依赖” 。

NPM 采取的方案 包含很多独特的特性 有别于传统的包管理器:

每个(顶级) 项目 都会有自己的 node_modules 树,树中包含了大量的包文件夹副本。即使一个非常小的 NodeJS 项目,其依赖目录下也可能包含 10,000 个以上的文件。

在 NPM 2.x,node_modules 文件夹树 很深 且 重复度高,但将 幽灵依赖 控制到了最少。NPM 3.x 引入的安装算法会打平整个树,这消除了大量的重复依赖,代价是引入了更多的 幽灵依赖 (额外的图边)。在某些情况下,该算法还会选择稍微老一些的包版本(在依旧满足 SemVer 的情况下) 以进一步减少 包文件夹的重复。

已安装的 node_modules 树不是独一无二的的。将 包文件夹 从 树形结构 通过排列近似为 有向无环图 的 方式 存在很多种可能,而且不存在唯一的 “规范化” 排列方式。你最终得到怎样的 依赖树 取决于你使用的 包管理器 选用了怎样的 启发式算法。NPM 自身的 启发式算法 甚至对于 包添加的顺序 很敏感。

node_modules 树 是一种 不同寻常 且 理论上很有趣 的数据结构。但让我们先聚焦这三个结果上,它们会引发真正的麻烦,尤其是对于 大型且活跃的 monorepo。我们也会展示 Rush 是如何改进的 — 缓解这些问题是创建 Rush 工具 的原始动机之一!

幽灵依赖

“幽灵依赖” 指的是 项目中使用了一些 没有被定义在其 package.json 文件中 的 包。考虑下面的例子:

my-library/package.json

{
"name": "my-library",
"version": "1.0.0",
"main": "lib/index.js",
"dependencies": {
"minimatch": "^3.0.4"
}
,
"devDependencies": {
"rimraf": "^2.6.2"
}
}

但假设代码是这样:

my-library/lib/index.js

var minimatch = require("minimatch")
var expand = require("brace-expansion"); // ???
var glob = require("glob") // ???

// (更多使用那些库的代码)

稍等一下下… 有两个库根本没有被作为依赖定义在 package.json 文件中。那这到底是怎么跑起来的呢?原来 brace-expansion 是 minimatch 的依赖,而 glob 是 rimraf 的依赖。在安装的时候,NPM 会打平他们的文件夹到 my-library/node_modules 。NodeJS 的 require() 函数能够在依赖目录找到它们,因为 require() 在查找文件夹时 根本不会受 package.json 文件 影响。这可能有点反直觉,但是跑起来没啥问题。也许这算是一个功能而不是 bug?

不幸的是,此项目的丢失声明 最好被理解成一个 bug。因为它可能会导致意想不到的错误:

不兼容的版本 :尽管我们库的 package.json 声明它需要 版本 3 的 minimatch ,但对于 brace-expansion 的版本,我们没有任何发言权。只要不影响 minimatch 的 API 签名,那么 minimatch 在一个 PATCH 发行版 中包含一个 库 brace-expansion 的 MAJOR 升级 对于 SemVer 体系 来说是完全合法的。实际上,作为 my-library 的开发者,我们可能永远不会遇到这个问题 — 而这个问题会被一个可怜的受害者发现,他以不同的 node_modules 排列 安装了我们发布的库,而这种排列 相比于 我们通常测试的环境 可能拥有 更新(或更老)的 约束规则。

丢失依赖 :包 glob 来自我们的 devDependencies ,这意味着它只会被 my-library 的开发者安装。对于其他使用者, require("glob") 应该立即执行失败 因为 glob 完全不会被他们安装。我们一发布 my-library 就知道,对吧?不一定。事实上,可能大部分使用者都会由于某些原因能够使用 glob (例如,他们自己也引入了 rimraf ),所以可能出现 require("glob") 执行成功的情况。只有一小部分的使用者会出现引入错误,这就导致 他们反馈的这类问题 似乎 很奇怪 而且 无法复现。

Rush 如何提供帮助 :Rush 的 符号连接策略 会确保每个项目的 node_modules 只会包含自身声明的直接依赖。这能帮助在构建时就立刻捕获到 幽灵依赖。如果你正在使用 包管理器 PNPM ,同样的 保护策略 也会被应用在所有间接的依赖中(提供能力 通过使用 pnpmfile.js 来灵活处理任何“坏”包)。

幽灵 node_modules 文件夹

假设我们有一个 monorepo,有人添加了一个根级的 package.json 文件,就像这样:

my-monorepo/package.json

{
"name": "my-monorepo",
"version": "0.0.0",
"scripts": {
"deploy-app": "node ./deploy-app.js"
}
,
"devDependencies": {
"semver": "~5.6.0"
}
}

这样一来,执行 npm run deploy-app ,我们的脚本就会自动的将 monorepo 中的项目全部部署。(如果你在使用 Rush,那请不要这么做!而是应该定义一个 自定义命令 。)注意这个假设的脚本需要使用 库 semver ,所以被添加到了 devDependencies 列表。人们在执行 npm run deploy-app 之前 会在 repo 根文件夹 被要求执行 npm install 。

最终安装的目录结构会像是这样:

- my-monorepo/
- package.json
- node_modules/
- semver/
- ...
- my-library/
- package.json
- lib/
- index.js
- node_modules/
- brace-expansion
- minimatch
- ...

但回想一下 NodeJS 的 模块解析器 会在 父文件夹 中查找依赖。这意味着我们的 my-library/lib/index.js 可以调用 require("semver") 并能找到 包 semver ,即使它没有出现在 my-library/node_modules 下。这是一个 意外获取到 幽灵依赖 的潜伏更深的情况 — 有时查找的 node_modules 可能甚至都不在你的 Git 工作目录下!

Rush 如何提供帮助 :Rush 为你提供保障。rush install 命令会扫描所有可能的父文件夹 并 在发现任何 幽灵 node_modules 文件夹后发出警告。

关于本文
译者:@Tapir
译文:https://zhuanlan.zhihu.com/p/412419619
作者:@Rush official doc
原文:https://rushjs.io/pages/advanced/phantom_deps/

为你推荐


【第2325期】使用patch-package修改Node.js依赖包内容


【第2167期】埋点自动收集方案-路由依赖分析


欢迎自荐投稿,前端早读课等你来。