简述前端包管理工具机制和相关实践

634 阅读9分钟

npm 依赖管理机制

区别于 Python 的包管理工具 pip 的全局安装,npm 会安装依赖包到当前项目目录,使不同项目的依赖更成体系,这样做的好处是减轻了包作者的 API 兼容性压力;但是缺陷是如果两个项目依赖了一个相同的库,一般这个库会在这两个项目中各安装一次,即相同的依赖包会被多次安装。
我们先通过一张流程图(源自掘金)来了解下 npm install 的整体流程

可以看到执行 npm install 后依次会进行以下流程

  • 检查 package-lock.json
  • 通过和 package.json 对比确定是否远程获取包信息
  • 扁平化构建依赖树
  • 添加缓存
  • 下载包并解压到 node_modules
  • 生成新的 lock 文件 值得注意的是,早期 npm 版本(v5.0 - v5.4)发现 package.json 和 package-lock.json 不一致时,对依赖的安装方式是不一样的。所以对于团队而言,最佳实践应该是保持 npm 版本的一致性!

缓存机制

我们可以从流程图中看到,npm install 的流程中会查找和使用缓存,以及下载包后会添加缓存的环节。由于依赖嵌套机制,项目中 node_moudles 占用的磁盘空间无疑是最大的,如果安装时每次都通过网络下载获取,那么时间成本是巨大的。常见的优化方式是“空间换时间”,npm 也通过缓存机制来解决这个问题。
简单了解下缓存的目录的和清除机制。
通过 npm config get cache 命令可以查询到缓存目录:默认是用户主目录下的 .npm/_cacache 目录。
npm cache clean --force 即可强制清除缓存。

yarn 带来了什么?

yarn 是于 2016 年诞生的,它的出现解决了历史上 npm 的很多问题,比如缺乏对于依赖的完整性和一致性保障(npm v3 版本还没有 package-lock.json),以及 npm 安装速度过慢的问题等。npm 目前已经迭代到 v8 版本,在很多方面已经借鉴了 yarn 的优点,但是我们不妨了解下 yarn 诞生时带来的理念。

  1. 确定性。通过 yarn.lock 等机制,保证了确定性,这里的确定性包括但不限于明确的依赖版本、明确的依赖安装结构等。即在任何机器和环境下,都可以以相同的方式被安装。
  2. 模块扁平化安装。将依赖包的不同版本,按照一定策略,归结为单个版本,以避免创建多个副本造成冗余。
  3. 更快的速度。yarn 采取并行安装的机制进行包的安装任务,提高了性能;yarn 引入的缓存机制使二次安装的速度更快。
  4. 更好的语义化。yarn 的命令更加简洁。 解决早期 npm 的依赖管理问题

文章的开始提到 npm 是将依赖放到项目的 node_modules 中,同时如果 node_modules 中的依赖 A 还依赖了其他依赖 B,那么 B 也会被安装到 A 的 node_modules 文件夹,依次递归最终形成非常复杂和庞大的依赖树。
这种依赖管理方式会随着项目的迭代,node_moudles 会变得越来越复杂,从而造成:

  • 非常深的项目依赖层级,难以排查问题
  • 依赖被重复安装,浪费磁盘,网络等资源,安装速度慢 那么 yarn 是如何解决这个问题的呢? 那就是模块扁平化安装机制。 假如我们有这样一个文件依赖结构。
App
 -a@2.0
   -b@2.0
 -b@2.0
 -c@1.0
   -b@2.0

yarn 在安装依赖时会打平依赖,并对重复依赖进行提升,最终形成的依赖结构如下:

App
 -a@2.0
 -b@2.0
 -c@1.0

但是需要注意的是:模块的安装顺序可能影响 node_modules 内的文件结构。 在 npm v3 版本中,假如 项目一开始依赖了 a@1.0,此时 a@1.0 会被安装在顶层目录;随着迭代,又引入了模块 b@1.0,而 b@1.0 又依赖了 a@2.0,此时 a@2.0 会被安装在 b@1.0 下,因为顶层已经有一个 a@1.0 了。

pnpm: 最先进的包管理工具?

在各个场景下,pnpm 相比较于 npm(v8)和 yarn(v3)在性能上都有不错的提升。
pnpm 之所以有如此大的性能提升,简单来说 pnpm 是通过全局 store(目录 ${os.homedir}/.pnpm-store)来存储 node_modules 依赖的 hard-links,当在项目文件中引用依赖的时候则是通过 symlink 去找到对应虚拟磁盘目录下(.pnpm 目录)的依赖地址。相比于 npm 和 yarn 会在每个项目中都安装一份 node_moudles, pnpm 的全局 store 则实现了“安装一次,所有项目复用”,这样避免了二次安装带来的时间消耗。
除此之外,pnpm 本身的设计机制解决了 monorepo 的很多痛点,比如 ”幽灵依赖“”依赖重复安装“ 的问题。 如图: 下面两小节内容源自: pnpm: 最先进的包管理工具[1]

幽灵依赖

Phantom dependencies 被称之为幽灵依赖,解释起来很简单,即某个包没有被安装(package.json 中并没有,但是用户却能够引用到这个包)。
引发这个现象的原因一般是因为 node_modules 结构所导致的,例如使用 yarn 对项目安装依赖,依赖里面有个依赖叫做 foo,foo 这个依赖同时依赖了 bar,yarn 会对安装的 node_modules 做一个扁平化结构的处理(npm v3 之后也是这么做的),会把依赖在 node_modules 下打平,这样相当于 foo 和 bar 出现在同一层级下面。那么根据 nodejs 的寻径原理,用户能 require 到 foo,同样也能 require 到 bar。

package.json -> foo(bar 为 foo 依赖)
node_modules
  /foo
  /bar -> 👻依赖

那么这里这个 bar 就成了一个幽灵依赖,如果某天某个版本的 foo 依赖不再依赖 bar 或者 foo 的版本发生了变化,那么 require bar 的模块部分就会抛错。

依赖重复安装

这个问题其实也可以说是 hoist 导致的,这个问题可能会导致有大量的依赖的被重复安装,举个例子:
例如有个 package,下面依赖有 lib_a、lib_b、lib_c、lib_d,其中 a 和 b 依赖 util_e@1.0.0,而 c 和 d 依赖 util_e@2.0.0。
那么早期 npm 的依赖结构应该是这样的:

package
  - package.json
  - node_modules
     - lib_a
       - node_modules <- util_e@1.0.0
     - lib_b
       - node_modules <- util_e@1.0.0
     _ lib_c
       - node_modules <- util_e@2.0.0
     - lib_d
       - node_modules <- util_e@2.0.0

这样必然会导致很多依赖被重复安装,于是就有了 hoist 和打平依赖的操作:

package
  - package.json
  - node_modules
     - util_e@1.0.0
     - lib_a
     - lib_b
     _ lib_c
       - node_modules <- util_e@2.0.0
     - lib_d
       - node_modules <- util_e@2.0.0

但是这样也只能提升一个依赖,如果两个依赖都提升了会导致冲突,这样同样会导致一些不同版本的依赖被重复安装多次,这里就会导致使用 npm 和 yarn 的性能损失。
如果是 pnpm 的话,这里因为依赖始终都是存在 store 目录下的 hard links ,一份不同的依赖始终都只会被安装一次,因此这个是能够被彻彻底底的消除的。

项目中的相关场景实践和常见问题

npm link

适用场景: 本地调试 npm 模块,将模块链接到对应的业务项目中运行 使用方法:假如我们需要把模块 pkg-a 链接到主项目 App 中,首先在 pkg-a 根目录中执行 npm link,然后在 App 根目录中执行 npm link pkg-a 即可。调试完可以使用 npm unlink 取消关联。 原理:npm link 通过软连接将 pkg-a 链接到 node 模块的全局目录和可执行文件中,实现 npm 包命令的全局可执行。

npx

适用场景:在 npm 5.2.0 版本之后,npm 内置了 npx 的包。npx 是一个简单的 cli 工具,可以帮助我们快速的调试,还可以让我们在不通过 npm 安装包的前提下执行一些 npm 包。

使用方法:
Before: 一般情况下,如果我们想使用 es-lint, 会先通过 npm install es-lint, 然后在项目根目录执行 ./node_modules/.bin/es-lint your_file.js 或者 通过 package.json 的 npm scripts 调用 eslint。
After: npx es-lint your_file.js
原理:npx 在运行时会自动去 ./node_moudles/.bin 和 环境变量 寻找命令

是否提交 lock.json 到代码仓库

前面我们提到 yarn 带来了 .lock 文件的机制,使得在任何环境下执行 install,都能得到一致的 node_modules 安装结果。但是是否需要提交 lockfiles(package-lock.json/yarn.lock) 到代码仓库呢?
npm 官方文档[2] 是建议把 package-lock.json 文件提交到代码仓库的。在多人协作的项目中,这样做确实没有问题。但是如果开发的是库,在 npm publish 的时候最好忽略 lockfiles。因为库一般是被其他项目依赖的,在不使用 lockfiles 的情况下,由于新版 npm 和 yarn 的 hoist 机制,可以复用住项目已经加载过的包,减少依赖重复和体积。
但是存在这样一种现象:即使在一些发布时忽略 lockfiles 的库中,在主项目顶层存在相关依赖包的前提下,最终生成的 lockfile 仍然没复用主项目的包。这是为什么呢?原因是库的依赖包版本和主项目存在的依赖包版本不一致。具体看下图: 主项目的 yarn.lock 中显示 browser 这个包依赖了 @babel/runtime@7.0.0

主项目 node_modules 顶层的 @babel/runtime 版本为 7.10.1

知道了原因,那么如何减少库项目的依赖项呢。到这里,解决方案也就呼之欲出了:

  1. 库项目尽量使用和主项目版本一致的依赖包
  2. 在库项目 package.json 的 “peerDevpendencies” 字段中声明主项目已有的依赖包

合入其他分支代码后编译报错

相信很多同学都遇到过和我一样的问题:当自己的 feat 分支代码合入 master 或者业务班车分支的代码时,重新 yarn 时,有时候会编译失败,报大量 "can't resolve module xxx"的错误。这种错误有很多情况是依赖版本不一致的问题,但是又极其难以定位,令人头痛。那么此时有另外一个思路,那就是从 master 拉一个最新的分支再进行合入。
但更好的解决方式是:建议在日常开发过程中,定时合入 master 代码,一方面可以合入最新的 feat,另一方面可以避免长时间不合入,最后在上线阶段合入代码,可能出现大量冲突,解决不当或遗漏而造成的编译问题。同时也可以考虑将工具升级为 pnpm,以解决潜在的“幽灵依赖”和“依赖嵌套”问题,同时带来性能上的提升。

参考资料

[1]

pnpm: 最先进的包管理工具: bytedance.feishu.cn/docs/doccng…

[2]

npm官方文档: docs.npmjs.com/cli/v7/conf…