赞
踩
title: 如何解决项目依赖重复打包问题 tags: - webpack - vite categories: - 前端 author: 余腾靖
由于最近面试经常被问到这个问题(简历上写了),感觉答的时候不是很系统清晰,于是便有了这篇文章。
在上家公司最后一段时间是做前端工程基建相关的,不说是最有成长的一段时间,但绝对是最开心的一段时间。上来第一个任务是优化项目构建体积,项目之前是 webpack 写的,做技术升级之后迁移到了 vite,包管理器也从 yarn1 迁移到 pnpm,但是迁移后发现主入口 bundle 的体积增加了很多,后面也是采用了多个方法对体积进行优化:
pnpm.overrides
很长ant design vue
based,我知道最新版已经支持 tree-shaking,不需要配置按需导入,但当时项目用的版本很低了,毕竟还是 vue2。paper.js
的核心功能,但是默认 paper
包 main
是指向 dist/paper-full.js(451KB)
你可以配置它指向 dist/paper-core.js(394KB)
,importmap
将包从构建中分离,改为使用浏览器原生的 ESM 从 CDN 加载rollup
+ babel
打包的 package 使用 babel-runtime
避免重复打包 helper 代码browserslist
,所有构建工具统一使用 package.json
中 browserslist
字段读取浏览器兼容目标版本moment
, dayjs
, date-fns
, 还有我记得生成二维码和处理 xlsx
的库也有多个,为了这个问题我还写了个 cli: find-similar-packagesnode_modules
打包到 dist
里面,把 ant design vue
的 babel-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 package
,mono
文件夹叫 root workspace
。
在包管理器的视角依赖可以分为两类:
ui
的 package.json
中声明的 axios
axios
依赖的 follow-redirects
按照用途分为两类:
apps/web/src
导入了 pkg1
和 vue
,pkg1
和 vue
以及它俩依赖树上的依赖称为源码依赖vite
, esbuild
不会被打包到 apps/web/dist
中的只在开发时使用的依赖,也包括它俩依赖树上的依赖lockfile 可以帮我们确保安装的依赖完全一致,但是它却也是导致我们项目依赖安装多个版本的主要原因之一。
场景还原:
pkg1
中的 dependencies
声明 "foo": "^1.0.1"”
,此时 foo
最新版本是 1.0.1
,运行 pnpm install
, 这时 lockfile 中写入了 pkg1
的 foo
解析到的版本是 1.0.1
,实际也是安装 1.0.1
pkg2
中开发需求,pkg2
也要用到 foo
,于是运行 pnpm --filter pkg2 add foo
,在这段时间 foo
发布了 1.0.2
,此时 pkg2
安装的就会是 1.0.2
。我们的 app package 依赖 pkg1
和 pkg2
,于是打包 app 的时候就会打包 foo@1.0.1
和 foo@1.0.2
你可能会说 pnpm 咋这么蠢,不会直接把 pkg1
的 foo
也安装为 1.0.2
吗,1.0.2
是符合 ^1.0.1
的兼容性要求的呀。实际上按照我的理解,pnpm 之所以没这么做,是因为
pkg1
的 foo
解析到的版本,所以会直接按照 lockfile 声明的版本来foo@1.0.1
之前已经验证测试时可用的,要是它帮你更新到 1.0.2
可能会出现 bug,为了确保项目的稳定默认不会帮你升级。semver 只是一个规范, 1.0.1
到 1.0.2
到底有没有引没有引入 breaking change
那你得去看 changelog
和源代码才能确定。一个很典型的例子就是去年 axios
发布了 1.x
,项目中同时存在 0.x
和 1.x
。草,axios
这么古老的项目直到去年才发 1.x
你敢信?esbuild
和 react native
加把油,希望我退休之前能发 1.0
。
即便依赖树上的包在 dependencies
中声明 axios
的时候都使用了兼容性前缀 ^
,但是对于这种不符合兼容性前缀的多个版本,pnpm 就没法通过后文会详细介绍的删 lockfile 重装
或者跑 pnpm dedupe
来解决了。
除此之外还有换包名这种特殊情况,例如 babel-runtime
-> @babel/runtime
我们知道,在 node_modules/.pnpm
里面存放的是缓存的硬链接,它的命名格式是 pkgName@pkgVersion_peer1Name@peer1Version_peer2Name@peer2Version
,简单来说就是由本身的包名,版本号和它的所有 peerDependencies 包名,版本号组成,使用 _
连接,在 lockfile
中是用括号,例如 react@16.8.0(typescript@8.0.0)
。
实际的项目中就可能在 .pnpm
下看到同一个包名和版本号,由于实际依赖的 peerDependencies 不同安装了多次。
```plaintext
{ "dependencies": { "vue": "2.6.14", "@yutengjing/foo": "1.0.3" // 对应硬链接 @yutengjing+foo@1.0.3_vue@2.6.14 } }
{ "dependencies": { "vue": "2.7.16", "@yutengjing/foo": "1.0.3" // 对应硬链接 @yutengjing+foo@1.0.3_vue@2.7.16 } }
{ "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
, 但是由于是不同的硬链接,被视为不同的文件,就会打包多次。
关于 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
插件的这个设计。要问 rspack
和 turbopack
更看好谁?那肯定是 rspack
。rspack
兼容 webpack
生态呀,看看 bun
和 deno
现在的流行趋势还不够说明问题吗?
我记得我当时分析我们公司那个项目的时候当时就发现打包了好几个 ant design vue
,对于那些体积较大的依赖一眼就能看出来。然后可以试着过滤一些非常常用的第三方依赖,像 vue
, vue-router
, lodash
这些。
拿 github star 21k 的 vue-vben-admin 做测试,本来想用网红项目 elk 的,但是这个项目构建有点复杂,会构建好几次,我对 nuxt
也不熟,还是算了。
rollup-plugin-visualizer
这个插件建议用 5.8.3 版本,最新版过滤不太好用,参考 issue:new Include and Exclude is hard to understand and use
早在 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
,结果是:
如果使用我写的插件:
vue-types
在 pnpm 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 ```
打开 vuedraggable
的 package.json
看了一下,发现居然是使用固定版本号声明的 "sortablejs": "1.14.0"
,怪不得 pnpm dedupe
检测不出来。
面试经常被问到 webpack
插件是怎么工作的,其实 webpack
和其他它很多构建工具的插件的开发方式都一样,插件本质上就是去订阅构建工具暴露的扩展事件(或者说钩子),在回调编写你的扩展逻辑。
核心逻辑:
第一步可以使用 rollup
的 resolveId
事件,第三步则可以使用 buildEnd
事件。
只是列出了被重复打包的依赖,感觉对构建体积的影响不够直观,于是便借助 https://bundlephobia.com/ 的 api 来获取依赖的体积并展示,接口很简单:
plaintext https://bundlephobia.com/api/size?package=${name}@${version}
其实我一直不太理解为啥用 import cost vscode 插件的人那么多,这玩意因为获取一个包体积需要跑一次 webpack
,所以又慢又吃资源,直接用 api 请求不香吗?
最初借助这个插件把一些重复依赖干掉后,过了一段时间,发现又多了一些新的重复依赖。对于开发效率有极致追求的我,受不了自己再处理一遍,谁拉的
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。