
作 者 | 王辰新(朔宸)
前言
npm是Node.JS的包管理工具,除此之外,社区有一些类似的包管理工具如yarn、pnpm和cnpm,以及集团内部使用的tnpm。我们在项目开发过程中通常使用以上主流包管理器生成node_modules目录安装依赖并进行依赖管理。本文主要探究前端包管理器的依赖管理原理,希望对读者有所帮助。
npm
当我们执行npm install命令后,npm会帮我们下载对应依赖包并解压到本地缓存,然后构造node_modules 目录结构,写入依赖文件。那么,对应的包在node_modules目录内部是怎样的结构呢,npm主要经历了以下几次变化。
1 npm v1/v2 依赖嵌套
npm最早的版本中使用了很简单的嵌套模式进行依赖管理。比如我们在项目中依赖了A模块和C模块,而A模块和C模块依赖了不同版本的B模块,此时生成的node_modules目录如下:


依赖地狱(Dependency Hell)
可以看到这种是嵌套的node_modules结构,每个模块的依赖下面还会存在一个 node_modules 目录来存放模块依赖的依赖。这种方式虽然简单明了,但存在一些比较大的问题。如果我们在项目中增加一个同样依赖2.0版本B的模块D,此时生成的node_modules目录便会如下所示。虽然模块A、D依赖同一个版本B,但B却重复下载安装了两遍,造成了重复的空间浪费。这便是依赖地狱问题。
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
├── C@1.0.0
│ └── node_modules
│ └── B@2.0.0
└── D@1.0.0
└── node_modules
└── B@1.0.0
一些著名的梗图:


2 npm v3 扁平化
npm v3完成重写了依赖安装程序,npm3通过扁平化的方式将子依赖项安装在主依赖项所在的目录中(hoisting提升),以减少依赖嵌套导致的深层树和冗余。此时生成的node_modules目录如下:


为了确保模块的正确加载,npm也实现了额外的依赖查找算法,核心是递归向上查找node_modules。在安装新的包时,会不停往上级node_modules中查找。如果找到相同版本的包就不会重新安装,在遇到版本冲突时才会在模块下的 node_modules 目录下存放该模块子依赖,解决了大量包重复安装的问题,依赖的层级也不会太深。
扁平化的模式解决了依赖地狱的问题,但也带来了额外的新问题。
幽灵依赖(Phantom dependency)
幽灵依赖主要发生某个包未在package.json中定义,但项目中依然可以引用到的情况下。考虑之前的案例,它的package.json如右图所示。


在index.js中我们可以直接require A,因为在package.json声明了该依赖,但是,我们require B也是可以正常工作的。
var A = require('A');
var B = require('B'); // ???
因为B是A的依赖项,在安装过程中,npm会将依赖B平铺到node_modules下,因此require函数可以查找到它。但这可能会导致意想不到的问题:
-
依赖不兼容:my-library库中并没有声明依赖B的版本,因此B的major更新对于SemVer体系是完全合法的,这就导致其他用户安装时可能会下载到与当前依赖不兼容的版本。
-
依赖缺失:我们也可以直接引用项目中devDepdency的子依赖,但其他用户安装时并不会devDepdency,这就可能导致运行时会立刻报错。
多重依赖(doppelgangers)
-
破坏单例模式:模块C、D中引入了模块B中导出的一个单例对象,即使代码里看起来加载的是同一模块的同一版本,但实际解析加载的是不同的module,引入的也是不同的对象。如果同时对该对象进行副作用操作,就会产生问题。
-
types冲突:虽然各个package的代码不会相互污染,但是他们的types仍然可以相互影响,因此版本重复可能会导致全局的types命名冲突。
不确定性(Non-Determinism)




此时完成开发,将项目部署至服务器,重新执行npm install,此时提升的子依赖B版本发生了变化,产生的node_modules目录结构将会与用户本地开发产生的结构不同,如下图所示。如果需要node_modules目录结构一致,就需要在package.json修改时删除node_modules结构并重新执行npm install。


3 npm v5 扁平化+lock
一致性


兼容性
语义化版本(Semantic Versioning)
-
主版本号:不兼容的 API 修改
-
次版本号:向下兼容的功能性新增
-
修订号:向下兼容的问题修正

在使用第三方依赖时,我们通常会在package.json中指定依赖的版本范围,语义化版本范围规定:
-
~:只升级修订号
-
^:升级次版本号和修订号
-
*:升级到最新版本
1 Yarn v1 lockfile
A@^1.0.0:
version "1.0.0"
resolved "uri"
dependencies:
B "^1.0.0"
B@^1.0.0:
version "1.0.0"
resolved "uri"
B@^2.0.0:
version "2.0.0"
resolved "uri"
C@^2.0.0:
version "2.0.0"
resolved "uri"
dependencies:
B "^2.0.0"
D@^2.0.0:
version "2.0.0"
resolved "uri"
dependencies:
B "^2.0.0"
E@^1.0.0:
version "1.0.0"
resolved "uri"
dependencies:
B "^1.0.0"
Yarn lock vs. npm lock
-
文件格式不同,npm v5 使用的是 json 格式,yarn 使用的是自定义格式
-
package-lock.json 文件里记录的依赖的版本都是确定的,不会出现语义化版本范围符号(~ ^ *),而 yarn.lock 文件里仍然会出现语义化版本范围符号
-
package-lock.json 文件内容更丰富,实现了更密集的锁文件,包括子依赖的提升信息
-
npm v5 只需要 package.lock 文件就可以确定 node_modules 目录结构
-
yarn.lock 无法确定顶层依赖,需要 package.json 和 yarn.lock 两个文件才能确定 node_modules 目录结构。node_modules 目录中 package 的位置是在 yarn 的内部计算出来的,在使用不同版本的 yarn 时可能会引起不确定性。
2 Yarn v2 Plug'n'Play

pnp模式优缺点也非常明显:
-
优:摆脱node_modules,安装、模块速度加载快;所有 npm 模块都会存放在全局的缓存目录下,避免多重依赖;严格模式下子依赖不会提升,也避免了幽灵依赖(但这可能会导致某些包出现问题,因此也支持了依赖提升的宽松模式:<)。
-
缺:自建resolver 处理Node require方法,执行Node文件需要通过yarn node解释器执行,脱离Node现存生态,兼容性不太好
1 非扁平化的node_modules
符号链接(symbolic link)创建嵌套结构


可以看到node_modules下的bar目录下并没有node_modules,这是一个符号链接,实际真正的文件位于.pnpm目录中对应的 <package-name>@version/node_modules/<package-name>目录并硬链接到全局store中。而bar的依赖存在于.pnpm目录下<package-name>@version/node_modules目录下,而这也是软链接到<package-name>@version/node_modules/<package-name>目录并硬链接到全局store中。

2 局限性
-
忽略了 package-lock.json。npm 的锁文件旨在反映平铺的 node_modules 布局,但是 pnpm 默认创建隔离布局,无法由 npm 的锁文件格式反映出来,而是使用自身的锁文件pnpm-lock.yaml。
-
符号链接兼容性。存在符号链接不能适用的一些场景,比如 Electron 应用、部署在 lambda 上的应用无法使用 pnpm。
-
子依赖提升到同级的目录结构,虽然由于 Node.js 的父目录上溯寻址逻辑,可以实现兼容。但对于类似 Egg、Webpack 的插件加载逻辑,在用到相对路径的地方,需要去适配。
-
不同应用的依赖是硬链接到同一份文件,如果在调试时修改了文件,有可能会无意中影响到其他项目。

cnpm是由阿里维护并开源的npm国内镜像源,支持官方 npm registry 的镜像同步。tnpm是在cnpm基础之上,专为阿里巴巴经济体的同学服务,提供了私有的 npm 仓库,并沉淀了很多 Node.js 工程实践方案。


Deno

在Deno不使用npm、package.json以及node_modules,而是将引入源、包名、版本号、模块名全部塞进了 URL 里,通过URL导入依赖并进行全局统一缓存,不仅节省了磁盘空间,也优化了项目结构。
import * as log from "https://deno.land/std@0.125.0/log/mod.ts";
// dep.ts
export {
assert,
assertEquals,
assertStringIncludes,
} from "https://deno.land/std@0.125.0/testing/asserts.ts";
// index.ts
import { assert } from './dep.ts';
-
node_modules困境:https://zhuanlan.zhihu.com/p/137535779 -
npm:https://npm.github.io/how-npm-works-docs/index.html -
Yarn: https://yarnpkg.com/features/pnp -
pnpm: https://pnpm.io/zh/symlinked-node-modules-structure -
tnpm: https://zhuanlan.zhihu.com/p/455809528 -
deno:https://deno.land/manual@v1.18.2/linking_to_external_code
云栖号的伙伴群开启了,欢迎大家入群聊起来! 大家想看什么内容,我们可以一起聊聊~ 👇👇👇
![]()
✨ 精彩推荐✨
最 新 活 动 🔥 2022年阿里云上云采购季大促全攻略
🔥 招募通知!采购季云产品体验官缺人!
“虎力全开”采购季,存储产品已就位!
无影云电脑支持企业快速实现居家办公
9块9!让AI帮你做个LOGO!
技 术 好 文 什么是好的技术氛围?
如何从容应对软件复杂性
Java应用结构规范
领域驱动编程,代码怎么写?
企 业 案 例 🔥 企业上云|数字化转型经验分享
人和未来:一面是科技,一面是责任
洞见科技:“隐私计算+云”推动场景应用大规模落地
万物有灵!萌物Luka机器人如何让故事点缀童年
↓ 直通2022年采购季主会场!🔑


