node_modules 与包管理器

身为前端开发的我们应该每天都会接触 node_modules,但对于 node_modules 的认知是否充分?也许因为包管理器的存在,平时只需要一个 install 命令,可能就不会去过多关注 node_mdouels 本身。

简单而言,node_modules 是为 Node 设计存放依赖的文件夹。一直到今天,node_modules 能满足很多场景的使用,但同时也存在不少缺陷。

从一个常见的版本冲突场景开始切入主题,查看以下依赖关系:

当出现这种情况时,node_modules 下的文件结构是如何组织的?

如果 X 是像 react 这种不支持多版本共存的,可以进行前置的报错、警告以避免多版本同时存在的情况,进而通过项目内指定唯一版本的方式来避免多版本的问题。

但更多地、 X 会是像 lodash 这类支持多版本共存的模块,那此时如何保证应用在运行时、依赖能加载到他们正确版本的子依赖?

NPM 处理多版本依赖的方式

npm 通过 Node 加载模块的路径查找算法node_modules 的目录结构来配合解决这个问题。

Node 的模块(非内置模块)加载(require)算法会遵循以下两点:

  • 优先从同级的 node_modules 寻找依赖
  • 递归向上从父级的 node_modules 中寻找依赖

有如下文件:

// ~/desk/projects/demo/a.js
const _ = require('lodash');

那么应用在运行时,将会按如下顺序去寻找 lodash:

  • ~/desk/projects/demo/node_modules/lodash
  • ~/desk/projects/node_modules/lodash
  • ~/desk/node_modules/lodash
  • ~/node_modules/lodash
  • /node_modules/lodash

nest mode

利用 require 会先在同级 node_module 里查找依赖的特性,能想到一个很简单的方式,直接在 node_module 维护模块需要的拓扑图即可:

APP - node_modules
├── A
│   └── node_modules
│       └── X@1.0
├── B
│   └── node_modules
│       └── X@2.0

应用在运行时,A 会就近加载 X@1.0B 就近加载 X@2.0,依赖加载的准确性自然地得到了保证。

但如果此时新增一个依赖了 X@2.0C 模块,node_modules 就会变成下面这样:

APP - node_modules
├── A
│   └── node_modules
│       └── X@1.0
├── B
│   └── node_modules
│       └── X@2.0
├── C
│   └── node_modules
│       └── X@2.0

虽然依赖加载的版本正确性能得到保障,但其中显然是存在着问题:

  • X@2.0 被重复安装了两次
  • X@2.0 会执行两次,X@2.0 的 require 缓存会有两份

flat mode

flat mode 可以认为是基于 nest mode 的一种优化,同时也是当前 npm 采用的方式。该模式同样利用到向上递归查找依赖的特性,不过区别是会将一些公共依赖提升到项目顶层的 node_modules:

# nest mode - npm v2
APP - node_modules
├── A
│   └── node_modules
│       └── X@1.0
└── B
│   └── node_modules
│       └── X@1.0
├── C
│   └── node_modules
│       └── X@2.0

# ││
# ││
# \/

# flat mode - npm v3
APP - node_modules
├── X@1.0
├── A
├── B
└── C
    └── node_modules
        └── X@2.0

观察两种文件结构,flat 之后 X@1.0 被提升安装到了顶层,AB 目录下不会再安装 X@1.0 的依赖,并且:

  • A、B 都能就近加载到 X@1.0 - (经历一次向上查找)
  • C 就近加载到 X@2.0 - (直接同级加载)

这样一来保证正确性的同时,也一定程度上减少了依赖重复的问题。

但这依旧不能完全解决依赖重复的问题,下面的场景无论把 X@1.0 提升或是将 X@2.0 提升都会导致另一个版本出现重复。

当项目的依赖增多的时候,node_modules 下可以有成千上万个文件,除了 node_modules 的体积会被诟病;因为 Node 寻找依赖的特性,会需要遍历大量的文件才能找到正确版本的依赖,性能也会受到影响;此外,大量的依赖导致包管理器在 install 阶段所经历的 I\O 消耗和时间消耗也成了一个新的问题。

这时候就要上一张黑洞图:

哪有什么岁月静好,不过是有人替你负重前行!

新的问题 - 隐式依赖

flat mode 相比 nest mode 节省了很多的空间,然而也带来了一个隐式依赖的问题。

比如在实际项目中,我们知道 muya 是依赖了 muya-core 的,所以会直接在项目中使用如下的代码:

import { createOSSPostTool } from '@qunhe/muya-core';

我们能直接使用 muya-core 还不用去管理它的版本,表面上看起来很方便,但问题也出在“不用去管理它”。

  • 首先,是因为 flat mode 提升了模块 @qunhe/muya-core,因此可以直接在项目中使用 muya-core;
  • 接着假设 muya 的一次升级弃用了 @qunhe/muya-core 改用了 @qunhe/muya-core2,那么当我们在某次升级 muya 之后,node_modules 之中实际上已经不存在 @qunhe/muya-core,此时我们项目本身就会出现错误了。

所以,推荐的做法是将直接用到的依赖都应该明确在 package.json 中定义,而且这样做了之后,对于编辑器(比如 vscode)的提示也会有优化作用。

Yarn v1 的处理方式

早期的时候,yarn 与 npm 的区别是比较大的,当时的 npm 不够完善,缺少很多特性,yarn 的出现弥补了这些缺失。

而现在可能是因为 yarn 或其他优秀包管理器的刺激,npm 已经不断完善了起来,比如 npm7 也能支持 workspaces,甚至做到了对 yarn.lock 的支持。

yarn 同样使用 flat mode 来组织 node_modules 下的依赖文件,优先提升依赖,只有当子依赖的版本和 root 的冲突的时候,才不进行提升的操作。

yarn 有一种更为激进的模式,即 --flat 模式,该模式下 node_modules 里的各个 package 只允许一个版本的存在,当出现版本冲突的时候,需要选择指定一个版本(即通过指定在 resolution 里,强控版本),但这在大型项目中显然行不通,因为第三方库里存在大量的版本冲突问题(仅 webpack 内就存在 160 + 个版本冲突)。

lock 文件的作用

问题:项目中用到的大部分依赖往往都有子依赖,而项目的 package.json 只能管理项目的直接依赖,并不能保证协作时所有依赖的一致性,如何去做到一致性?

不仅要处理好本地 node_modules 的文件组织,包管理器还得保证持续迭代和协同工作时依赖版本的一致性,于是有了 lock 文件。

yarn 和 npm 在初次安装之后都会生成一个 lock 文件,包含所有依赖的版本信息,这样他人根据 lock 文件就能复现出当前 node_modules 的状态。

不过细节上 yarn.lock 与 package-lock.json 还是有一些区别:

  • yarn.lock 只记录了依赖的版本情况
  • package-lock.json 记录了依赖的版本情况,还会记录依赖的拓扑结构

yarn.lock

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


"@ant-design/colors@^3.1.0":
  version "3.2.2"
  resolved "https://registry.npm.taobao.org/@ant-design/colors/download/@ant-design/colors-3.2.2.tgz#5ad43d619e911f3488ebac303d606e66a8423903"
  integrity sha1-WtQ9YZ6RHzSI66wwPWBuZqhCOQM=
  dependencies:
    tinycolor2 "^1.4.1"

"@ant-design/create-react-context@^0.2.4":
  version "0.2.4"
  resolved "https://registry.npm.taobao.org/@ant-design/create-react-context/download/@ant-design/create-react-context-0.2.4.tgz#0fe9adad030350c0c9bb296dd6dcf5a8a36bd425"
  integrity sha1-D+mtrQMDUMDJuylt1tz1qKNr1CU=
  dependencies:
    gud "^1.0.0"
    warning "^4.0.3"

"@ant-design/icons-react@~2.0.1":
  version "2.0.1"
  resolved "https://registry.npm.taobao.org/@ant-design/icons-react/download/@ant-design/icons-react-2.0.1.tgz#17a2513571ab317aca2927e58cea25dd31e536fb"
  integrity sha1-F6JRNXGrMXrKKSfljOol3THlNvs=
  dependencies:
    "@ant-design/colors" "^3.1.0"
    babel-runtime "^6.26.0"

package-lock.json

{
  "name": "design-zone",
  "version": "1.0.0",
  "lockfileVersion": 1,
  "requires": true,
  "dependencies": {
    "@ant-design/colors": {
      "version": "3.2.2",
      "resolved": "https://registry.npm.taobao.org/@ant-design/colors/download/@ant-design/colors-3.2.2.tgz",
      "integrity": "sha1-WtQ9YZ6RHzSI66wwPWBuZqhCOQM=",
      "requires": {
        "tinycolor2": "^1.4.1"
      }
    },
    "@ant-design/create-react-context": {
      "version": "0.2.5",
      "resolved": "https://registry.npm.taobao.org/@ant-design/create-react-context/download/@ant-design/create-react-context-0.2.5.tgz",
      "integrity": "sha1-9fWpFjtHcgl3EoNzl60w4i55+Fg=",
      "requires": {
        "gud": "^1.0.0",
        "warning": "^4.0.3"
      }
    }
  }
}

此外,在使用 yarn 的过程中发现会不经意间引入版本重复的问题,随手打开了一个项目的 lock 文件发现了下面这种看起来有点不合“逻辑”的描述片段:

"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.0.1, lodash@^4.16.5, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.3.0, lodash@~4.17.10, lodash@~4.17.4:
  version "4.17.15"
  resolved "https://registry.npm.taobao.org/lodash/download/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
  integrity sha1-tEf2ZwoEVbv+7dETku/zMOoJdUg=

lodash@^4.17.19:
  version "4.17.19"
  resolved "https://registry.npm.taobao.org/lodash/download/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b"
  integrity sha1-5I3e2+MLMyF4PFtDAfvTU7weSks=

观察 lodash 的版本描述,应该都是符合语义化版本规范的,为何在项目中还会存在两个不同的版本?

实际上这种情况一般是随着项目的迭代、依赖的增加而不经意间引入的,比如下面的场景:

  1. 项目初始化的时候,各种兼容的版本号(lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.0.1...)指向了目前最新的 lodash@4.17.15,安装完毕后锁定了 lodash@1.17.15
  2. 一段迭代之后,引入了新模块 X,X 依赖了 lodash@^4.17.19,此时指向了最新的 lodash@4.17.19,X 的安装导致了新的 lodash@4.17.19 的引入,从而导致了重复

此时将 lock 文件中 lodash 相关的两段描述删除、再重新执行安装即可,此时 lodash 版本为 4.17.20,同时去除了重复:

"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.0.1, lodash@^4.16.5, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.3.0, lodash@~4.17.10, lodash@~4.17.4:
  version "4.17.20"
  resolved "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
  integrity sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=

当然,更推荐借助 yarn-deduplicate 工具来自动进行去重操作,例 npx yarn-deduplicate yarn.lock

monorepo 模式与隐式依赖

monorepo 模式目前也用得越来越多,我个人也很喜欢这种模式,而且我还喜欢将各个子包的依赖描述都定义在根 package.json 中,因为这样在各个 package 中可以自由、方便地使用依赖,但这实际上是一个误区行为。

在 monorepo 模式中,无论是 lerna 还是 yarn 工作机制的核心都是:

  • 将所有 package 的依赖都尽量以 flat 模式安装到根级别的 node_modules 里,即 hoist,以避免各个 package 重复安装第三方依赖;将有冲突的依赖,安装在各自 package 的 node_modules 里,以解决依赖的版本冲突问题。
  • 将各个 package 都软链到根级别的 node_modules 里,这样各个 package 利用 Node 的递归查找机制,可以导入其他 package,不需要自己进行手动的 link。
  • 将各个 package 里 node_modules 的 bin 软链到 root level 的 node_modules 里,保证每个 package 的 npm script 能正常运行。

但是

  • packageA 可以轻松的导入 packageB,即使没有在 packageA 里声明 packageB 为其依赖,甚者 packageA 可以轻松地导入 packageB 的第三方依赖,类似上述的误区行为。

因为这样一来,实际上将是隐式依赖的问题加剧放大了,所以使用的时候还是需要稍加注意。

下一代 yarn 已经到来

初衷是想重点介绍本节的内容,但在准备过程中发现《node_modules 困境》 一文对 node_modules 相关的描写挺全面的,遂进行了一些二次整理和结合,同时压缩了这一节。

代号:berry

Berry 是 Yarn 2 的代号,同时也是Yarn 2 仓库的名称。

yarn 2 有一个理念,表示 yarn 虽作为一个包管理器,但 yarn 本身也是项目的依赖之一,yarn 认为 yarn 作为项目的第一个依赖,也应该被锁定。因此,yarn 2 及更高版本通过项目内安装的形式达到按项目进行管理的目的。

只需在已经使用 yarn 1 的项目内,进行本地升级,即可将某个项目的 yarn 升级至新版:

$ yarn set version berry

接下来就可以开始体验 yarn 的新特性了。

Plug'n'Play 解决了什么?

当然,提到 yarn 2,我觉得 pnp 才应该是首要关注的一大特性,这是 yarn 对 node_modules 做出的一次重大变革。

实际上 pnp 的功能早在 18 年 9 月份就被提出实现了,但在 yarn 2 中算是正式出道吧,因为 yarn 2 默认使用 pnp 模式。

根据前文可以发现,Node 寻找模块实际上效率不高,而大量的依赖导致包管理器在安装依赖的时候也会有大量工作,对于 yarn 在 install 大体会经历四个阶段:

  1. 将依赖包的版本区间解析为某个具体的版本号
  2. 下载对应版本依赖的 tar 包到本地离线镜像
  3. 将依赖从离线镜像解压到本地缓存
  4. 将依赖从缓存拷贝到当前目录的 node_modules 目录

其中第 4 步涉及大量的文件 I/O,导致安装依赖时效率不高(尤其是在 CI 环境,每次都是重新安装全部依赖)。

pnp 就是为了解决这些问而出现的新特性:既然 Node 查找的方式低效,为什么不直接告诉 Node 文件在哪里呢?Node 所要做的仅仅只是从一个地方加载文件;同时 Node 不需要再自行寻找 node_modules 了,那么也无需为了模拟拓扑结构而重复拷贝依赖了。

在开启 pnp 的情况下,在安装之后 yarn 会生成一个 .pnp.js 文件,而 node_modules 不会再有了,取而代之的是一个 .yarn/cache 目录,作为依赖的存放位置。

.pnp.js 包含了两个映射表,可概括成以下信息:

  • 当前项目依赖树中包含了哪些依赖包的哪些版本
  • 这些依赖包是如何互相关联的
  • 这些依赖包在文件系统中的具体位置

.pnp.js 还包含一个 resolver 来告诉 Node 如何加载依赖。总之,使用了 pnp 可以预计是可以获得这些收益的:

  • 取代 node_modules:
    • 所有依赖都平铺在 cache 目录下,解决了重复依赖重复安装的问题
    • 所有依赖都以压缩包(zip)的形式存放,大大减少了文件数量和体积
    • 无需在文件系统上处理拓扑结构,只需生成一个 .pnp.js 文件,也减少了时间消耗
  • 提高模块 Node 加载模块的效率,yarn 直接定位模块、告知 Node 模块的文件路径
  • 若还开启了全局缓存,可以实现本机所有项目的模块统一一份缓存,项目中甚至也不会再有 .yarn/cache(终于能做的像 gradle 或是 rust 的依赖管理了)

开启 pnp 后的安装结果:

.
├── .pnp.js
├── .yarn
│   ├── cache
│   │   ├── @ant-design-colors-npm-3.2.2-71aac486be-b42a2e5422.zip
│   │   ├── @ant-design-create-react-context-npm-0.2.5-7998e8d912-d86c381caf.zip
│   │   ├── @ant-design-css-animation-npm-1.7.3-f3d18e5bbb-2d0e5c0a61.zip
│   │   ├── @ant-design-icons-npm-2.1.1-c472b7964a-8e3682f594.zip
│   │   ├── @ant-design-icons-react-npm-2.0.1-d1619b6de4-12eedf6ecd.zip
│   │   ├── @babel-code-frame-npm-7.0.0-beta.44-de6de9a17f-58b214c926.zip
│   │   ├── @babel-code-frame-npm-7.12.11-b0730d1d28-033d3fb3bf.zip
│   │   ├── @babel-compat-data-npm-7.12.7-79f7d2298d-96e60c267b.zip
│   │   ├── @babel-core-npm-7.12.10-6f71cf4941-4d7b892764.zip
│   │   ├── @babel-generator-npm-7.0.0-beta.44-2d4de4045e-9c2e655e61.zip
│   │   ├── @babel-generator-npm-7.12.11-d1390772ed-eb76477ff8.zip
│   │   ├── @babel-helper-annotate-as-pure-npm-7.12.10-c32669dae2-d237f38b6a.zip
│
├── .yarnrc.yml
├── package.json
└── yarn.lock

最直观上的感受就是体积和文件数量上的变化(感觉终于不会再是黑洞了):

另一点因为 yarn 2 使用 zip 的形式保存依赖,依赖体积上有了很大的改善,使用版本管理工具直接管理依赖成为了一种现实易行的方式,berry cache 就是采用这种形式。

这样一来能带来新的改善:

  • 更好的开发体验
    • 每次使用 git pull, git checkout 等命令更新完代码之后无需再使用 yarn install 进行依赖的安装,同时能避免因为更新代码却忘了安装而导致的错误
  • 更快、更简单、更稳定的 CI 部署
    • CI 配置无需再关注依赖安装部分的配置
    • 由于每次部署代码的时候,yarn install 占用的时间都是一个大头,去掉这个步骤后部署速度将会有一定提升

不过,pnp 不是能直接使用的,需要各种工具进行支持,好消息是目前为止,社区的大部分工具都能直接支持 pnp 了,可以在官方文档看到现在的支持情况

yarn 2 还有不少新特性和改善,如配置文件和 lock 文件使用标准 yml 格式,自带对 lock 文件中的依赖去重、支持 yarn 插件、更好的 workspaces 支持、新的模块协议等等,但本篇到此结束、不过多扩展了。


参考资料:

知识共享许可协议
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。
comments powered by Disqus