身为前端开发的我们应该每天都会接触 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.0
,B
就近加载 X@2.0
,依赖加载的准确性自然地得到了保证。
但如果此时新增一个依赖了 X@2.0
的 C
模块,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
被提升安装到了顶层,A
、B
目录下不会再安装 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 的版本描述,应该都是符合语义化版本规范的,为何在项目中还会存在两个不同的版本?
实际上这种情况一般是随着项目的迭代、依赖的增加而不经意间引入的,比如下面的场景:
- 项目初始化的时候,各种兼容的版本号(lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.0.1...)指向了目前最新的 lodash@4.17.15,安装完毕后锁定了 lodash@1.17.15
- 一段迭代之后,引入了新模块 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 大体会经历四个阶段:
- 将依赖包的版本区间解析为某个具体的版本号
- 下载对应版本依赖的 tar 包到本地离线镜像
- 将依赖从离线镜像解压到本地缓存
- 将依赖从缓存拷贝到当前目录的 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 支持、新的模块协议等等,但本篇到此结束、不过多扩展了。
参考资料: