Gadzan

Node.js包管理器全解

0x00 前言

NPM、Yarn 这两款包管理器大多数前端应该都很熟悉,近段时间火起来的 pnpm 新一代包管理器很多人可能还不太了解,但先别说 pnpm,就 yarn v2 和 yarn v1 有什么不同这个问题,也未见得有多少人能说清楚。

最近 yarn v3 出来了,连 yarn v2 都没用过,更别说 pnpm,它们到底有什么不同? ╥﹏╥...学不动了,能不能给我说个清楚?

先别急,要搞清楚这些包管理器,得知道他们都在处理些什么,先看看这个👇

node_module

这张图相信大家都看过,很形象地展示了 node_module 的庞大,现如今一个项目小则几百兆,大则上G的空间,不仅安装等半天,删个 node_module 文件夹也须等上半天。现代包管理工具的主要目就是为了解决这个问题。那为什么 node_module 会这么大呢?故事要回到500年前...

0x01 NPM 考古

npm logo

没那么久,也就 2009 年,随着 node.js 的诞生,NPM 也随之诞生,NPM 的意思是 Node Package Manager。那当时的 NPM 是怎么处理 node_modules 的呢?

依赖地狱

假设项目里依赖了 A,C 两个包,看一下 package.json 文件

"dependencies": {
    A: "1.0.0",
    C: "1.0.0"
}

A 依赖 B,C 也依赖 B,构成了一个非常简单的有向无环图

有向无环图依赖树

这很好理解,但是如果 A 和 C 所依赖的 B 版本不一致呢?即: A -> Bv1,C -> Bv2

NPM v3 还没有发布之前的处理方式很简单粗暴,每个依赖下都放着各自的依赖,在安装依赖后,目录的结构就变成了下面这样:

node_modules
├── A@1.0.0
│   └── node_modules
│       └── B@1.0.0
└── C@1.0.0
    └── node_modules
        └── B@2.0.0

有人会想到,如果 B 继续依赖 A 那就会形成循环依赖,NPM 是怎么解决的呢?Node.js搜索包的机制是冒泡式的,首先会搜索执行文件当前目录下的 node_modules,看是否存在该依赖,如果不存在则向上一层目录的 node_modules 继续搜索,直到找到为止。而 NPM 如果发现上层已经安装过该包不会再安装,这就避免了循环依赖,也能确保可用。

当然,不同层级也会存在同一版本的依赖,这会导致很多冗余。在小项目中可能不会存在什么问题,但随着项目越来越复杂,依赖的包越来越多,依赖的包可能又有各自的依赖,这就会导致项目依赖关系越来越深,最后可能形成一个无底洞...

NPM v3 提出了一个解决办法,基于 Node.js 的冒泡式搜索机制把依赖 hoist(提升)到顶层 node_modules 目录,将依赖树拍平,像这样:

扁平依赖树

把所有依赖都放到顶层,就能解决依赖越嵌越深的问题了。这就是为什么你现在在 node_module 会发现平铺了这么多文件夹的原因,因为所有依赖都给放到顶层了。

不确定性

但问题来了,如果被依赖的包中存在不同版本怎么办?

首先需要知道,NPM 推荐开发者开发包时使用语义化版本 Semantic Versioningpackage.json会使用^~><等符号表示满足依赖的版本的区间。

如果同一个包被依赖的版本分别是^1.1.0^1.2.0,而最新的版本是1.2.3,那么 NPM 会只安装1.2.3的版本以减少 node_modules 的体积。

但如果包一个是v1,一个是v2,这种不满足依赖版本的情况就会产生问题。

沿用上面例子的依赖关系 A -> Bv1,C -> Bv2,NPM v3 会在顶层放一个版本的 B,在 A 或者 C 下放另一个版本的 B,像这样的:

情况1

或者像这样:

情况2

那到底是哪一种呢?

答案是都有可能,取决于开发者在开发过程中先安装哪个依赖。

因为如果在开发时先安装 A,由于扁平化的依赖树,Bv1 会被提升到顶层。再安装 C,由于 C 依赖 B v2,但此时顶层已经存在 Bv1,因此 Bv2被放到 C 的 node_modules 下。

那如果在开发时先安装 C 呢,同样道理,Bv2 则会被提升到顶层,再安装 A 会让其依赖 Bv1 放在 A 下。

在另一台机器(线上机器)运行npm install安装依赖后,NPM 在满足版本需求的情况下,合拼依赖版本并提升到顶层,然后按照字母排序依次安装依赖,这可能会导致依赖树与开发时不一致的情况,这就是依赖的不确定性,具体逻辑可以参考这篇说明

因为不确定性,最终可能会导致“在我的机子可以,咋你的就不行呢?”等不可预知的问题,具体可参考这个例子

NPM 当然也想到了这个问题,所以 NPM 提供了npm shrinkwrap命令生成npm-shrinkwrap.json文件来锁定依赖版本和依赖树,但每次安装新的依赖都须要手动运行,而且一旦第三方库中使用了shrinkwrap就会锁死版本和依赖树,心智和操作成本高之余还丧失了扁平依赖树的优势。

0x02 Yarn 诞生

yarn-kitten-full

时间迅速跳转到2016年10月,为了解决 NPM 诸多问题,Facebook 联合 Google 等公司的工程师打算另起炉灶,于是 yarn 应运而生,YARN 的意思是 Yet Another Resource Negotiator。 yarn 能够平行下载安装依赖,而且完全兼容 NPM 的工作流,使用 Workspaces 功能管理起 monorepos 更加方便。

NPM v3 在安装新依赖时都会直接从源上获取依赖包信息,Yarn 则提供了离线模式,它会将安装过的包缓存下来,在安装新依赖包时可以选择优先使用本地缓存,甚至可以做到完全使用本地缓存安装依赖。

yarn.lock

相比同时期的包管理工具,Yarn 有着的革命性优势,为了解决 NPM 的不确定性,yarn 从其他语言的包管理工具中受到启发并引入了 yarn.lock 文件,这个文件工作原理跟npm-shrinkwrap.json很像,同样是锁定依赖版本和依赖树,而 yarn 每次安装依赖都会自动更新 lock 文件。

对比npm-shrinkwrap.json,yarn 在计算依赖树和安装依赖时只会使用顶层目录的yarn.lcok 文件,自动忽略下层依赖包的yarn.lock文件,充分利用了扁平依赖树的优势,又降低了心智和操作成本。

通过引入 lock 文件,Yarn 算是解决了 NPM 不确定性带来的问题,而 NPM 在半年后发布了 v5,并紧跟 yarn 步伐也推出了离线模式和自己的 package-lock.json 文件,这个文件长得跟npm-shrinkwrap.json一模一样,但工作原理跟yarn.lock几乎一致,至于它跟npm-shrinkwrap.json的关系,可以参考官方的这篇说明,这里不再赘述了。

Yarn 和 NPM 处理安装依赖的过程

现在我们来看看 Yarn v1 和 NPM v5 在处理安装依赖的过程有什么不同(添加依赖的场景)。

  1. 读取本地依赖

    Yarn 不会直接查 node_module 只会对比yarn.lockpackage.json文件获取本地依赖信息,这一步速度很快;

    但 NPM 保守一点,会遍历整个 node_module 如果发现依赖树有问题会自动修复,就是慢了点。

  2. 从源处获取缺失的依赖包信息,计算依赖树

    这一步两个包管理器都选择从 lock 文件获取依赖树的信息,然后计算依赖树

  3. 下载解压包文件到 node_modules

    Yarn 和 NPM 都有维护一个缓存中心,Yarn 可以用yarn cache dir看到缓存目录,而 NPM 可以用npm config get cache。它们两都会先在缓存中寻找,如果缓存中没有才会从源上下载。有一点不同,Yarn 的策略是解压后做缓存,所以占用的空间多一些,复制的需求少一些;但 NPM 用的是硬链接来减少复制的需求并节省空间。

Yarn 和 NPM 留下的两个问题

NPM 和 Yarn 都是通过扁平依赖树来尝试解决依赖过深的问题,但问题并没有得到根本解决,因为顶层 node_module 只能容纳一个版本的依赖包,如果项目复杂,不同版本的依赖包还是会嵌套在各个包里面。

Doppelgangers(分身)依赖

说到这里,我们要引用一个名词 Doppelgangers(分身)依赖来描述上面的情况。我们继续用上一节讨论的依赖关系:A -> B v1,C -> B v2。

如果这时候再引入一个依赖 D 而 D 同时也依赖 B v1,那情况就会变成这样:

Doppelgangers(分身)依赖

同时有多个依赖 B v1 在依赖树下,这些依赖 B v1 被称为分身依赖。分身依赖不仅会使安装依赖速度变慢,整体体积变大,还会在运行时内存中因为引用不同的分身而产生不同实例和闭包导致不可预知的bug。

Phantom (幻影)依赖

什么是幻影依赖呢?在项目中,由于 NPM 和 Yarn 已经将依赖树拍平,依赖 B 被提升到顶层 node_module,我们可以在项目代码上直接写require('B'),并正常使用。原本在package.json没有定义的依赖,开发者在项目也能使用,这种对于项目无中生有的依赖 B 就是所谓的幻影依赖。直接require('B')的做法非常不安全,因为你没办法控制幻影依赖的版本,容易导致不兼容的语法报错。

Yarn 和 NPM 为了解决幻影依赖,在安装时新增了--global-style选项,加上此选项后项目的顶级 node_modules 只会放上在package.json的里定义的依赖,而在依赖的 node_modules 才会把该依赖的依赖树拍平。这种退而求其次的做法明显会产生更多的分身依赖。

因此,以上两个问题 Yarn 和 NPM 至今都没有很好的办法解决。

0x03 PNPM

pnpm_logo

就在 Yarn 发布后不到半年时间,2017年3月,Zoltan Kochan 发表了一篇文章Why should we use pnpm?,随之发布了第一版的 pnpm, pnpm 的意思是 performant npm。

pnpm 在官网上所写的创作动机就是为了解决分身依赖问题,从而加快安装速度,进一步减少依赖树体积,还顺带解决了幻影依赖的问题。

那 pnpm 是怎么解决的呢?非常简单,既然拍平依赖树会带来这两个问题,那不拍不就好了?

所以 pnpm 不走寻常路,还开了个“历史倒车”,直接回到 npm v2 时代,用嵌套树!但 pnpm 用了个非常巧妙的手段——操作系统的 hardlink(硬链接)和symbollink(符号链接)。

来看看实际使用例子:

假如用 pnpm 在项目中安装 express,你会看到 pnpm 提示所有包都缓存到一个叫content-addressable store(内容寻址存储器)的地方,这里是E:\.pnpm-store\v3,接着把包都硬链接到项目的 node_modules 里。

pnpm 首次安装依赖

因此,如果你在其他项目也要安装 express,pnpm 会直接使用 content-addressable store 里的缓存然后进行硬链接,而不是通过网络再下载一次。

pnpm 重新安装依赖

打开 node_module 目录会看到下面结构:

▾ node_modules
  ▸ .pnpm
  ▾ express
    ▸ lib
      History.md
      index.js
      LICENSE
      package.json
      Readme.md
    .modules.yaml

node_module 只有一个依赖 express,而真实的依赖都被平铺地被放到了其中的.pnpm文件夹里,利用符号(软)链接和硬链接把真实依赖都映射到 express 及其依赖中。

▾ node_modules
  ▾ .pnpm
    ▸ accepts@1.3.7
    ▸ array-flatten@1.1.1
    ...
    ▾ express@4.17.1
      ▾ node_modules
        ▸ accepts
        ▸ array-flatten
        ▸ body-parser
        ▸ content-disposition
        ...
        ▸ etag
        ▾ express
          ▸ lib
            History.md
            index.js
            LICENSE
            package.json
            Readme.md

可以看到.pnpm里平铺着包名@版本号的依赖,打开express@4.17.1,下面只有一个 node_modules 文件夹,里面躺着所有 express 的依赖包,但这些依赖包除了 express 以外全是软连接。

.pnpm的node_modules文件夹

这些软链接各自指向.pnpm下的包名@版本号文件夹:

node_modules
├── express -> ./.pnpm/express@4.17.1/node_modules/express
└── .pnpm
    ...
    ├── array-flatten@1.1.1
    │   └── node_modules
    │       └── array-flatten -> <store>/array-flatten
    ...
    └── express@4.17.1
        └── node_modules
            ...
            ├── array-flatten -> ../../array-flatten@1.1.1/node_modules/array-flatten
            ...
            ├── express -> <store>/express
            ...

这种结构其实跟 Yarn/NPM 加上--global-style安装选项后的结构一摸一样,只是 pnpm 用软硬链接规避了分身依赖的问题。

但是目前还是有很多库会利用幻影依赖去完成一些骚操作,pnpm 在后续版本中舍弃默认的严格模式,改为“半严格”模式,即,将所有的工程非直接依赖,软链接到.pnpm/node_modules中,目的是解决这些“不规范”的问题。不过在项目中你还是没有办法使用幻影依赖,只是依赖包能够这么做而已。

想详细了解 pnpm 的工作原理可以看看官方的这篇说明

因为嵌套树的结构和软硬链接的实现,由于分身依赖而产生的大量 I/O 操作成为过去式,而项目越大节省的时间也越明显。

下面是 pnpm 官方给出的数据统计:

pnpm 安装速度数据统计

据了解字节的前端团队已经全面使用 pnpm。

0x04 更激进的做法 Plug'n'Play

在 Yarn 诞生后,其开发团队想要更加“有效”地解决 node_modules 的问题,于是他们在2018年9月提出并实现了 Plug'n'Play(PnP) 这个功能,并宣称能减少安装依赖70%的时间。

他们认为一个好的包管理工具应该主动告诉 Node.js 包的位置,而不是让 Node.js 一层层地找,这样太没效率了,应该把这活都交给 Yarn 干。于是 Yarn 创建了.pnp.js文件作为依赖的静态映射表,不过.pnp.js文件不仅仅是个映射表,同时也是一个可运行的文件,文件包含了项目的依赖树信息, 模块查找算法, 也包含了模块查找器的 patch 代码。

既然 Node.js 不需要自己搜索了,那 node_modules 的存在意义也就没有了。Yarn 的做法一直都很激进,想要解决 npm 的问题,就做个新的包管理工具 Yarn。同样地,他们想要解决 node_modules 的问题,于是打算干掉 node_modules,取而代之的是一个叫.yarn的文件夹。

.yarn文件夹下取平铺了所有的包,跟 pnpm 很像,也是包名加版本号组合成express-npm-4.17.1-6815ee6bf9-d964e9e17a.zip的样子。有趣的是,所有包都是 zip 文件,PnP 直接跳过了解压从源上下载的包,因此体积很小。然后通过 .pnp.js 给 Node.js 的 fs 模块进行补丁,让 Node.js 可以直接从压缩包里读包的代码。

.yarn文件夹

你可以结合.pnp.js文件和 Yarn 对 readFileSync 的源码窥见一斑。

由于依赖包足够小,Yarn 发明了一个用法,并取名为Zero-Installs,说白了就是把.yarn文件夹也整个放到代码仓库中保存,这样,开发者只要从仓库拉取代码就能立刻运行,不需要再安装依赖。这也是 Plug'n'Play 的终极思想。

由于所有依赖信息都存放在.pnp.js这个可执行文件中,在 PnP 管理下幻影依赖和分身依赖自然就解决了,而且磁盘 I/O 也大大降低,看上去比 pnpm 要更厉害。

不过先别激动,因为整个 Node.js 生态都默认依赖着 node_modules,正常跑代码是没问题,但是开发就要用到 IDE,Typescirpt 等都无法直接识别 PnP,如果开发者想要用起 Yarn 的新特性,就得手动安装各种插件和配置项。

假如使用 VSCode 开发,那就得运行yarn dlx @yarnpkg/sdks vscode添加对 VSCode 的支持,然后通过 VSCode 的Select TypeScript Version设置勾选Use Workspace Version来改变使用工作区的 Typescript 版本。如果需要使用 vetur,还得勾上Vetur: Use Workspace Dependencies这个设置。目前还无法保证所有库和插件都能跟 Yarn 配合起来。

虽然要完美使用 PnP 很麻烦,但我非常理解 Yarn 的做法。Yarn 知道万恶之源在 node_modules,把 node_modules 废掉能才能倒逼 Node 生态作出调整,不然整个生态只能继续绕着 node_modules 在转圈。而且 PnP 其实更像一个协议,任何包管理工具都可以通过实现.pnp.js来使用 PnP 的特性。而 pnpm 在 v5.9 以后也支持了 yarn 的 PnP 的特性

尽管 PnP 已经在 Yarn 1.12 上实现,但在 windows 系统上,开发者只能将 Yarn 升级到 v2 以上才能使用 PnP 特性,而且 v2 是默认开启 PnP 特性的,通过yarn set version berry命令可以在项目中使用 v2 版本的 Yarn。如果不想开启 PnP,只要在.yarnrc.yml文件里修改nodeLinker: node-modules就可以用回 node_modules 了。

0x05 Yarn v3

Yarn v3 也于去年发布了,除了一些内部的改动外,还优化了性能,而且声称某些方面跑得比 pnpm 还快。

最值得期待的是,Yarn v3 扩展了“node_modules linkers”,以至于后续可能可以实现 pnpm 式的结构,但详细细节还没有公开,如果可以用回 node_module,那 PnP 的兼容性就不是问题了。

0x06 到底用哪个?

NPM 时至今日已经跟上了 Yarn v1 的步伐,在使用上体验上基本没有区别,而且 NPM v7 还能直接使用yarn.lock文件,Yarn 同样可以通过yarn import命令引入package-lock.json并转成yarn.lock,因此如果你不打算使用 Yarn 的 PnP 特性,其实用哪个都没什么影响。

既然不考虑PnP,而且能耐心看到这里,就不难判断出从兼容性,性能和安全性角度来看,pnpm 都是一个不错的选择,如果还没用过 pnpm,那么何不给 pnpm一个机会呢?

0x07 参考

How npm3 Works - npm3 Non-Determinism

npm(v3) 模块安装机制简介

Replace metadata + fetch + cache code #15666

Yarn 的 Plug'n'Play 特性

Welcome to JS Dependency Hell

Rushjs - NPM doppelgangers

Rushjs - Phantom dependencies

tink FAQ: a Package Unwinder for JavaScript

TINK: A NEXT GENERATION PACKAGE MANAGER

Yarn 3.0 🚀🤖 Performances, ESBuild, Better Patches, ...


打赏码

知识共享许可协议 本作品采用知识共享署名 4.0 国际许可协议进行许可。

评论