NPM 分身

1,151 阅读4分钟

本文为翻译

原文标题:NPM doppelgangers

原文作者:Rush official doc

原文地址:rushjs.io/pages/advan…

本文顺着 “幽灵依赖” 章节 继续讨论。建议先读完 “幽灵依赖” 再来读本文。

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"
  }
}

BC 同时依赖 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"
  }
}

但是 DE 依赖于 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 有一个依赖 GG 也被 依赖树 中的其他包 使用。在树中, F1 的第一份拷贝 会在 B 下 搜索 G ,而 F1 的第二份拷贝 会从 C 下开始搜索。 require() 算法 会从两个不同的起点找到不同版本的 G 。这意味着两个 F1 实例 的 运行时 行为可能会有所不同。或者在编译时,如果 F 导出来 一个 TypeScript class 继承自 G 中的一个基类,我们的 相同版本相同包的同一个 class 最终会得到不同的函数签名。这可能会导致高度混乱的编译器错误。

Rush 如何提供帮助 :Rush 的 符号连接策略 消除了 monorepo 本地项目中的 依赖分身。如果你使用 NPM 或者 Yarn 作为包管理器,很不幸,任何间接依赖 还是可能会存在 分身。然而,如果你使用 PNPM 配合 Rush,分身问题 就完全解决了(因为 PNPM 的安装模型完全模拟了一个真正的有向无环图)。