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
的核心功能,但是默认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
字段读取浏览器兼容目标版本 - 项目中同时使用了多个功能类型的 pkg,例如时间相关的
moment
,dayjs
,date-fns
, 还有我记得生成二维码和处理xlsx
的库也有多个,为了这个问题我还写了个 cli: find-similar-packages - 通知其它部门同事不要把
node_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
。
在包管理器的视角依赖可以分为两类:
- 直接依赖:例如 workspace package
ui
的package.json
中声明的axios
- 间接依赖:例如
axios
依赖的follow-redirects
按照用途分为两类:
- 源码依赖:例如
apps/web/src
导入了pkg1
和vue
,pkg1
和vue
以及它俩依赖树上的依赖称为源码依赖 - 开发依赖:例如
vite
,esbuild
不会被打包到apps/web/dist
中的只在开发时使用的依赖,也包括它俩依赖树上的依赖
lockfile 的副作用
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 之所以没这么做,是因为
- lockfile 里面已经声明了
pkg1
的foo
解析到的版本,所以会直接按照 lockfile 声明的版本来 - 因为 pkg 中
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 就没法通过