这次我们来看一个很典型的 Vue 3 前端工程化问题:业务项目里明确只用了 3 个组件库组件,结果npm run build之后,产物里多出来 1.2MB 的“死代码”。这个问题的本质不是组件库不好用,而是引入方式、产物格式、样式加载方式和打包工具的 tree-shaking 机制没有对齐。
很多 Vue 3 开发者在从 Vue 2 迁移时,会把app.use(组件库)这种全量注册习惯带过来,或者从旧文档里抄了一个按需引入写法,却没注意到组件库是否真的支持 ESM 按需裁剪。结果是:页面功能正常,构建产物却偷偷把整套组件库塞了进去。这篇文章会先把死代码的来源梳理清楚,再给出一套完整的排查链路:如何用构建产物分析工具定位、如何修正入口和 sideEffects、如何用 unplugin 插件做自动按需引入、以及优化后怎么验证体积变化。
如果你正在维护 Vue 3 业务项目,或者需要把组件库二次封装给团队使用,又或者构建脚本里已经出现了 chunk 体积告警,这篇文章可以直接收藏。下面所有配置示例都会给出可复制代码,但具体路径和版本号需要以你本机项目为准。
1. 问题速览:1.2MB 死代码从哪来
先把最常见的现象和根因列成一张表,方便对号入座:
| 现象 | 可能根因 |
|---|---|
| 只用了 3 个组件,JS 体积仍然多出 1MB+ | 组件库入口没走 ESM,打包工具退回 CommonJS/UMD,整包被保留 |
| JS 体积正常,CSS 体积异常大 | 在入口文件直接引入了组件库全量样式文件 |
| 构建日志出现 “Some chunks are larger” | 某个组件库 chunk 被整体打进页面主 bundle |
| 配置过按需引入,体积却没有变化 | sideEffects 标记缺失或值为空,tree-shaking 被副作用阻断 |
| 样式时有时无,组件显示异常 | 按需引入时组件 JS 有了,但对应样式没被加载 |
从这张表可以看出来,“只用了 3 个组件却多出 1.2MB 死代码”通常不是单一原因,而是多个环节叠加的结果。最常见的是:组件库主入口是 CommonJS/UMD,打包工具无法静态分析;业务代码或组件库包又没声明sideEffects,导致模块的全部关联代码被保留;样式文件则直接全量 import,把整个主题样式带进去。后面的根因分析会一个一个展开。
2. 适用场景与排查边界
这篇文章主要解决 Vue 3 + Vite/Webpack 生态里的组件库打包膨胀问题,适合以下场景:
- 业务项目使用了 Element Plus、Ant Design Vue、Naive UI、Vuetify、PrimeVue 等 Vue 3 组件库,产物体积明显异常。
- 团队正在做组件库二次封装,需要给内部组件库补充 ESM 产物、
sideEffects和exports字段。 - 构建产物分析图上出现了大量“无法被裁剪”的模块,需要判断是依赖问题还是自身配置问题。
但也有边界需要提前说明。按需引入不是性能神药:如果组件库本身没有提供 ESM 产物,或者组件库内部大量使用运行时动态 require、模板字符串拼接组件路径,再强的 tree-shaking 也无法完全裁剪。遇到这种情况,应该优先考虑更换组件库,而不是在业务项目里做 workaround。
另外,所有体积优化都必须在功能正常的前提下进行。按需引入之后如果组件依赖的指令、插件或全局配置没被引入,页面可能直接报错。因此每改一步都要回归验证:组件渲染、事件交互、主题覆盖、国际化都正常后再谈体积数字。
3. 环境准备与前置检查
开始排查之前,先确认本机环境基础项。以 Vite 项目为例,最常用的检查项是:
- Node.js 版本:通常建议 16.20+ 或 18+,Vite 5 需要 Node 18+,具体以你安装的 Vite 版本文档为准。
- 包管理器:npm、pnpm、yarn 都可以,但注意 lockfile 要和团队一致,否则依赖解析可能不同。
- 构建工具:Vite 或 Webpack,两者对 tree-shaking 的支持程度和处理方式有差异。
- 组件库版本:记录当前使用的组件库版本,后面排查要对照它的 package.json 字段。
- 磁盘空间:构建时会生成临时文件和分析报告,确保磁盘剩余空间足够。
在动手改配置之前,先做一套基线记录。执行一次构建,把以下信息记录下来:
# 记录构建前后的产物总大小 npm run build # Linux/macOS 查看产物大小 du -sh dist # Windows PowerShell 查看产物大小 Get-ChildItem dist -Recurse | Measure-Object -Property Length -Sum然后检查组件库包本身暴露了哪些入口。这一步不需要写代码,直接在node_modules里看包描述文件即可:
# 以 element-plus 为例,路径按实际安装的包调整 cat node_modules/element-plus/package.json | grep -E '"main"|"module"|"exports"|"sideEffects"'输出会直接告诉你组件库有没有声明module字段、exports字段,以及sideEffects的值是什么。这是判断 tree-shaking 能否生效的关键,很多死代码问题在第一步就能看出端倪。如果组件库的module字段缺失,或者main指向的是 UMD 文件,Vite 和 Webpack 都会优先选择 CJS/UMD 入口,之后的 tree-shaking 就基本失效了。
4. 复现:业务项目里 3 个组件为什么能拉进 1.2MB
为了防止“我们的代码没写错,体积却异常”的幻觉,建议先做一个最小复现。新建一个临时测试项目,只安装一个 Vue 3 组件库,然后按最粗暴的方式注册组件:
npm create vite@latest tree-shaking-demo -- --template vue cd tree-shaking-demo npm install element-plus在src/main.ts里全局注册:
import { createApp } from 'vue' import App from './App.vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' createApp(App).use(ElementPlus).mount('#app')执行npm run build,观察dist产物。正常情况是:JS 和 CSS 都变得很大,因为app.use(ElementPlus)本质上是全量注册。这是预期的,不算死代码问题。
接下来改成文档推荐的按需写法,但只写一半:
import { createApp } from 'vue' import App from './App.vue' import { ElButton } from 'element-plus' import 'element-plus/dist/index.css' createApp(App).component(ElButton.name, ElButton).mount('#app')这次页面只挂载了一个按钮组件,但如果组件库的入口指向了 CommonJS 产物,或者打包工具没有正确处理sideEffects,构建结果可能仍然接近全量。你可以对比两次构建产物体积,如果 JS 部分几乎没有缩小,说明 tree-shaking 被绕过了。
这个最小复现的价值在于:它把环境和配置因素降到最低,让你能快速判断问题是出在组件库包本身,还是出在业务项目的复杂依赖上。复现成功后再回到真实项目里做同样的验证,排查效率会高很多。
5. 根因分析:tree-shaking 被绕过的四个典型原因
5.1 入口走了 CommonJS/UMD
Rollup 和 Webpack 要做 tree-shaking,前提是模块系统是 ESM,这样才能在静态分析阶段知道哪些导出被使用了。很多 Vue 3 组件库在package.json里同时声明了main、module和exports:
{ "main": "lib/index.js", "module": "es/index.mjs", "exports": { ".": { "import": "./es/index.mjs", "require": "./lib/index.js" } } }正常情况下 Vite 会优先读exports里的import条件,Webpack 会优先读module字段。但如果组件库没有exports或module字段,Vite 和 Webpack 会退回main,也就是 CommonJS 入口。CommonJS 的module.exports在静态分析阶段很难被精确裁剪,打包工具只能保守地保留整个模块,于是 1.2MB 就进来了。
5.2 sideEffects 标记干扰
sideEffects是打包工具判断模块是否有副作用的依据。如果组件库的package.json里没有这个字段,打包工具会默认所有模块都有副作用,无法移除任何未使用的导出。这在 Vite(Rollup)和 Webpack 里都会发生。
组件类的代码通常应该标记为"sideEffects": false,但很多组件库因为依赖.scss、.css或.vue文件,不能简单粗暴地写false,否则样式会被误删。常见的组件库会在package.json里这样配置:
{ "sideEffects": [ "**/*.css", "**/*.less", "**/*.scss", "dist/*" ] }如果这个字段缺失,或者写成了"sideEffects": true,那么即便你只import { ElButton },组件库入口里所有被引用过的文件都会被当作“可能有副作用”而保留。
5.3 样式文件全量引入
很多业务的main.ts里会写一行:
import 'element-plus/dist/index.css'这行代码会把整套组件库的 CSS 全部打进产物。你只用了 3 个组件,但样式表是全部组件的。CSS 文件经过 gzip 压缩后可能没那么大,但在未压缩的 JS/CSS 体积统计里,可能直接多出数百 KB 甚至更多。
正确的做法是:使用组件的按需样式路径,或者用unplugin-vue-components的解析器自动匹配样式文件。Element Plus 支持element-plus/es/components/button/style/css这样的路径,Ant Design Vue 也有类似的按需样式方式。
5.4 组件内部依赖链
即使入口正确、sideEffects 标记正确,组件库内部也可能存在“碰一个就带一串”的模块。比如一个表单组件依赖了表单校验库、日期组件依赖了 dayjs 的完整中文 locale,或者组件内部直接import { onMounted } from 'vue',这些依赖不一定能全部被 tree-shaking 清掉,但它们通常不会带来 1.2MB 这么大的死代码。
如果遇到这种情况,建议打开构建分析工具,直接看是哪一层依赖占的体积。用 Rollup 的@rollup/plugin-visualizer或 Webpack 的webpack-bundle-analyzer都可以。下面的第 7 章会给配置示例。
6. 解决方案:从手动按需到自动按需
6.1 先修正引入入口
第一步是把组件库入口从 CJS/UMD 切到 ESM。对 Vite 项目来说,大多数情况下只要确认组件库包的exports或module字段存在即可,Vite 会优先选择 ESM。如果是 Webpack 项目,还需要确保mode: 'production'开启optimization.sideEffects和usedExports。
如果是自己的组件库包,应在package.json中显式声明:
{ "name": "your-ui-lib", "main": "lib/index.js", "module": "es/index.mjs", "exports": { ".": { "import": "./es/index.mjs", "require": "./lib/index.js" } }, "sideEffects": [ "**/*.css", "**/*.scss", "**/*.less" ] }这样业务项目在引用时,构建工具就能稳定地走 ESM 分支。
6.2 手动按需引入组件与样式
如果不想引入额外的插件,可以先手动按需引入。以 Element Plus 为例,在业务组件里这样写:
import { ElButton } from 'element-plus' import 'element-plus/es/components/button/style/css'然后再把组件注册到app.component或者当前组件的components选项里。这种方式代码量大一些,每新增一个组件都要手动补一条 import,但它能帮你理解组件库按需加载的最小路径,排查问题时会很有帮助。
手动按需引入有一个隐藏坑:组件之间的依赖。如果按钮组件内部依赖了别的组件(比如ElButton依赖ElIcon),你可能还需要手动引入被依赖组件的样式和 JS。这也是为什么很多项目最终会切换到自动按需引入。
6.3 用 unplugin 插件做自动按需引入
最常见的 Vite 项目自动按需方案是unplugin-vue-components配合unplugin-auto-import。这两个插件是 Vue 生态里做自动按需引入的标准工具,支持和 Element Plus、Ant Design Vue、Naive UI 等库的解析器。
先安装依赖:
npm install -D unplugin-vue-components unplugin-auto-import然后在vite.config.ts里配置:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] })配置完成后,业务代码里不需要手动 import 组件,直接在模板里使用<el-button>,插件会在编译阶段自动分析模板并引入对应的组件 JS 和样式。这种方式对团队协作很友好,新同学不需要了解每个组件的样式路径,维护成本也低。
Webpack 项目也有对应接入方式。核心思路是在vue.config.js里配置相同的插件:
const AutoImport = require('unplugin-auto-import/webpack') const Components = require('unplugin-vue-components/webpack') const { ElementPlusResolver } = require('unplugin-vue-components/resolvers') module.exports = { configureWebpack: { plugins: [ AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] } }无论用哪种方案,都要记得检查组件库官方文档,因为不同库的解析器路径和规则不同。以实际项目安装的版本为准。
6.4 修正 sideEffects 与构建配置
如果项目里已经用了 ESM 入口,但体积依然异常,下一步检查sideEffects。
Vite 项目通常不需要在业务项目里额外配置sideEffects,因为组件库包的package.json已经告诉构建工具哪些模块有副作用。问题更多出现在业务项目自己的package.json没有设置sideEffects,导致一些本地模块被保留。可以在项目根目录package.json中添加:
{ "sideEffects": [ "**/*.css", "**/*.scss", "**/*.vue" ] }**/*.vue这一项需要谨慎,如果组件里确实在<script>顶部执行了副作用代码,这里会影响 tree-shaking。更稳妥的做法是先不加,观察构建分析工具结果后再调整。
Webpack 项目则需要在webpack.config.js里确认:
module.exports = { mode: 'production', optimization: { usedExports: true, sideEffects: true } }确保sideEffects没有被设为false,否则会把 Vue 单文件组件的副作用逻辑也错误裁剪掉。
7. 优化验证:构建产物分析、体积对比与性能观察
改完配置之后,不要只看“构建成功”就结束。你需要用工具看产物,确认组件库 chunk 里只剩实际用到的模块。
以 Vite/Rollup 项目为例,在vite.config.ts中引入rollup-plugin-visualizer:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { visualizer } from 'rollup-plugin-visualizer' export default defineConfig({ plugins: [ vue(), visualizer({ filename: './dist-report.html', gzipSize: true, brotliSize: true, open: true }) ] })重新执行npm run build,浏览器会自动打开dist-report.html,可以看到每个 chunk 的组成关系。在分析图里搜索组件库名称,如果发现它所在的 chunk 体积仍然很大,点进去看具体保留了哪些模块,再对照业务代码里实际使用的组件范围。
验证时建议记录三个指标,并做成表格对比:
| 指标 | 优化前 | 优化后 | 说明 |
|---|---|---|---|
| dist 总大小 | 修改前记录 | 修改后记录 | 以du -sh dist或打包输出为准 |
| 组件库相关 chunk | visualizer 里的模块体积 | 同上 | 关注 JS 和 CSS 分别占比 |
| 首次加载资源 | 开发调试工具的 Network 面板 | 同上 | 关注 gzip/brotli 后大小 |
构建阶段就定位问题,总比发布后线上慢要好。发布后如果想更精确地看“哪些代码在页面上没有被执行”,可以用浏览器 DevTools 的 Coverage 面板:打开开发者工具,切到 Coverage,刷新页面,它能标出哪些 JavaScript/CSS 没有运行。这个方法适合优化线上存量路径,但对构建配置的反馈不够实时,所以更推荐先在构建阶段用分析工具处理。
8. 常见问题与排查清单
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 配置 unplugin 后组件样式没了 | 解析器没匹配到样式路径,或组件库版本不兼容 | 检查模板里的标签是否被插件识别,查看生成的类型声明文件 | 升级组件库或插件版本,检查 resolver 是否写错 |
| 组件 JS 引入了,但模板里没渲染 | 手动按需漏了组件依赖 | 打开浏览器 Console 看警告 | 改用 unplugin 自动解析组件依赖 |
| 改完配置体积还是 1MB+ | 入口仍指向 CJS/UMD | 查看package.json的 main/module/exports 字段 | 换用 ESM 入口或升级组件库 |
| 构建日志没有任何 tree-shaking 提示 | sideEffects配置存在但被忽略 | 使用 visualizer 查看模块引用关系 | 在组件库包中修正 sideEffects |
| CSS 体积巨大 | 入口仍存在全量样式导入 | 搜索代码里的dist/index.css或theme.css | 删除全量样式,使用按需样式路径 |
构建产物有undefined is not a function | 依赖被错误裁剪 | 检查业务项目sideEffects配置,尤其是.vue文件 | 调整sideEffects列表,保留必要的副作用文件 |
如果遇到插件缓存问题,可以先清理缓存再构建:
# 清理 Vite 缓存 npx vite optimize # 或直接删除 node_modules/.vite 缓存目录 rm -rf node_modules/.vite9. 工程化最佳实践:让死代码不再回潮
体积优化是长期工程,不是一次配置就结束。以下是几条建议。
第一,建立构建产物体积基线并在 CI 里加门槛。可以写一个脚本读取打包后的dist目录大小,超过阈值就告警。团队项目把体积控制在 200KB 还是 500KB,取决于业务复杂度,但至少要有一个明确告警线。如果团队规模大,可以把体积检查脚本接到 CI 流水线里,让它成为每次合并前必跑的批量校验任务。
第二,统一按需引入方式。业务模块里同时存在手动import '组件库/dist/index.css'和插件自动引入,会造成重复加载和体积膨胀。建议代码里禁用全量样式 import,统一走 unplugin 解析器。
第三,组件库二次封装时先做最小可运行检查。发布到 npm 前,把包安装进一个临时 Vite 项目,只使用一个组件,构建一次,观察体积。如果这时的产物已经包含全量代码,说明包的sideEffects或exports字段配置有问题,要修在源头而不是让业务侧忍受。
第四,保留一份构建分析报告存档。每次大版本升级组件库或构建工具,都把dist-report.html导出到 CI 日志或文档,方便后续对比。
第五,注意开源组件库的许可证和合规风险。按需引入只是体积优化,不改变依赖的许可证。企业项目里要对依赖做许可证扫描,避免把带强传染性许可证的代码引入核心业务模块。涉及版权素材、人脸数据或其他敏感数据的项目,也要确保所用组件和模型的使用边界合法合规。
10. 总结与后续动作
回到最初的问题:Vue 3 组件库,业务项目只用了 3 个组件,打包却多出 1.2MB 死代码。到这里可以给出明确结论:这不是 Vue 3 的问题,也不是组件库“太笨”,而是 ESM 入口、sideEffects 标记、样式按需加载这三件事没有同时做对。
接下来如果想继续优化,建议按这个顺序执行:先在一台干净环境里最小复现,确认入口路径;再补齐package.json的 main/module/exports 和 sideEffects 字段;然后接入 unplugin-vue-components 做自动按需;最后用 rollup-plugin-visualizer 验证体积差异。每一步都要回归功能,确保