幽灵依赖项
Rush 的文档偶尔会提到“幽灵”和“Doppelgangers”。想了解更多关于 JavaScript 包管理器的工作原理吗?
一些历史和理论
众所周知,软件**包**可以依赖于其他**包**,而由此产生的依赖关系图是一种计算机科学中的有向无环图。与树形数据结构不同,有向无环图可以具有重新合并的菱形分支。例如,库**A**可能从库**B**和**C**导入定义,但是**B**和**C**都可以从**D**导入,这会在这四个包之间创建一个“**菱形依赖关系**”。按照惯例,编程语言的**模块解析器**通过遍历该图的边来查找导入的包,并且(在其他系统中)包本身位于可以被多个项目共享的中央存储中。
由于历史原因,NodeJS 和 NPM 采取了一种不同的方法,在磁盘上物理地表示该图:NPM 使用实际的包文件夹副本对图顶点进行建模,而图边则由子文件夹关系隐含。但文件夹树的分支不能重新合并以形成菱形。为了处理这种情况,NodeJS 添加了一个特殊解析规则,其效果是引入额外的图边(指向所有父文件夹的直接子节点)。从计算机科学的角度来看,此规则以两种方式放松了文件系统的树形数据结构:(1)它现在可以表示一些(但不是全部)有向无环图,以及(2)我们获得了不对应于任何声明的包依赖关系的额外边。这些额外的边被称为“**幽灵依赖关系**”。
NPM 的方法具有许多与传统包管理器不同的独特特征
每个(根级)项目都拥有自己的**node_modules** 树,其中包含许多包文件夹副本。即使是最小的 NodeJS 项目也可能在其文件夹下有超过 10,000 个文件副本。
在 NPM 2.x 中,**node_modules** 文件夹树非常深且重复,这最大限度地减少了幽灵依赖关系。NPM 3.x 改进了安装算法以展平树,这消除了大量重复,但代价是引入了更多幽灵依赖关系(额外的图边)。在某些情况下,新算法还会选择稍微旧版本的包(同时仍然满足 SemVer),以进一步减少包文件夹的重复。
已安装的**node_modules** 树不是唯一的。有许多可能的将包文件夹排列成树以近似有向无环图的方法,并且没有唯一的“规范化”排列。您得到的树取决于您的包管理器选择遵循的启发式算法。NPM 自己的启发式算法甚至对您添加包的顺序敏感。
**node_modules** 树是一种不寻常且理论上有趣的树形数据结构。但让我们关注三个可能导致实际问题的后果,以及在大型且非常活跃的单体仓库中可能特别难以诊断的问题。我们还将展示 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'); // ???
// (more code here that uses those libraries)
等等——这两个库都没有在**package.json** 文件中声明为依赖项。这到底是如何工作的?事实证明,**brace-expansion** 是**minimatch** 的依赖项,而**glob** 是**rimraf** 的依赖项。在安装过程中,NPM 已将其文件夹展平到**my-library/node_modules** 下。NodeJS 的require()
函数在那里找到了它们,因为它会探测文件夹,而不考虑**package.json** 文件。这可能违反直觉,但似乎运行良好。也许这是一个特性而不是一个错误?
不幸的是,该项目的缺失声明最好被认为是一个错误。它会导致意外的故障或错误
不兼容版本:尽管我们的库的**package.json** 声明它需要**minimatch** 版本 3,但我们无法控制我们将获得的**brace-expansion** 版本。只要它不影响**minimatch** 的 API 签名,SemVer 系统 允许**minimatch** 的 PATCH 版本合并**brace-expansion** 库的 MAJOR 升级。在实践中,我们作为**my-library** 的开发者可能永远不会遇到这种情况——相反,它将由后来在与我们定期测试的版本约束非常不同的**node_modules** 排列中安装我们发布的库的可怜受害者发现。
缺少依赖项:**glob** 包来自我们的
devDependencies
,这意味着它只针对在**my-library** 项目上工作的开发者进行安装。对于其他消费者,require("glob")
应该立即以错误失败,因为**glob** 根本不会为他们安装。我们应该在我们发布**my-library** 包后立即收到关于它的消息,对吧?不完全是。在实践中,大多数消费者很可能出于某种原因也拥有**glob**(例如,使用**rimraf** 本身),因此它似乎可以正常工作。只有我们消费者中的一小部分人会遇到导入失败的错误,这使得他们似乎报告了一个难以重现的奇怪问题。
Rush 如何提供帮助:Rush 的符号链接策略确保每个项目的**node_modules** 只包含其声明的直接依赖项。这会在构建时立即捕获幽灵依赖项。如果您使用的是 PNPM 包管理器,则相同的保护也适用于所有间接依赖项(可以通过使用**pnpmfile.js** 来解决任何“不良”包)。
幽灵 node_modules 文件夹
假设我们有一个单体仓库,有人添加了一个根级**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
,而我们的脚本将自动部署单体仓库中的所有项目。(如果您使用的是 Rush,请不要这样做!相反,请定义一个自定义命令。)请注意,此假设脚本需要使用**semver** 库,因此它已添加到devDependencies
列表中。人们被要求在运行npm run deploy-app
之前在仓库根文件夹中运行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** 下。这是一种更隐蔽的拾取意外幽灵依赖关系的方式——它有时会找到甚至不在您的 Git 工作目录下的**node_modules** 文件夹!
Rush 如何提供帮助:Rush 已经为您准备好了。rush install
命令会扫描所有潜在的父文件夹,如果发现任何幽灵**node_modules** 文件夹,则会发出警告。