玖叶教程网

前端编程开发入门

聊聊依赖管理(依赖管理工具是什么意思)

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

npm

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

  1. 发出 npm install 命令;
  2. npm 向 registry 查询模块压缩包的网址;
  3. 下载压缩包,存放在 ~/.npm 目录;
  4. 将压缩包解压到当前项目的 node_modules 目录。

针对 npm2npm3 还是有区别的。

npm2

嵌套地狱

npm2 安装依赖的时候比较简单直接,直接按照包依赖的树形结构下载填充本地目录结构,也就是嵌套的 node_modules 结构,直接依赖会平铺在 node_modules 下,子依赖嵌套在直接依赖的 node_modules 中。

比如项目依赖了 A 和 C,而 A 和 C 依赖了相同版本的 [email protected],而且 C 还依赖了 [email protected],node_modules 结构如下:

node_modules
├── [email protected]
│   └── node_modules
│       └── [email protected]
└── [email protected]
    └── node_modules
        └── [email protected]
        └── [email protected]

可以看到同版本的 B 分别被 A 和 C 安装了两次。

如果依赖的层级越多,且依赖包数量越多,久而久之,就会形成嵌套地狱:

npm3

扁平化嵌套、不确定性、依赖分身、幽灵依赖

扁平化嵌套

针对 npm2 存在的问题,npm3 提出新的解决方案,将依赖进行展平,也就是扁平化。

npm v3 将子依赖「提升」(hoist),采用扁平的 node_modules 结构,子依赖会尽量平铺安装在主依赖项所在的目录中。

举个例子,项目依赖了 A 和 C,而 A 依赖了 [email protected],而且 C 还依赖了 [email protected]

node_modules
├── [email protected]
├── [email protected]
└── [email protected]
     └── node_modules
          └── [email protected]

可以看到 A 的子依赖的 [email protected] 不再放在 A 的 node_modules 下了,而是与 A 同层级。而 C 依赖的 [email protected] 因为版本号原因还是放到了 C 的 node_modules 下。

这样不会造成大量包的重复安装,依赖的层级也不会太深,解决了依赖地狱问题。

那为什么不把 [email protected] 提到 node_modules 而是 [email protected] 呢?而且将 B 直接提取到我们的 node_modules,是不是意味着我们可以在代码直接引用 B 包?由此引出我们下面的问题:

不确定性

我们对于这种处理方式其实很容易有一个疑问,如果我同时引用了同一个包的多个不同版本,会帮我把哪个包提出来,同时我每次 npm i 之后提出来的包版本都是一样的吗?这也意味着同样的 package.json 文件,install 依赖后可能不会得到同样的 node_modules 目录结构。

举个例子:

install 后究竟应该提升 B 的 1.0 还是 2.0?

node_modules
├── [email protected]
├── [email protected]
└── [email protected]
     └── node_modules
         └── [email protected]
node_modules
├── [email protected]
│   └── node_modules
│       └── [email protected]
├── [email protected]
└── [email protected]

网上大部分说法是会根据 package.json 里面的顺序决定谁会被提出来,放在前面的包依赖的内容会被先提出来,看源码后,npm 其实会调用一个叫做 localeCompare 的方法对依赖进行一次排序,实际上就是字典序在前面的 npm 包的底层依赖会被优先提出来。

幽灵依赖

什么叫做幽灵依赖,也就是我的 package.json 没有指明这个包,但实际项目使用了这个包,且这个包因为扁平化嵌套导致了可以直接使用,也就是非法访问,最经常碰到的就是 dayjs 这个包。

比如我的项目使用了 arco,但是 arco 的子依赖有 dayjs,那么根据扁平化,dayjs 就会被放在 node_modules 的首层。

但是存在很大的问题,一旦 arco 去掉了这个子依赖,那么我们的代码就直接报错了。

依赖分身

假设继续再安装依赖 [email protected] 的 D 模块和依赖 @B2.0 的 E 模块,此时:

以下是提升 [email protected] 的 node_modules 结构:

node_modules
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
│    └── node_modules
│         └── [email protected]
└── [email protected]
      └── node_modules
           └── [email protected]

可以看到 [email protected] 会被安装两次,实际上无论提升 [email protected] 还是 [email protected],都会存在重复版本的 B 被安装,这两个重复安装的 B 就叫 doppelgangers。

而且虽然看起来模块 C 和 E 都依赖 [email protected],但其实引用的不是同一个 B,假设 B 在导出之前做了一些缓存或者副作用,那么使用者的项目就会因此而出错。

npm install

npm3 以上的版本安装依赖的步骤:

  • 检查配置:读取 npm config.npmrc 配置,比如配置镜像源。
registry = 'https://bnpm.byted.org/'

sass_binary_site=https://bnpm.bytedance.net/mirrors/node-sass
electron_mirror=https://bnpm.bytedance.net/mirrors/electron/
puppeteer_download_host=https://bnpm.bytedance.net/mirrors

strict-peer-dependencies=false
  • 确定依赖版本,构建依赖树:检查是否存在 package-lock.json。若存在,进行版本比对,处理方式和 npm 版本有关,根据最新 npm 版本处理规则,版本能兼容按照 package-lock 版本安装,反之按照 package.json 版本安装;若不存在,根据 package.json 确定依赖包信息。
  • 检查缓存或下载:判断是否存在缓存。若存在,将对应缓存解压到 node_modules 下,生成 package-lock.json;若不存在,则下载资源包,验证包完整性并添加至缓存,之后解压到 node_modules 下,生成 package-lock.json

不足之处

安装速度慢,没有解决扁平化带来的算法复杂性、幽灵依赖等本质问题;

yarn

并行安装

无论何时 npm 或者 yarn 需要安装包,都会产出一系列的任务。使用 npm 时,这些任务按包顺序执行,也就是只有当一个包全部安装完成后,才会安装下一个。

Yarn 通过并行操作最大限度地提高资源利用率,以至于再次下载的时候安装时间比之前更快。npm5 之前是等上一个安装完后再执行下一个,串行下载。

最重要的 - yarn.lock 文件

我们知道 npm 中的 package.json 安装的包结构或者版本并不是一定一致的,因为 package.json 的写法是根据 语义版本控制[1] ——发布的补丁不应该包括任何实质性的修改。但是很不幸,这并不总是事实。npm 的策略可能会导致两台设备使用同样的 package.json 文件,但安装了不同版本的包,这可能导致故障。

举个例子,无法保证一致性,拉取的 packages 可能版本不同,例如: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:对于 ~5.0.3,有可能 A 的电脑上是安装了 5.0.4,B 的电脑是 5.0.5,而且这个包在 5.0.5 出现了 break change,那么很不幸,项目很可能将会出错。

针对这个问题,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.jsondependenciesdevDependenciesoptionalDependencies 字段形成首层依赖集合,之后对嵌套依赖逐级进行递归解析(将解析过和正在解析的包用一个 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 相关钩子,包括 preinstallinstallpostinstall

pnpm

pnpm 代表 performant(高性能的)npm,如 pnpm 官方介绍,它是:速度快、节省磁盘空间的软件包管理器,pnpm 本质上就是一个包管理器,它的两个优势在于:

  • 包安装速度极快;
  • 磁盘空间利用非常高效。

根据目前官方提供的 benchmark[2] 数据可以看出在一些综合场景下比 npm/yarn 快了大概两倍:

那为什么 pnpm 能这么快呢?

这与 pnpm 独特的 link 机制有关。

link 机制

Hard link

那么 pnpm 是怎么做到如此大的提升的呢?是因为计算机里面一个叫做 Hard link[3] 的机制,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

Symbolic link[4] 也叫软连接,可以理解为快捷方式,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 看一下:

确实不是扁平化的了,依赖了 solid-js,那 node_modules 下就只有 solid-js。

展开 .pnpm 看一下:

所有的依赖都在这里铺平了,都是从全局 store 硬连接过来的,然后包和包之间的依赖关系是通过软链接组织的。

比如 .pnpm 下的 solid-js,这些都是软链接:

也就是说,所有的依赖都是从全局 store 硬连接到了 node_modules/.pnpm 下,然后之间通过软链接来相互依赖。

官方给了一张 pnpm 的实现原理图,配合着看一下就明白了:

优势

这套全新的机制设计地十分巧妙,不仅兼容 node 的依赖解析,同时也解决了以下问题:

  1. 幽灵依赖问题:只有直接依赖会平铺在 node_modules 下,子依赖不会被提升,不会产生幽灵依赖。
  2. 依赖分身问题:相同的依赖只会在全局 store 中安装一次。项目中的都是源文件的副本,几乎不占用任何空间,没有了依赖分身。
  3. 最大的优点是节省磁盘空间,一个包全局只保存一份,剩下的都是软硬连接。

不足之处

  1. 全局 hardlink 也会导致一些问题,比如改了 link 的代码,所有项目都受影响;对 postinstall 不友好;在 postinstall 里修改了代码,可能导致其他项目出问题。pnpm 默认就是 copy on write[5],但是 copy on write 这个配置对 mac 没生效,其实是 node 没支持导致的,参见 issue[6]
  2. 由于 pnpm 创建的 node_modules 依赖软链接,因此在不支持软链接的环境中,无法使用 pnpm,比如 Electron 应用。

如何迁移

How to migrate from yarn / npm to pnpm?[7]

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

本人迁移步骤如下:

  1. 删除 node_modules;
  2. 直接执行 pnpm i;
  3. 执行 pnpm dev,看控制台报错,看哪个包缺失,再给补上到 package.json。

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

幽灵依赖怎么办

初步思路

参考:https://www.npmjs.com/package/@sugarat/ghost。

但是该 npm 包对我们项目的扫描存在一些问题,比如会全量扫描,没有去除一些不必要的文件和文件夹。对于项目设置的 alias 没有配置,依然会误报,而且扫描速度有限,不够迅速,所以这次可能使用 swc 来进行实现,与 babel 相比,swc 至少有 10 倍以上的性能优势。

个人目前有一个思路,暂时还未实现,总结为以下 4 个步骤:

  1. 扫文件;
  2. 提取导入资源路径;
  3. 提取包名;
  4. 剔除 package.json 中存在的。

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

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

参考资料

[1]

语义版本控制: https://semver.org/

[2]

benchmark: https://pnpm.io/benchmarks

[3]

Hard link: https://en.wikipedia.org/wiki/Hard_link

[4]

Symbolic link: https://baike.baidu.com/item/%E8%BD%AF%E9%93%BE%E6%8E%A5/7177481

[5]

pnpm 默认就是 copy on write: https://pnpm.io/npmrc#package-import-method

[6]

参见 issue: https://github.com/pnpm/pnpm/issues/2761

[7]

How to migrate from yarn / npm to pnpm?: https://dev.to/andreychernykh/yarn-npm-to-pnpm-migration-guide-2n04

作者:林晓辉

来源:微信公众号:字节前端 ByteFE

出处:https://mp.weixin.qq.com/s/9JCs3rCmVuGT3FvKxXMJwg

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言