本文为翻译
原文标题:NPM doppelgangers
原文作者:Rush official doc
本文顺着 “幽灵依赖” 章节 继续讨论。建议先读完 “幽灵依赖” 再来读本文。
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 放在树的顶部 来共享使用,不过这样的话 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 拷贝 的情况下 排列依赖树。我们将这些拷贝称为 “分身(doppelgangers)”。其他编程语言传统的包管理器不会遇到这个问题;这是 NPM node_modules 树 很特殊的一面。这是 NPM 设计中 内在 且 不可避免的 问题。
分身 导致的后果
小型项目很少会遇到 分身 的问题,但是这在大型的 monorepo 中非常常见。这里列出了一些其可能引发的后果:
- 更慢的安装 :磁盘空间这些年已经不算太昂贵了,但是设想一下你有 20 个库依赖于 F1 ,这会导致 20 份拷贝的重复。或者假设有一个 后置执行脚本,该脚本 下载并解压大型的归档文件(例如 PhantomJS)并且 对于每一个 分身 都单独执行一遍。这会严重影响你安装依赖的时间。
- 打包大小激增 :Web 项目通常会使用像是 webpack 这样的打包器,它会静态分析
require()
语句并将代码整合到一个单独的打包文件用作部署。这个文件应当尽可得的小,因为它的大小会直接影响 Web 应用的性能。当意外的出现 分身 时(例如,由于一次npm install
操作 重新平衡了 node_modules 树 ),这将导致库的两份拷贝被嵌入到一个打包文件中,大大增加了文件大小。 - 非单例 :假设 library-f 有一个 API 暴露了一个 缓存对象,该对象准备作为一个单例 共享给该库的所有使用者。当两个不同的组件调用
require("library-f")
时,它们可能会得到两个不同的库实例,这意味着可能会突然出现两个单例的实例(换言之,底层的 “global” 变量被分配到两个不同的闭包中)。这可能会导致非常奇怪的行为,很难调试。 - 重复的类型 :假设 library-f 是一个 TypeScript 库。编译器会遇到该库重复拷贝的所有 *.d.ts 文件。例如,每个 class 存在自身定义的两份拷贝,由于它们时分离的两份物理文件,因此不能通过符号连接来减少重复。一般来说同一个 class 定义 在 TypeScript 中不能被随意转换,混合在一起会导致编译错误。Typescript 2.x 引入了一个启发式算法来检测并同一化这些声明,但这涉及到额外的复杂度和处理。但这样一来构建任务会变得过于高深莫测了。
- ** 不同语义的 分身** :假设 F 有一个依赖 G ,G 也被 依赖树 中的其他包 使用。在树中, F1 的第一份拷贝 会在 B 下 搜索 G ,而 F1 的第二份拷贝 会从 C 下开始搜索。
require()
算法 会从两个不同的起点找到不同版本的 G 。这意味着两个 F1 实例 的 运行时 行为可能会有所不同。或者在编译时,如果 F 导出来 一个 TypeScript class 继承自 G 中的一个基类,我们的 相同版本相同包的同一个 class 最终会得到不同的函数签名。这可能会导致高度混乱的编译器错误。
Rush 如何提供帮助 :Rush 的 符号连接策略 消除了 monorepo 本地项目中的 依赖分身。如果你使用 NPM 或者 Yarn 作为包管理器,很不幸,任何间接依赖 还是可能会存在 分身。然而,如果你使用 PNPM 配合 Rush,分身问题 就完全解决了(因为 PNPM 的安装模型完全模拟了一个真正的有向无环图)。