likes
comments
collection
share

📓 聊聊依赖管理

作者站长头像
站长
· 阅读数 24

前端开发者们每天都在接触 xxx install,包管理器是必不可少的工具,我们在项目开发的过程中会引用到各种不同的库,各种库又依赖了其他不同的库,这些依赖应该如何进行管理,今天这篇文章主要聊的就是依赖管理。

1.0——Npm

npm可以说是最早的依赖安装cli,我们先来看一下npm是怎么样安装依赖的吧

  1. 发出npm install命令

  2. npm 向 registry 查询模块压缩包的网址

  3. 下载压缩包,存放在~/.npm目录

  4. 解压压缩包到当前项目的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 目录结构。

举个例子,

A@1.0.0B@1.0.0

C@1.0.0B@2.0.0

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 中的 dependenciesdevDependenciesoptionalDependencies 依赖列表和 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 的依赖解析,同时也解决了:

  1. 幽灵依赖问题:只有直接依赖会平铺在 node_modules 下,子依赖不会被提升,不会产生幽灵依赖。

  2. 依赖分身问题:相同的依赖只会在全局 store 中安装一次。项目中的都是源文件的副本,几乎不占用任何空间,没有了依赖分身。

  3. 最大的优点是节省磁盘空间,一个包全局只保存一份,剩下的都是软硬连接

不足之处

  1. 全局hardlink也会导致一些问题,比如改了link的代码,所有项目都受影响,比如对postinstall不友好,例如在postinstall里修改了代码,可能导致其他项目出问题,pnpm 默认就是 copy on write pnpm.io/npmrc#packa… ,但是 copy on write 用了但没完全用github.com/pnpm/pnpm/i…,这个配置对mac没生效,其实是node没支持导致的

  2. 由于 pnpm 创建的 node_modules 依赖软链接,因此在不支持软链接的环境中,无法使用 pnpm,比如 Electron 应用。

如何迁移

dev.to/andreychern…

这个是前人给的迁移指南,但是我自己在迁移时并不是这样做的

个人步骤:

  1. 删除node_modules

  2. 直接执行pnpm i

  3. 执行pnpm dev,看控制台报错,看哪个包缺失,再给补上到package.json

为什么会有3呢,因为项目存在太多幽灵依赖了,所以我在想怎么去扫描代码的幽灵依赖呢?

幽灵依赖怎么办

初步思路

参考www.npmjs.com/package/@su…

但是该npm包对我们项目的扫描存在一些问题,比如会全量扫描,没有去除一些不必要的文件和文件夹

对于项目设置的alias没有配置,依然会误报

而且扫描速度有限,不够迅速,所以这次可能使用swc来进行实现,swc 对比 babel,swc 有至少 10 倍以上的性能优势

个人目前有一个思路,暂时还未实现

总结下就是4步

  1. 扫文件

  2. 提取导入资源路径

  3. 提取包名

  4. 剔除package.json中存在的

设计思路:设计成一个cli工具,其中可以自定义一个config.js文件在项目根目录

module.exports = ={
    ignoreFiles:[]// 填写一些不希望被扫描的文件后缀
    ignoreDirs:[]// 填写一些不希望被扫描的文件夹后缀
    alias:{
        // 将项目配置别名,对引用路径进行映射的文件给注明,
        //比如import xxx from '@/abc';可能会造成误报,将项目中设置的alias照搬就行了
    }
    
    
}