📓 聊聊依赖管理
前端开发者们每天都在接触 xxx install,包管理器是必不可少的工具,我们在项目开发的过程中会引用到各种不同的库,各种库又依赖了其他不同的库,这些依赖应该如何进行管理,今天这篇文章主要聊的就是依赖管理。
1.0——Npm
npm可以说是最早的依赖安装cli,我们先来看一下npm是怎么样安装依赖的吧
-
发出npm install命令
-
npm 向 registry 查询模块压缩包的网址
-
下载压缩包,存放在~/.npm目录
-
解压压缩包到当前项目的node_modules目录
针对npm2与npm3还是有区别的
npm2——嵌套地狱
npm2 安装依赖的时候比较简单直接,直接按照包依赖的树形结构下载填充本地目录结构,也就是嵌套的 node_modules 结构,直接依赖会平铺在 node_modules 下,子依赖嵌套在直接依赖的 node_modules 中。
比如项目依赖了A 和 C,而 A 和 C 依赖了相同版本的 B@1.0,而且C还依赖了D@1.0.0,node_modules 结构如下:
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
└── C@1.0.0
└── node_modules
└── B@1.0.0
└── D@1.0.0
可以看到同版本的 B 分别被 A 和 C 安装了两次。
如果依赖的层级越多,且依赖包数量越多,久而久之,就会形成嵌套地狱
npm3——扁平化嵌套、不确定性、依赖分身、幽灵依赖
扁平化嵌套
针对npm2存在的问题,npm3提出新的解决方案,将依赖进行展平,也就是扁平化
npm v3 将子依赖「提升」(hoist),采用扁平的 node_modules 结构,子依赖会尽量平铺安装在主依赖项所在的目录中。
举个例子
比如项目依赖了A 和 C,而 A 依赖了 B@1.0.0,而且C还依赖了B@2.0.0,
node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
└── node_modules
└── B@2.0.0
可以看到 A 的子依赖的 B@1.0 不再放在 A 的 node_modules 下了,而是与 A 同层级。
而 C 依赖的 B@2.0 因为版本号原因还是放到了 C 的 node_modules 下。
这样不会造成大量包的重复安装,依赖的层级也不会太深,解决了依赖地狱问题,但也形成了新的问题。
那为什么不把B@2.0.0提到node_modules,为啥是B@1.0.0呢?而且这样将B直接提取到我们的node_modules,是不是意味着我们可以在代码直接引用B包?引出我们下面的问题
不确定性
我们对于这种处理方式其实很容易有一个疑问,如果我同时引用了同一个包的多个不同版本,会帮我把哪个包提出来,同时我每次npm i之后提出来的包版本都是一样的吗?这也意味着同样的 package.json 文件,install 依赖后可能不会得到同样的 node_modules 目录结构。
举个例子,
install 后究竟应该提升 B 的 1.0 还是 2.0。
node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
└── node_modules
└── B@2.0.0
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
├── B@2.0.0
└── C@1.0.0
网上大部分说法是会根据package.json里面的顺序决定谁会被提出来,放在前面的包依赖的内容会被先提出来,看源码后,npm其实会调用一个叫做localeCompare的方法对依赖进行一次排序,实际上就是字典序在前面的npm包的底层依赖会被优先提出来。
幽灵依赖
什么叫做幽灵依赖,也就是我的package.json没有指明这个包,但实际项目使用了这个包,且这个包因为扁平化嵌套导致了可以直接使用,也就是非法访问,最经常碰到的就是dayjs这个包。
比如我的项目使用了arco,但是arco的子依赖有dayjs,那么根据扁平化,dayjs就会被放在node_modules的首层。
但是存在很大的问题,就是一旦arco去掉了这个子依赖,那么我们的代码就直接报错了呢?
依赖分身
假设继续再安装依赖 B@1.0 的 D 模块和依赖 @B2.0 的 E 模块,此时:
以下是提升 B@1.0 的 node_modules 结构:
node_modules
├── A@1.0.0
├── B@1.0.0
├── D@1.0.0
├── C@1.0.0
│ └── node_modules
│ └── B@2.0.0
└── E@1.0.0
└── node_modules
└── B@2.0.0
可以看到 B@2.0 会被安装两次,实际上无论提升 B@1.0 还是 B@2.0,都会存在重复版本的 B 被安装,这两个重复安装的 B 就叫 doppelgangers。
而且虽然看起来模块 C 和 E 都依赖 B@2.0,但其实引用的不是同一个 B,假设 B 在导出之前做了一些缓存或者副作用,那么使用者的项目就会因此而出错。
Npm install
npm3以上的版本安装依赖就是下面的步骤
-
检查配置: 读取 npm config 和 .npmrc 配置,比如配置镜像源
-
确定依赖版本,构建依赖树: 检查是否存在 package-lock.json
存在进行版本比对,处理方式和npm版本有关,根据最新npm版本处理规则,版本能兼容按照 package-lock 版本安装 , 反之按照 package.json 版本安装
不存在根据 package.json 确定依赖包信息
- 检查缓存或下载: 判断是否存在缓存
存在将对应缓存解压到 node_modules 下,生成 package-lock.json
不存在则下载资源包,验证包完整性并添加至缓存,之后解压到 node_modules 下,生成 package-lock.json
不足之处
安装速度慢,没有解决扁平化带来的算法复杂性、幽灵依赖等本质问题;
2.0——Yarn
并行安装
无论何时 npm 或者 yarn 需要安装包,都会产出一系列的任务。使用 npm 时,这些任务按包顺序执行,也就是只有当一个包全部安装完成后,才会安装下一个。
Yarn通过并行操作最大限度地提高资源利用率,以至于再次下载的时候安装时间比之前更快。npm5之前是等上一个安装完后再执行下一个,串行下载。
最重要的——yarn.lock文件
我们知道npm中的package.json安装的包结构或者版本并不是一定一致的,因为package.json的写法是根据 语义版本控制 ——发布的补丁不应该包括任何实质性的修改。但是很不幸,这并不总是事实。npm 的策略可能会导致两台设备使用同样的 package.json 文件,但安装了不同版本的包,这可能导致故障。
举个例子:
无法保证一致性,拉取的 packages 可能版本不同。e.g. "5.0.3","~5.0.3","^5.0.3”
5.0.3”表示安装指定的5.0.3版本,“~5.0.3”表示安装5.0.X中最新的版本,“^5.0.3”表示安装5.X.X中最新的版本。同一个项目,由于安装的版本不一致出现bug。
针对这个问题,yarn推出了lock文件
为了防止拉取到不同的版本,Yarn 有一个锁定文件 (lock file) 记录了被确切安装上的模块的版本号。每次只要新增了一个模块,Yarn 就会创建(或更新)yarn.lock 这个文件。这么做就保证了,每一次拉取同一个项目依赖时,使用的都是一样的模块版本。
yarn.lock 只包含版本锁定,并不确定依赖结构,需要结合 package.json 确定依赖结构。这个在install的过程会进行详细解答。
yarn.lock锁文件把所有的依赖包都扁平化的展示了出来,对于同名包但是semver不兼容的作为不同的字段放在了yarn.lock的同一级结构中。
Yarn install
执行 yarn install 后会经过五个阶段:
Validating package.json(检查 package.json):检查运行环境
Resolving packages(解析包):整合依赖信息
Fetching packages(获取包):获取依赖包到缓存中
Linking dependencies(连接依赖):复制依赖到 node_modules
Building fresh packages(构建安装):执行 install 阶段的 scripts
-
检查(checking) 检查系统运行环境,包括OS、CPU、engines等信息
-
解析包(resolving packages) 首先根据项目 package.json 中 dependencies、devDependencies、optionalDependencies 字段形成首层依赖集合,之后对嵌套依赖逐级进行递归解析(将解析过和正在解析的包用一个 Set 数据结构来存储,保证同一个版本范围内的包不会被重复解析),结合 yarn.lock 和 Registry 获取包的具体版本、下载地址、hash值、子依赖等信息(过程中遵循依照 yarn.lock 优先原则)最终确定依赖版本信息、下载地址。过程总结为两部分:
收集首层依赖:将 package.json 中的 dependencies、devDependencies、optionalDependencies 依赖列表和 workspaces 中的顶级 packages 列表以 「包名@版本范围」 的格式整合为首层依赖集合,可以具象为一个字符串数组;
遍历****所有依赖,收集依赖具体信息:概括的说,从首层依赖集合出发,结合 yarn.lock 和 Registry 获取包的具体版本、下载地址、hash值、子依赖等信息。
-
获取包(fetching packages) 首先判断缓存目录中有没有缓存资源,其次读取文件系统,都不存在则从Registry进行下载
-
链接包(linking dependencies) 复制缓存至项目 node_modules 目录
首先解析 peerDependencies 信息,之后基于扁平化原则(yarn扁平化规则不同于npm,使用频率较大的版本会安装到顶层目录,这个过程称为dedupe),从缓存复制依赖至当前项目 node_modules 目录
- 构建包(building fresh package) 依赖包存在二进制文件进行构建
这个过程会执行 install 相关钩子,包括 preinstall、install、postinstall
3.0——pnpm
pnpm 代表 performant(高性能的)npm,如pnpm 官方介绍,它是:速度快、节省磁盘空间的软件包管理器,pnpm 本质上就是一个包管理器,它的两个优势在于:
-
包安装速度极快;
-
磁盘空间利用非常高效。
根据目前官方提供的 benchmark 数据可以看出在一些综合场景下比 npm/yarn 快了大概两倍:
那为什么pnpm能这么快呢?这与pnpm独特的link机制有关
link机制
Hard link
那么 pnpm 是怎么做到如此大的提升的呢?是因为计算机里面一个叫做 Hard link 的机制,hard link 使得用户可以通过不同的路径引用方式去找到某个文件。pnpm 会在全局的 store 目录里存储项目 node_modules 文件的 hard links 。
hard links可以理解为**源文件的副本,**项目里安装的其实是副本,它使得用户可以通过路径引用查找到源文件,
同时,pnpm 会在全局 store 里存储硬链接,不同的项目可以从全局 store 寻找到同一个依赖,大大地节省了磁盘空间。
hard links指通过索引节点来进行连接。在 Linux 的文件系统中,保存在磁盘分区中的文件不管是什么类型都给它分配一个编号,称为索引节点号(Inode Index)。在 Linux 中,多个文件名指向同一索引节点是存在的。比如:A 是 B 的硬链接(A 和 B 都是文件名),则 A 的目录项中的 inode 节点号与 B 的目录项中的 inode 节点号相同,即一个 inode 节点对应两个不同的文件名,两个文件名指向同一个文件,A 和 B 对文件系统来说是完全平等的。删除其中任何一个都不会影响另外一个的访问。
举个例子:
echo "111" > a
ln a b// linux中创建hard link
然后我们进行打印,可以看到结果是一样的
cat a --> 111
cat b --> 111
如果我们尝试下删除 a文件,此时我们可以看到
rm a
cat a --> No such file or directory
cat b --> 111
我们尝试恢复a
echo "222" > a
cat a --> 222
cat b --> 111
文件删除后再恢复内容,那么hardlink的link关系将不再维持,后续所有变更不会同步到hardlink里
Symbolic link
也叫软连接,可以理解为快捷方式,pnpm 可以通过它找到对应磁盘目录下的依赖地址。软链接文件只是其源文件的一个标记,当删除了源文件后,链接文件不能独立存在,虽然仍保留文件名,但却不能查看软链接文件的内容了。
举个例子:
echo "111" > a
ln -s a c
此时 a、c的结果为
cat a --> 111
cat c --> 111
我们看到 a、c的结果保持同步,如果我们尝试下删除 a文件,此时我们可以看到
rm a
cat a --> No such file or directory
cat c --> No such file or directory
此时可以看到,c的内容一并被删除,我们再尝试将a的内容复原
echo "222" > a
cat a --> 222
cat c --> 222
删除文件会影响symlink的内容,文件删除后再恢复内容,但是仍然会和symlink保持同步,链接文件甚至可以链接不存在的文件,这就产生一般称之为”断链”的现象。
pnpm的link
执行 pnpm install。
你会发现它打印了这样一句话:
包是从全局 store 硬连接到虚拟 store 的,这里的虚拟 store 就是 node_modules/.pnpm。
我们打开 node_modules 看一下:
确实不是扁平化的了,依赖了 express,那 node_modules 下就只有 express。
展开 .pnpm 看一下:
所有的依赖都在这里铺平了,都是从全局 store 硬连接过来的,然后包和包之间的依赖关系是通过软链接组织的。
比如 .pnpm 下的 expresss,这些都是软链接,
也就是说,所有的依赖都是从全局 store 硬连接到了 node_modules/.pnpm 下,然后之间通过软链接来相互依赖。
官方给了一张原理图,配合着看一下就明白了:
这就是 pnpm 的实现原理。
优势之处
这套全新的机制设计地十分巧妙,不仅兼容 node 的依赖解析,同时也解决了:
-
幽灵依赖问题:只有直接依赖会平铺在 node_modules 下,子依赖不会被提升,不会产生幽灵依赖。
-
依赖分身问题:相同的依赖只会在全局 store 中安装一次。项目中的都是源文件的副本,几乎不占用任何空间,没有了依赖分身。
-
最大的优点是节省磁盘空间,一个包全局只保存一份,剩下的都是软硬连接
不足之处
-
全局hardlink也会导致一些问题,比如改了link的代码,所有项目都受影响,比如对postinstall不友好,例如在postinstall里修改了代码,可能导致其他项目出问题,pnpm 默认就是 copy on write pnpm.io/npmrc#packa… ,但是 copy on write 用了但没完全用github.com/pnpm/pnpm/i…,这个配置对mac没生效,其实是node没支持导致的
-
由于 pnpm 创建的 node_modules 依赖软链接,因此在不支持软链接的环境中,无法使用 pnpm,比如 Electron 应用。
如何迁移
这个是前人给的迁移指南,但是我自己在迁移时并不是这样做的
个人步骤:
-
删除node_modules
-
直接执行pnpm i
-
执行pnpm dev,看控制台报错,看哪个包缺失,再给补上到package.json
为什么会有3呢,因为项目存在太多幽灵依赖了,所以我在想怎么去扫描代码的幽灵依赖呢?
幽灵依赖怎么办
初步思路
但是该npm包对我们项目的扫描存在一些问题,比如会全量扫描,没有去除一些不必要的文件和文件夹
对于项目设置的alias没有配置,依然会误报
而且扫描速度有限,不够迅速,所以这次可能使用swc来进行实现,swc 对比 babel,swc 有至少 10 倍以上的性能优势
个人目前有一个思路,暂时还未实现
总结下就是4步
-
扫文件
-
提取导入资源路径
-
提取包名
-
剔除package.json中存在的
设计思路:设计成一个cli工具,其中可以自定义一个config.js文件在项目根目录
module.exports = ={
ignoreFiles:[]// 填写一些不希望被扫描的文件后缀
ignoreDirs:[]// 填写一些不希望被扫描的文件夹后缀
alias:{
// 将项目配置别名,对引用路径进行映射的文件给注明,
//比如import xxx from '@/abc';可能会造成误报,将项目中设置的alias照搬就行了
}
}
转载自:https://juejin.cn/post/7196635893971877948