当前位置:   article > 正文

如何解决项目依赖重复打包问题

如何解决重复打包的问题

title: 如何解决项目依赖重复打包问题 tags: - webpack - vite categories: - 前端 author: 余腾靖

pubDatetime: 2024-03-22

由于最近面试经常被问到这个问题(简历上写了),感觉答的时候不是很系统清晰,于是便有了这篇文章。

为啥对这个问题这么上心?

在上家公司最后一段时间是做前端工程基建相关的,不说是最有成长的一段时间,但绝对是最开心的一段时间。上来第一个任务是优化项目构建体积,项目之前是 webpack 写的,做技术升级之后迁移到了 vite,包管理器也从 yarn1 迁移到 pnpm,但是迁移后发现主入口 bundle 的体积增加了很多,后面也是采用了多个方法对体积进行优化:

  • 去除重复依赖,有很多依赖由于引用到了不同版本被打包了多次,具体哪些包忘了,但是印象中 pnpm.overrides 很长
  • 按需导入,组件库是 ant design vue based,我知道最新版已经支持 tree-shaking,不需要配置按需导入,但当时项目用的版本很低了,毕竟还是 vue2。
  • 选择精简的版本,例如我们项目目只用到了 paper.js 的核心功能,但是默认 papermain 是指向 dist/paper-full.js(451KB) 你可以配置它指向 dist/paper-core.js(394KB)
  • 使用 importmap 将包从构建中分离,改为使用浏览器原生的 ESM 从 CDN 加载
  • 对于一些还在使用 rollup + babel 打包的 package 使用 babel-runtime 避免重复打包 helper 代码
  • 精准配置 browserslist,所有构建工具统一使用 package.jsonbrowserslist 字段读取浏览器兼容目标版本
  • 项目中同时使用了多个功能类型的 pkg,例如时间相关的 moment, dayjs, date-fns, 还有我记得生成二维码和处理 xlsx 的库也有多个,为了这个问题我还写了个 cli: find-similar-packages
  • 通知其它部门同事不要把 node_modules 打包到 dist 里面,把 ant design vuebabel-runtime 升级到 @babel/runtime,反正就是说有些包打包很不规范
  • ...

等等,离职混日子快半年了,暂时只能想起这些。

回到主题,在我所有的优化策略中,去除重复依赖减小打包体积的效果是占第二位的。第一位是 importmap,最简单的减小体积策略就是不打包。关于 importmap,有机会单独写一篇文章。

重复依赖是如何产生的?

在讨论之前,先介绍一些后面会提到的术语,确保我们在同一个频道上。

下面是一个典型的 monorepo 前端项目:

plaintext ./mono ├── apps // 应用级别的包 │ ├── mobile │ └── web ├── packages // 共享模块 │ ├── pkg1 │ ├── pkg2 │ ├── ui │ └── utils └── tools // 工具包 ├── eslint-config └── vite-config

apps, packages, tools 都叫 workspace, 里面 package 称之为 workspace packagemono 文件夹叫 root workspace

在包管理器的视角依赖可以分为两类:

  • 直接依赖:例如 workspace package uipackage.json 中声明的 axios
  • 间接依赖:例如 axios 依赖的 follow-redirects

按照用途分为两类:

  • 源码依赖:例如 apps/web/src 导入了 pkg1vuepkg1vue 以及它俩依赖树上的依赖称为源码依赖
  • 开发依赖:例如 vite, esbuild 不会被打包到 apps/web/dist 中的只在开发时使用的依赖,也包括它俩依赖树上的依赖

lockfile 的副作用

lockfile 可以帮我们确保安装的依赖完全一致,但是它却也是导致我们项目依赖安装多个版本的主要原因之一。

场景还原:

  1. 我们在 pkg1 中的 dependencies 声明 "foo": "^1.0.1"”,此时 foo 最新版本是 1.0.1,运行 pnpm install, 这时 lockfile 中写入了 pkg1foo 解析到的版本是 1.0.1,实际也是安装 1.0.1
  2. 某一天我们需要在 pkg2 中开发需求,pkg2 也要用到 foo,于是运行 pnpm --filter pkg2 add foo,在这段时间 foo 发布了 1.0.2,此时 pkg2 安装的就会是 1.0.2。我们的 app package 依赖 pkg1pkg2,于是打包 app 的时候就会打包 foo@1.0.1foo@1.0.2

你可能会说 pnpm 咋这么蠢,不会直接把 pkg1foo 也安装为 1.0.2 吗,1.0.2 是符合 ^1.0.1 的兼容性要求的呀。实际上按照我的理解,pnpm 之所以没这么做,是因为

  1. lockfile 里面已经声明了 pkg1foo 解析到的版本,所以会直接按照 lockfile 声明的版本来
  2. 因为 pkg 中 foo@1.0.1 之前已经验证测试时可用的,要是它帮你更新到 1.0.2 可能会出现 bug,为了确保项目的稳定默认不会帮你升级。semver 只是一个规范, 1.0.11.0.2 到底有没有引没有引入 breaking change 那你得去看 changelog 和源代码才能确定。

不兼容版本

一个很典型的例子就是去年 axios 发布了 1.x,项目中同时存在 0.x1.x。草,axios 这么古老的项目直到去年才发 1.x 你敢信?esbuildreact native 加把油,希望我退休之前能发 1.0

即便依赖树上的包在 dependencies 中声明 axios 的时候都使用了兼容性前缀 ^,但是对于这种不符合兼容性前缀的多个版本,pnpm 就没法通过后文会详细介绍的删 lockfile 重装或者跑 pnpm dedupe 来解决了。

除此之外还有换包名这种特殊情况,例如 babel-runtime -> @babel/runtime

peerDependencies

我们知道,在 node_modules/.pnpm 里面存放的是缓存的硬链接,它的命名格式是 pkgName@pkgVersion_peer1Name@peer1Version_peer2Name@peer2Version,简单来说就是由本身的包名,版本号和它的所有 peerDependencies 包名,版本号组成,使用 _ 连接,在 lockfile 中是用括号,例如 react@16.8.0(typescript@8.0.0)

实际的项目中就可能在 .pnpm 下看到同一个包名和版本号,由于实际依赖的 peerDependencies 不同安装了多次。

```plaintext

workspace package

packages/pkg1/package.json

{ "dependencies": { "vue": "2.6.14", "@yutengjing/foo": "1.0.3" // 对应硬链接 @yutengjing+foo@1.0.3_vue@2.6.14 } }

workspace package

packages/pkg2/package.json

{ "dependencies": { "vue": "2.7.16", "@yutengjing/foo": "1.0.3" // 对应硬链接 @yutengjing+foo@1.0.3_vue@2.7.16 } }

间接依赖

@yutengjing/foo

{ "peerDependencies": { "vue": "^2.7.16" } }

nodemodules .pnpm @yutengjing+foo@1.0.3vue@2.6.14 nodemodules @yutengjing/foo@1.0.3 vue@2.6.14 @yutengjing+foo@1.0.3vue@2.7.16 node_modules @yutengjing/foo@1.0.3 vue@2.7.16

```

由于本地安装的 peerDependencies vue 的版本不同,且都不符合兼容性要求 ^2.7.16,实际上会存在两个硬链接依赖 @yutengjing+foo@1.0.3_vue@2.6.14@yutengjing+foo@1.0.3_vue@2.7.16

虽然都是 @yutengjing+foo@1.0.3, 但是由于是不同的硬链接,被视为不同的文件,就会打包多次。

peerDeps.png

关于 peerDependencies 在 pnpm 中是怎么解析的,可以阅读:How peers are resolved

文章写到这我才发现后面要介绍的我写的分析重复依赖的插件有 bug,因为我没考虑同一个依赖同一个版本被打包多次的情况,马上修!

顺便提一下上面 pkg1 和 pkg2 依赖的 vue 如果都使用兼容性前缀(也就是 ^2.6.14^2.7.16),pnpm 最新版是可以自动去重的,硬链接到 node_modules/.pnpm 下的 @yutengjing+foo@1.0.3_vue@2.7.16

其它情况

之前有发现某些重复依赖总是干不掉,仔细查看依赖路径发现有个别不规范的 package 构建的时候把 dependencies 也打包到 dist 了。

如何发现重复依赖?

下面介绍下我使用过的方法和工具。

直接看依赖分布图

对于一个在 3 年前应届时期就是 webpack 老鸟的我,第一步当然是装上一些构建分析工具来看看项目里面打包了哪些妖魔鬼怪。于是找下 vite 的 bundle 可视化工具,没找到,于是搜了搜 rollup 相关的插件,被我找到 rollup-plugin-visualizer。要咱说为什么 vite 能这么成功,兼容 rollup 插件很关键啊。当时我对 vite 其实一点都不熟,也没那么多 vite 插件,很多插件都是用 rollup 的,就非常佩服 vite 兼容 rollup 插件的这个设计。要问 rspackturbopack 更看好谁?那肯定是 rspackrspack 兼容 webpack 生态呀,看看 bundeno 现在的流行趋势还不够说明问题吗?

我记得我当时分析我们公司那个项目的时候当时就发现打包了好几个 ant design vue,对于那些体积较大的依赖一眼就能看出来。然后可以试着过滤一些非常常用的第三方依赖,像 vue, vue-router, lodash 这些。

拿 github star 21k 的 vue-vben-admin 做测试,本来想用网红项目 elk 的,但是这个项目构建有点复杂,会构建好几次,我对 nuxt 也不熟,还是算了。

bundle-visualizer.png

rollup-plugin-visualizer 这个插件建议用 5.8.3 版本,最新版过滤不太好用,参考 issue:new Include and Exclude is hard to understand and use

基于 lockfile 分析的 cli

早在 pnpm dedupe 出来之前,我一直用的是 pnpm-deduplicate,效果很直观:

node Package "@babel/runtime" wants ^7.17.10 and could get 7.18.2, but got 7.18.0 Package "@babel/runtime" wants ^7.15.5 and could get 7.18.2, but got 7.18.0 Package "@babel/runtime" wants ^7.7.5 and could get 7.18.2, but got 7.18.0 Package "@babel/generator" wants ^7.7.2 and could get 7.18.2, but got 7.18.0 Package "@babel/generator" wants ^7.18.0 and could get 7.18.2, but got 7.18.0

整挺好,但是和 pnpm 一样只要是基于 lockfile 分析的工具都有一个通病,就是无法区分开发依赖和源码依赖。另外,在实际的 monorepo 项目中,你可能存在多个 app 需要构建,可能不同的 app 依赖了不同版本也会被扫描出来。

有时候为了确保某个重复依赖是否被干掉,你也可以直接去 pnpm-lock.yaml 里面搜索那个 package,还可以借助 pnpm why 去查看一个包是因为什么原因被安装的。

打包工具插件

最开始我搜到了 duplicate-package-checker-webpack-plugin,但是我们项目用的是 vite,于是熟悉 vite 插件开发的机会来了,当时 unplugin 挺火,可以让你用 rollup 插件系统的钩子写跨构建工具的插件,于是在借鉴了前面提到的 webpack 插件和 chatgpt 的帮助下很快就写出了 unplugin-detect-duplicated-deps

还是拿项目 vue-vben-admin 来做测试,如果使用 pnpm dedupe,结果是:

vben-pnpm-dedupe.png

如果使用我写的插件:

vben-unplugin.png

vue-typespnpm dedupe 中检测不出来好理解,因为是跨大版本号。那 sortablejs 为啥没检查出来? axios 怎么又被检查出来了?

我们可以借助 pnpm why 来分析依赖是怎么被引入项目的:

```node ❯ pnpm why -r axios Legend: production dependency, optional only, dev only

vben-admin@2.10.1 /Users/yutengjing/code/vue-vben-admin

dependencies: axios 1.6.7

devDependencies: unplugin-detect-duplicated-deps 1.1.1 └── axios 1.6.8

@vben/vite-config@1.0.0 /Users/yutengjing/code/vue-vben-admin/internal/vite-config

devDependencies: vite-plugin-purge-icons 0.10.0 ├─┬ @purge-icons/core 0.10.0 │ └── axios 0.26.1 └─┬ rollup-plugin-purge-icons 0.10.0 └─┬ @purge-icons/core 0.10.0 └── axios 0.26.1 ```

查看发现一个版本是源码依赖 /Users/yutengjing/code/vue-vben-admin/package.json 依赖了 axios@1.6.7,另外两个是开发依赖:unplugin-detect-duplicated-deps 依赖了 axios@1.6.8@purge-icons/core 依赖了 axios@0.26.1

```node ❯ pnpm why sortablejs vben-admin@2.10.1 /Users/yutengjing/code/vue-vben-admin

dependencies: sortablejs 1.15.2 vuedraggable 4.1.0 └── sortablejs 1.14.0 ```

打开 vuedraggablepackage.json 看了一下,发现居然是使用固定版本号声明的 "sortablejs": "1.14.0",怪不得 pnpm dedupe 检测不出来。

原理

面试经常被问到 webpack 插件是怎么工作的,其实 webpack 和其他它很多构建工具的插件的开发方式都一样,插件本质上就是去订阅构建工具暴露的扩展事件(或者说钩子),在回调编写你的扩展逻辑。

核心逻辑:

  1. 再打包工具解析完模块 id 后获取其对应的文件绝对路径
  2. 从文件当前目录往上找 package.json,确定这个模块是哪个包,以及哪个版本引入的
  3. 构建结束后分析收集到的模块信息,对于那些同一个依赖出现了多个版本,则被视为有问题的依赖
  4. 当有依赖被重复打包则输出有问题的依赖信息

第一步可以使用 rollupresolveId 事件,第三步则可以使用 buildEnd 事件。

显示包的体积

只是列出了被重复打包的依赖,感觉对构建体积的影响不够直观,于是便借助 https://bundlephobia.com/ 的 api 来获取依赖的体积并展示,接口很简单:

plaintext https://bundlephobia.com/api/size?package=${name}@${version}

其实我一直不太理解为啥用 import cost vscode 插件的人那么多,这玩意因为获取一个包体积需要跑一次 webpack,所以又慢又吃资源,直接用 api 请求不香吗?

如何预防再次出现重复依赖

最初借助这个插件把一些重复依赖干掉后,过了一段时间,发现又多了一些新的重复依赖。对于开发效率有极致追求的我,受不了自己再处理一遍,谁拉的

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/很楠不爱3/article/detail/606143
推荐阅读
相关标签