npm

2010 年 1 月诞生,NodeJS 的默认包管理工具。

问题

  1. 依赖地狱(Dependency Hell):npm v3 之前采用嵌套的 node_modules 结构,直接依赖会平铺在 node_modules 下,子依赖嵌套在直接依赖的 node_modules 中。例如项目依赖了A 和 C,而 A 和 C 依赖了不同版本的 B@1.0B@2.0node_modules 结构如下:
        node_modules
        ├── A@1.0.0
        │   └── node_modules
        │       └── B@1.0.0
        ├── C@1.0.0
        │   └── node_modules
        │       └── B@2.0.0
        └── D@1.0.0
            └── node_modules
                └── B@1.0.0
    
  2. 扁平的 node_modules 结构:为了将嵌套的依赖尽量打平,避免过深的依赖树和包冗余,npm v3 将子依赖「提升」(hoist),采用扁平的 node_modules 结构,子依赖会尽量平铺安装在主依赖项所在的目录中。
        node_modules
        ├── A@1.0.0
        ├── B@1.0.0
        └── C@1.0.0
            └── node_modules
                └── B@2.0.0
        └── D@1.0.0
    
    • A 依赖的 B@1.0 不再放在 A 的 node_modules 下了,而是与 A 同层级。
    • 而 C 依赖的 B@2.0 因为版本号原因还是嵌套在 C 的 node_modules 下。
    • 这样不会造成大量包的重复安装,依赖的层级也不会太深,解决了依赖地狱问题。
  3. 幽灵依赖(Phantom dependencies):指在 package.json 中未定义的依赖,但项目中依然可以正确地被引用到。
    • 由于 B 在安装时被提升到了和 A 同样的层级,所以在项目中引用 B 还是能正常工作的。
    • 幽灵依赖是由依赖的声明丢失造成的,如果某天某个版本的 A 依赖不再依赖 B 或者 B 的版本发生了变化,就会造成依赖缺失或兼容性问题。
  4. 不确定性(Non-Determinism):指同样的 package.json 文件,install 依赖后可能不会得到同样的 node_modules 目录结构。
    • A 依赖 B@1.0,C 依赖 B@2.0,依赖安装后应该提升 B 的 1.0 还是 2.0 取决于用户的安装顺序。
    • 如果有 package.json 变更,本地需要删除 node_modules 重新 install,否则可能会导致生产环境与开发环境 node_modules 结构不同,代码无法正常运行。
  5. 依赖分身(Doppelgangers):假设继续再安装依赖 @B2.0 的 E 模块,此时 B@2.0 会被安装两次,无论提升 B@1.0 还是 B@2.0,都会存在重复版本的 B 被安装,这两个重复安装的 B 就叫依赖分身。虽然看起来模块 C 和 E 都依赖 B@2.0,但其实引用的不是同一个 B,假设 B 在导出之前做了一些缓存或者副作用,那么使用者的项目就会因此而出错。
    node_modules
    ├── A@1.0.0
    ├── B@1.0.0
    ├── D@1.0.0
    ├── C@1.0.0
    │   └── node_modules
    │       └── B@2.0.0
    └── E@1.0.0
        └── node_modules
            └── B@2.0.0
    

yarn

2016 年发布,定义为快速、安全、可靠的依赖管理。

  1. 并行提升安装速度:
    1. npm 中安装依赖时,安装任务是串行的,会按包顺序逐个执行安装,这意味着它会等待一个包完全安装,然后再继续下一个。
    2. 为了加快包安装速度,yarn 采用了并行操作,在性能上有显著的提高。
    3. 而且在缓存机制上,yarn 会将每个包缓存在磁盘上,在下一次安装这个包时,可以脱离网络实现从磁盘离线安装。
  2. lockfile 解决不确定性:
    1. 在依赖安装时,会根据 package.josn 生成一份 yarn.lock 文件。
    2. lockfile 里记录了依赖,以及依赖的子依赖,依赖的版本,获取地址与验证模块完整性的 hash
    3. 即使是不同的安装顺序,相同的依赖关系在任何的环境和容器中,都能得到稳定的 node_modules 目录结构,保证了依赖安装的确定性。
    4. npm 在一年后的 v5 才发布了 package-lock.json
  3. npm 一样的弊端:yarn 依然和 npm 一样是扁平化的 node_modules 结构,没有解决幽灵依赖依赖分身问题。

yarn2

2020 年 1 月,yarn2 发布,也叫 yarn berry(v1 叫 yarn classic)。

  1. yarn 的一次重大升级,其中一项重要更新就是 Plug'n'PlayPlug and Play = PnP,即插即用)。
  2. 抛弃 node_modules
    • 无论是 npm 还是 yarn,都具备缓存的功能,大多数情况下安装依赖时,其实是将缓存中的相关包复制到项目目录中 node_modules 里。
    • yarn PnP 则不会进行拷贝这一步,而是在项目里维护一张静态映射表 pnp.cjs
    • pnp.cjs 会记录依赖在缓存中的具体位置,所有依赖都存在全局缓存中。同时自建了一个解析器,在依赖引用时,帮助 node 从全局缓存目录中发现依赖,而不是查找 node_modules
    • 这样就避免了大量的 I/O 操作同时项目目录也不会有 node_modules 目录生成,同版本的依赖在全局也只会有一份,依赖的安装速度和解析速度都有较大提升。
    • pnpm 在 2020 年底的 v5.9 也支持了 PnP
  3. 脱离 node 生态:
    • 因为使用 PnP 不会再有 node_modules 了,但是 WebpackBabel 等各种前端工具都依赖 node_modules。虽然很多工具比如 pnp-webpack-plugin 已经在解决了,但难免会有兼容性风险。
    • PnP 自建了依赖解析器,所有的依赖引用都必须由解析器执行,因此只能通过 yarn 命令来执行 node 脚本。

pnpm - performant npm

2017 年发布,定义为快速的,节省磁盘空间的包管理工具。

  1. 允许跨项目地共享同一版本的依赖。
  2. 如果同一依赖需要使用不同的版本,则仅有版本之间不同的文件会被存储起来。
  3. pnpm 的根目录下的 node_modules 只包含 pakcage.json 中显式声明的依赖,并且这里实际上保存的是依赖的软链。
软链.png
实际位置.png
  1. 使用 pnpm 安装依赖后 node_modules 结构如下:
node_modules
├── .pnpm
│   ├── A@1.0.0
│   │   └── node_modules
│   │       ├── A => <store>/A@1.0.0
│   │       └── B => ../../B@1.0.0
│   ├── B@1.0.0
│   │   └── node_modules
│   │       └── B => <store>/B@1.0.0
│   ├── B@2.0.0
│   │   └── node_modules
│   │       └── B => <store>/B@2.0.0
│   └── C@1.0.0
│       └── node_modules
│           ├── C => <store>/C@1.0.0
│           └── B => ../../B@2.0.0
│
├── A => .pnpm/A@1.0.0/node_modules/A
└── C => .pnpm/C@1.0.0/node_modules/C

内容寻址存储 CAS

  1. 与依赖提升和扁平化的 node_modules 不同,pnpm 引入了另一套依赖管理策略:内容寻址存储。
  2. store:该策略会通过 store 来存储所有的 node_modules 依赖,依赖的每个版本只会在系统中安装一次。
  3. .pnpm:虚拟存储目录,所有直接和间接依赖项都链接到此目录中。该目录通过 name@version 来实现相同模块不同版本之间隔离和复用。
  4. 安装依赖:如果某个依赖在 sotre 目录中存在了话,就会直接从 store 目录里面去 hard-link,否则就会去下载一次。
  5. pnpm store prune:提供了一种用于删除一些不被全局项目所引用到的 packages 的功能,例如有个包 axios@1.0.0 被一个项目所引用了,但是某次修改使得项目里这个包被更新到了 1.0.1 ,那么 store 里面的 1.0.0axios 就就成了个不被引用的包,执行 pnpm store prune 就可以在 store 里面删掉它了。
  6. 幽灵依赖问题:只有直接依赖会平铺在 node_modules 下,子依赖不会被提升,不会产生幽灵依赖。
  7. 依赖分身问题:相同的依赖只会在全局 store 中安装一次。项目中的都是源文件的副本,几乎不占用任何空间,没有了依赖分身。

链接

  1. 硬链接 Hard link,可以理解为源文件的副本,项目里安装的其实是副本。
    • 用户可以通过路径引用查找到全局 store 中的源文件,而且这个副本根本不占任何空间。
    • pnpm 会在全局 store 里存储硬链接,不同的项目可以从全局 store 寻找到同一个依赖。
    • .pnpm 中的每个文件都是来自内容可寻址存储的硬链接,指向全局 store 中安装的依赖。
  2. 符号链接 Symbolic link,也叫软连接,在 Windows 中使用的是连接点链接pnpm 可以通过它找到对应磁盘目录下的依赖地址。
  3. 示例:
pnpm.png
  1. 项目中有一个依赖 bar@1.0.0
  2. bar@1.0.0 也有一个依赖 foo@1.0.0
  3. node_modules 下面有 bar@1.0.0.pnpm 目录,没有 foo@1.0.0
  4. bar@1.0.0 通过软链接指向 .pnpm/bar@1.0.0/node_modules/bar@1.0.0
  5. .pnpm/bar@1.0.0/node_modules/bar@1.0.0 又通过硬链接指向 store
  6. bar@1.0.0 依赖的 foo@1.0.0 会安装在跟自己的同一级。
  7. .pnpm/bar@1.0.0/node_modules/foo@1.0.0 通过软链指向 .pnpm 中的 foo@1.0.0
  8. .pnpm/foo@1.0.0 一样通过硬链接指向 store

pnpm 目前的缺点是兼容性、功能丰富度和社区生态等比较弱。

总结:

以上对比了几种不同的包管理方案:目前还没有完美的依赖管理方案。

它们有不同的 node_modules 结构,有嵌套,扁平,甚至没有 node_modules,不同的结构也伴随着兼容与安全问题。它们使用不同的依赖存储方式来节约磁盘空间,提升安装速度。但每种管理器都伴随新的工具和命令,不同程度的可配置性和扩展性,影响开发者体验。这些包管理器也对 monorepo 有不同程度的支持,会直接影响项目的可维护性和速度。