Skip to main content

node_modules, the Heaviest Object in the Universe

相信大家在开发中都遇到过这样的问题:同一个项目,在本地开发环境没问题,到线上环境却有问题?那很可能是包管理出了问题。

前端的项目依赖主要是通过 package.json 和 node_modules 管理,常见的包管理工具有 npm, yarn。

通常来说:npm 包 = 结构化模块 + 描述文件(package.json)

需要注意的是:一个 module 不一定是一个 package,一个 package 也不一定是一个 module,package 可以是一个 tar 包,也可以是本地 file 协议,甚至是 git 仓库地址。

在下文中,node_modules 的包管理主要分两个阶段说明:早期的 npm V2 阶段、 npm V3 及以后阶段。

package.json#

package.json 主要用来定义项目的基础信息、相关执行命令及依赖包。

*dependencies#

项目的依赖包可以划分为:

  • dependencies

    项目线上运行所依赖的包

  • devDependencies

    项目开发过程中所需要的包,但是线上运行完全不需要,比如构建工具、CSS处理器、JS处理器、校验工具、测试工具的相关包。

  • peerDependencies

    同伴依赖,常见于插件库依赖的核心库,避免核心库被重复下载的问题。

    同伴依赖告知宿主环境需要的依赖以及版本范围,比如包 react-native-safe-area-view 的运行需要 reactreact-native 库,就可以在 peerDependencies 里声明。

    在 npm V2 阶段,依赖树采取 nest 嵌套模式(下文会说明),在安装 react-native-safe-area-view 时会顺便把它的 peerDependencies 也一起安装了。

    在 npm V3 及以后阶段,依赖树采取 flat 扁平模式(下文会说明),peerDependencies 的处理相应有所变化。如果宿主环境没有对应版本范围内的依赖,在安装依赖时会报出警告,不会自动安装,需要手动安装,可以通过第三方库实现自动安装。

  • optionalDependencies

    可选依赖,这种依赖即便安装失败,也不会中断依赖安装过程,会认为安装是成功的。

  • bundledDependencies

    打包依赖,这个数组里的包都会被打包到最终的发布包里。

    功能跟 dependencies 是一样的,区别在于,当需要构建项目并发布版本时,bundleDependencies 下的依赖会被包含在构建结果中,不需要另行安装了,通常用于不在 npm 发布的第三方包。

    bundledDependencies 中的包必须是在其他 *dependencies 声明过的。

npm-semver#

npm 采用了语义化版本号,在 package.json 文件中,我们常常会看到形如 “x.y.z” 的版本号,x、y、z的含义分别为主版本号、次版本号、补丁版本号。

  • 主版本号:大改动,新版本不兼容老版本的 API(主版本号为 0 通常表示是内测版本)。
  • 次版本号:新增了部分功能,新版本应该向下兼容。
  • 补丁版本号:修复了部分bug,新版本应该向下兼容。

有时语义版本也会包含一些“标签”或者“扩展”,用于标记预发布版本或者测试版本,比如 2.0.0-beta.3。

在描述文件里,有时候我们也会看到带符号的版本号 "5.0.3", "~5.0.3", "^5.0.3"。

  • "5.0.3" 表示安装指定的5.0.3版本。
  • 字符 X、x 或者 * 都可以作为通配符,用于填充部分或全部版本号。
  • 字符 ~,同时使用字符 ~ 和次版本号表明允许补丁版本号变更,同时使用字符 ~ 和主版本号表明允许次版本号变更。“~5.0.3”表示安装5.0.X中最新的版本。
  • 字符 ^, 表明不会修改版本号中的第一个非零数字,“^5.0.3”表示安装 5.X.X 中最新的版本。

不规范的版本号会导致同一个项目在不同时间、不同设备环境下无法保持一致性,可能会产生意想不到的 bug

最简单粗暴的方法是写死所有版本号,无视更新,但是这样跟不上外界的技术发展不利于项目更新。而且,大多数npm包都严重依赖于其他npm包,这就会导致嵌套依赖关系,并增加匹配相应版本的难度。为了解决这个问题,yarn 采用了锁文件 yarn.lock 来记录了被确切安装上的模块的版本号,npm >= 5.0 也引入了类似的锁文件 package-lock.json。

实际上,package-lock.json 文件只是提供回溯记录,安装时参照的还是 package.json 里的版本号,一个 🌰:

假设半年前项目依赖的vue版本是2.5.13,现在 vue 更新到 2.5.21 版本, 并且 package.json 中 vue: '^2.5.2'。
如果项目使用的包管理工具是 yarn,yarn.lock 文件中的 vue 依赖为2.5.13,
现在重新执行 yarn,node_modules 中 vue 的依赖还是 2.5.13,并没有被更新到 2.5.21。
如果项目使用的包管理工具是 npm,项目中的 package-lock.json 中的 vue 依赖为2.5.13,
现在执行 npm install,node_modules 中的 vue 依赖自动更新成 2.5.21,package-lock.json 中的 vue 版本不变,这就导致不同时期安装的依赖的版本不同。
解决方法:查看 package-lock.json 中的 vue 版本,例如是 2.5.13,将 package.json 中的依赖直接写成固定版本,"vue": "2.5.13", 或者 npm install 后执行 npm install vue@2.5.13。

yarn 还提供了 resolutions 机制,可以强行用指定版本去覆盖。

然而还是有一些场景lock文件无法覆盖,当我们第一次安装创建项目时或者第一次安装某个依赖的时候,此时即使第三方库里含有 lock 文件,但是并不会去读取第三方依赖的lock,这导致第一次创建项目的时候还是可能会触发bug。

node_modules#

Node 的作者对于 node 的遗憾之一就是支持了 node_modules。事实上,除了 Node 的 npm,很少有其他语言是需要每个项目都维护一个 node_modules。

node_modules 的模块寻址策略是:

  • 核心模块

    已内置,可以直接引用(比如 fs ),优先级仅次于缓存加载。

  • 路径形式的文件模块

    把路径转换为真实路径,初次编译执行后将结果放在缓存内。

  • 自定义(第三方)模块

    先找当前目录下的 node_modules,没有的话再找上一级目录的 node_modules,向上逐级递归直到系统用户根目录下的 node_modules。即优先读取最近的 node_modules 的依赖,递归向上查找。

这样的设计听起来很合理,相互之间的依赖调用完全隔离,允许多版本兼容,简直完美。但是,实际上存在不少问题。例如,项目 App 引入的第三方包 A 和 C,A 自身引入了 B v1.0,C 自身引入了 B v2.0,项目自身没有显示依赖模块 B,那么到底是安装 B v1.0 还是 B v2.0 ?

项目依赖 A、C A、C 依赖 B 如何确定 B 的版本

如果 B 本身不支持多版本共存(会产生全局污染的副作用),那么需要尽早在包安装阶段就报错检查。

如果 B 本身支持多版本共存,那么我们需要通过一定的方法保证模块之间的正确加载顺序,但是多版本共存很容易出现 Dependency Hell 的问题。

npm V2:nest mode 嵌套结构#

在 npm V2 阶段,根据模块寻址策略,对于第三方包,递归向上查找 node_modules 依赖,按照 package.json 结构以及子依赖包的 package.json 结构将依赖安装到他们各自的 node_modules 中,直到有子依赖包不再依赖其他模块。

nest mode

显然,这样会造成重复依赖的问题,如果我们有 100 个依赖包,其中有 90 个包依赖了 lodash 包,那么项目会安装90个 lodash 。重复依赖不仅仅会造成空间浪费,拉包耗时,有时候也会产生全局types 冲突,破坏单例模式,甚至导致项目无法正常运行。

the heaviest object in the universe

npm V3 及之后: flat mode 扁平结构#

在 npm V3 及之后阶段,同样利用向上递归查找依赖的特性,不同的是,把一些公共依赖放在项目根目录公共的 node_module 里,相比较 nest mode,节省了空间。

flat mode

然而,树形结构不稳定,很大程度上会受到依赖安装顺序的影响,如果新引入的 E 依赖的是 B v1.0,而 C 和 D 依赖的是 B v2.0,那么无论是把 B v1.0 还是 B v2.0 放在公共的 node_module 里,仍然无法完全避免重复依赖的问题。

problem of flat mode

而且,幽灵依赖(Phantom dependency)也是很容易被忽视的问题,例如 A 可以轻松地导入 C,即使在 A 里没有声明 C 为其依赖,A 也可以轻松地导入 C 的第三方依赖,如果有朝一日 C 的第三方依赖发生变更,就可能会影响到 A。

Dependency graph#

在不考虑循环依赖情况的前提下,项目内模块的依赖关系应该是一个有向无环图,是典型的拓扑结构,npm 和 yarn 通过文件目录和路径解析算法模拟的只是有向无环图的一个超集(多了很多错误的祖先节点和兄弟节点之间的链接),这就导致了很多的问题。

pnpm 通过硬链接(hard link)和符号链接(symlink),更加精确地模拟拓扑结构,来解决问题。

软链接(符号链接)是一类特殊的可执行文件, 其包含有一条以绝对路径或者相对路径的形式指向其它文件或者目录的引用。

pnpm 不仅能保证一个项目里的所有 package 的每个版本是唯一的,甚至能保证不同的项目之间也可以公用唯一的版本(只需要公用 store 即可),可以极大地节省空间。

pnpm

npm-config 配置#

配置项的优先级:命令行 > ENV 环境变量 > .npmrc > 系统默认配置。

当系统中存在多个 .npmrc 文件,这些配置文件的优先级从高到低的顺序为:

  1. 项目级, /path/to/my/project/.npmrc
  2. 用户级, ~/.npmrc
  3. 全局级,$PREFIX/etc/npmrc
  4. npm 内置,/path/to/npm/npmrc

对于使用 yarn 的项目,.yarnrc (在 yarn 2.X 中是 .yarnrc.yml )作为 .npmrc 的 扩展,.npmrc 的优先级更高,在 .npmrc 和 .yarnrc 同时存在于项目里,会参照 .npmrc 的 registry,在开发时需要注意。(经测试,低版本的 yarn 不会读取 .npmrc 的配置

Deno 的包管理#

总的来说,Node 的递归向上查找 node_modules 的算法,强依赖于 node_modules 的物理拓扑结构,这也是导致不同项目的项目难以复用 node_modules 的根源,目前的解决方案都只是在给这个问题打补丁。

Deno 1.0 目前的做法,直接通过 URL 来加载模块(初次下载后本地缓存),感觉上就像 script 标签。官方示例:

import { serve } from "https://deno.land/std@0.50.0/http/server.ts";
const s = serve({ port: 8000 });
for await (const req of s) {
req.respond({ body: "Hello World\n" });
}

Deno 只支持 ES6 的模块规范,不支持 CommonJS 模块规范,不提供 require 命令。

Deno 没有 npm,没有中心化的 npm registry,也没有 package.json 和 node_modules。官方的说法是:

These modules do not have external dependencies and they are reviewed by the Deno core team. The intention is to have a standard set of high quality code that all Deno projects can use fearlessly. 即:所有模块没有外部依赖性,并且(官方包)由Deno核心团队进行审查。目的是提供一套标准的高质量代码,所有Deno项目都可以无依赖的使用它们。

目前,Deno 官方库差不多有 25个一级目录(约1600个文件) 可供使用 ,第三方模块约有 233个