身为前端开发的我们应该每天都会接触 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/lodashnest 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 "registry.npm.taobao/@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 "registry.npm.taobao/@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 "registry.npm.taobao/@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": "registry.npm.taobao/@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": "registry.npm.taobao/@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 "registry.npm.taobao/lodash/download/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"

  integrity sha1-tEf2ZwoEVbv+7dETku/zMOoJdUg=lodash@^4.17.19:

  version "4.17.19"

  resolved "registry.npm.taobao/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 "registry.npm.taobao/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 .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 支持、新的模块协议等等,但本篇到此结束、不过多扩展了。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐