NPM 重复项
本文延续了 "虚假依赖项" 部分的讨论。建议先阅读该部分。
NPM 重复项是如何产生的
有时 node_modules 数据结构被迫安装 同一版本的 同一包的两个副本。真的吗?这是怎么发生的?
假设我们有一个主项目 A,如下所示
{
"name": "library-a",
"version": "1.0.0",
"dependencies": {
"library-b": "^1.0.0",
"library-c": "^1.0.0",
"library-d": "^1.0.0",
"library-e": "^1.0.0"
}
}
然后 B 和 C 都依赖于 F1
{
"name": "library-b",
"version": "1.0.0",
"dependencies": {
"library-f": "^1.0.0"
}
}
{
"name": "library-c",
"version": "1.0.0",
"dependencies": {
"library-f": "^1.0.0"
}
}
但 D 和 E 依赖于 F2
{
"name": "library-d",
"version": "1.0.0",
"dependencies": {
"library-f": "^2.0.0"
}
}
{
"name": "library-e",
"version": "1.0.0",
"dependencies": {
"library-f": "^2.0.0"
}
}
node_modules 树可以通过将 F1 放置在树的顶部来共享 F1,但随后 F2 必须在子文件夹中复制
- library-a/
- package.json
- node_modules/
- library-b/
- package.json
- library-c/
- package.json
- library-d/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@2.0.0
- library-e/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@2.0.0
- library-f/
- package.json <-- library-f@1.0.0
或者,包管理器可以选择将 F2 放置在顶部,但随后 F1 会被复制
- library-a/
- package.json
- node_modules/
- library-b/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@1.0.0
- library-c/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@1.0.0
- library-d/
- package.json
- library-e/
- package.json
- library-f/
- package.json <-- library-f@2.0.0
无论哪种方式,我们都无法排列树而不具有 library-f 的同一版本的两个副本。我们称之为“重复项”。来自其他编程语言的传统包管理器不会遇到这个问题;这是 NPM 的 node_modules 树的一个特殊方面。它是设计中固有的,不可避免的。
重复项的后果
小型项目很少遇到重复项,但它们在大型单一仓库中相当常见。以下是可能导致的一些潜在问题
更慢的安装: 如今磁盘空间并不昂贵,但想象一下,你有 20 个库依赖于 F1,导致 20 个复制副本。或者假设有一个安装后脚本下载并解压缩大型存档(例如 PhantomJS),并且这针对每个重复项单独发生。这可能会显着影响您的安装时间。
捆绑包大小爆炸: Web 项目通常使用 webpack 等捆绑器,该捆绑器会静态分析
require()
语句并将代码收集到单个捆绑文件中以供部署。此文件应尽可能小,因为它直接影响 Web 应用程序的加载时间。当重复项意外出现时(例如,由于npm install
操作重新平衡了 node_modules 树),这会导致捆绑包中嵌入两个库副本,从而大大增加其大小。非单一单例: 假设 library-f 有一个 API,它公开一个缓存对象,该对象旨在作为所有使用该库的消费者共享的单例实例。当两个不同的组件调用
require("library-f")
时,它们可能会获得两个不同的库实例化,这意味着单例将突然有两个实例(即底层的“全局”变量将在两个不同的闭包中分配)。这会导致非常奇怪的行为,难以调试。重复类型: 假设 library-f 是一个 TypeScript 库。编译器将遇到该库的所有*.d.ts 文件的重复副本。例如,每个类将有两个副本的声明,它们不能通过遵循符号链接目标来去重,因为它们是单独的物理文件。通常,相同的类声明不被 TypeScript 视为可互换的,并且在混合时会导致编译错误。Typescript 2.x 引入了一种启发式方法来检测和等同这些重复项,但这涉及额外的复杂性和处理。其他构建任务可能没有那么复杂。
语义上不同的重复项: 假设 F 有一个依赖项 G,该依赖项也被树中的其他包使用。在树中,F1 的第一个副本从 B 下开始搜索 G,而 F1 的第二个副本从 C 下开始搜索。
require()
算法可以从这两个起点找到 G 的不同版本。这意味着 F1 的两个实例的运行时行为可能不同。或者在编译时,如果 F 导出一个从 G 中定义的基类继承的 TypeScript 类,我们最终可能会得到 来自同一版本同一包的同一类的不同类型签名。这会导致非常令人困惑的编译错误。
Rush 如何提供帮助: Rush 的符号链接策略仅针对单一仓库中作为本地项目的依赖项消除重复项。如果您使用 NPM 或 Yarn 作为您的包管理器,不幸的是,对于任何间接依赖项,重复项仍然可能存在。而如果您使用 Rush 结合 PNPM,则重复项问题将完全解决(因为 PNPM 的安装模型准确地模拟了真正的有向无环图)。