Vue 3项目死代码排查:从打包分析到按需引入的完整实践
一个 Vue 3 业务项目只用了 3 个组件打包产物却多出 1.2MB 死代码。这个问题通常在开发阶段看不出来真正难受的是上线前首包变大、并行请求变多、性能预算突然超支。很多人第一反应是“组件库太大”但在大多数情况下大不是重点重点是无效代码被打进了产物。我按实际排错路径拆一遍。先讲清死代码为什么会存在再说怎么用打包分析报告定位接着给两种按需引入方案最后聊 tree shaking 生效需要满足哪些条件以及上线前怎么验收体积变化。这套流程对 Vue 3 组件库基本通用不管用的哪种组件库思路一致。1. 先定位 1.2MB 死代码到底藏在哪些位置死代码不会凭空出现。1.2MB 的增量通常来自四个位置组件全量注册、样式全量引入、图标库全量打包、公共依赖被连带保留。这四个原因可以同时存在也可以只中一个。先逐个确认再动手改比急着调打包配置更有效。1.1 全量注册组件库最大号的死代码来源很多 Vue 3 项目沿用 Vue 2 时代的习惯在入口处直接挂整个组件库import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css const app createApp(App) app.use(ElementPlus)这段代码会把整个组件库注册到 Vue 实例上。即使业务模板里只写了 3 个组件构建时组件库内部的所有组件、指令、插件模块都会进入产物。为什么构建工具不能把“注册了但没被模板使用”的组件剪掉因为app.use(ElementPlus)是一个函数调用插件内部会对组件、指令、服务方法做批量注册构建工具只能看到参数对象无法追踪注册过程中到底使用了哪些导出。没有静态线索就宁可多保留也不能剪错否则运行时一调用就报错。如果你在项目里同时有多个页面文件情况会更复杂。组件库自身的内部共享逻辑比如弹层、popper、日期计算、虚拟滚动会被很多内部组件引用。构建工具发现这些公共模块有多个引用点时通常会把它们打包成一个公共 chunk。即使业务里只用按钮按钮内部引用的公共基础模块也会跟着进来这部分不算完全的死代码但体积占比很大。所以“只用了 3 个组件”是业务层面的使用情况构建层面的模块依赖可能已经牵动了大半个组件库。这是第一个要确认的点入口是不是全量app.use。1.2 样式全量引入体积往往比组件还大组件代码按需了样式却容易漏。很多组件库的文档会给出两种选择全量主题 CSS或者按组件拆分样式。一些团队图省事在main.js里写import element-plus/dist/index.css或者用自动按需插件之后又在某个全局样式里保留了一行全量主题文件。这样组件逻辑可能只打进 100KB样式却把整个库的 CSS 都带进来了。CSS 文件的特点是不能像 JS 那样做精确的 tree shaking。普通 CSS 里没有模块作用域构建工具很难判定哪些类名永远不会出现在 HTML 里。虽然一些现代构建工具能对 CSS 做 unused 删除但组件库的 CSS 通常和主题变量、工具类、动画状态混在一起风险高优化效果有限。所以样式这一步必须显式按需。不同组件库的按需路径不同有些叫theme-chalk有些在es/components/*/style下。最好以组件库官方文档为准不要自己猜路径。1.3 图标库和内置依赖被一起打包还有一个很大的坑图标全量注册。以 Vue 3 组件库常用的图标包为例有人会在入口写import * as Icons from element-plus/icons-vue for (const [name, component] of Object.entries(Icons)) { app.component(name, component) }这段代码会把几百个图标全部注册成全局组件。图标每个都很小但合在一起可能比三个业务组件大得多。更重要的是图标组件会引用统一的 Svg 基础组件、属性解析逻辑这些公共依赖也会被一起包进去。一些高级组件内部还会带独立的第三方库。比如日期选择器内部可能依赖 date-fns 或 dayjs表格组件可能带有自己的虚拟滚动逻辑。如果这个组件本身没有被使用按需应该能把它们排除但如果组件库入口存在export * from ./components之类的聚合导出构建工具不一定能安全地把未使用组件删干净。结果就是一个没用到的表格组件连带它的日期依赖、递归菜单依赖全都出现在产物里。1.4 组件库自身的副作用标记与模块格式问题死代码还和模块格式、副作用标记有关。ES 模块的 import 是静态的构建工具可以看到引用了哪些导出这是 tree shaking 的前提。但如果组件库走的是 CommonJS 入口或者业务侧被某个插件转成了 CommonJS构建工具就只能做整体引用无法细粒度删除。另一个关键是sideEffects字段。这个字段告诉构建工具哪些文件是有副作用的不能随便删。组件库通常需要把 CSS 文件标记为副作用否则样式会被误删。但如果一个组件库没有把组件入口标记成可安全清理或者在 publish 时链路里混进了非 ESM 的目录tree shaking 就会失败。排查时可以打开组件库的package.json看sideEffects字段再确认安装包里es或lib目录是否存在。不要只依赖文档判断因为多数情况下问题出在依赖树里某个中间包把模块格式改掉了。2. 用打包分析报告代替猜测遇到死代码问题最怕按感觉改。一会把插件去掉一会加参数改到最后可能只是把问题从一个 chunk 挪到另一个 chunk。我更建议先做一次分析报告把死代码分布看清楚再决定怎么改。2.1 哪种报告更容易看出死代码分布Vite 项目可以用rollup-plugin-visualizerWebpack 项目可以用webpack-bundle-analyzer。这两个工具的产出都是 HTML 文件打开后能看到每个 chunk 里各模块的占比。以 Vite 项目为例先安装依赖npm i -D rollup-plugin-visualizer然后在vite.config.ts里临时加一段配置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 }) ] })重新执行npm run build打开dist/report.html你会看到每个 chunk 的树状模块占比。重点看组件库 chunk 里有没有出现你没用到的高级组件。图标相关模块是否集中在一处。CSS 文件是否和组件库同名且体积很大。有没有“公共依赖”模块包含日期处理、工具函数、防抖节流这类基础逻辑。Webpack 项目尤其是 Vue CLI 创建的项目可以直接用vue-cli-service build --report之后在dist目录下生成 report.html。它基于 webpack-bundle-analyzer效果类似。2.2 快速查看产物构成三个最容易忽视的数据分析报告信息很多一眼看过去容易迷失。我一般先只盯三个数字。第一个是总包未压缩大小和 gzip 后大小。很多人把未压缩大小当成线上传输大小会高估问题反过来只看 gzip又会低估 CSS 的影响。两个都记下来再看中间的差距。第二个是组件库在产物中的体积占比。如果组件库 chunk 占比超过 20%并且业务代码很小那大概率有全量引入或聚合导出问题。第三个是 CSS 文件数量与大小。正常情况下按需样式会拆成多个小 CSS 文件或者合并成一个体积可控的 CSS。如果 CSS 里出现了大量未使用组件类名说明样式入口仍有全量引入。为了方便对比可以建一张小表格指标记录值判断标准总包未压缩大小例如 2480KB趋势下降说明有效gzip 后总大小例如 680KB业务包压缩率更高组件库 chunk例如 1200KB应远小于全量引入CSS 总大小例如 900KB按需后应明显下降异步 chunk 数量例如 12 个死代码常藏在异步包里2.3 改配置前的基线记录不要凭记忆对比。在改动前先执行一次完整构建记下几个关键数据再开始改。后续每次重新构建都回到同一个报告里对比。如果报告显示组件库 chunk 很大但业务确实只用几个组件先检查入口写法和是否有全局图标注册。如果报告显示 CSS 很大再点开 CSS 对应模块看类名前缀。一个容易被忽略的点某些组件库会把所有组件统一打进lib/index.js即使你从es目录导入最终模块也可能是聚合导出。这时无论业务侧怎么按需依然会有大块死代码。遇到这种情况要么继续使用官方提供的自动按需插件要么把组件库升级到更规范维护 ESM 子路径导出的版本。注意改配置前一定要留下基线数据否则体积变大变小都说不清原因。3. 按需引入的正确落地方式定位到原因之后再动手改引入方式。按需引入有两种路线手动按需和自动按需。少量组件项目用手动组件数量多且需要长期扩展的用自动。3.1 手动按需适合只使用少量组件且不想加插件手动按需的核心是从组件库的es目录单独导入对应组件再单独导入它的样式。以 Vue 3 组件库为例在组件文件中局部注册import { ElButton, ElInput } from element-plus import element-plus/es/components/button/style/css import element-plus/es/components/input/style/css export default { components: { ElButton, ElInput } }如果多个页面都用到可以在main.js里全局注册。注意这里的全局注册只包含用到的组件而不是整个库import { createApp } from vue import { ElButton, ElInput } from element-plus import element-plus/es/components/button/style/css import element-plus/es/components/input/style/css const app createApp(App) app.component(ElButton.name, ElButton) app.component(ElInput.name, ElInput) app.mount(#app)手动按需的最大优势是直观不会引入额外的构建插件报错时容易定位。缺点是组件一多每个文件都要重复写导入而且样式路径可能因为组件库版本变化而失效。还有一个容易踩的坑某些组件有依赖样式比如日期选择器依赖 popper组件库内部已经处理了大部分但如果你发现样式缺失要按文档把对应依赖组件的样式也补上。如果只使用 3 个组件我建议先试手动按需。它能直接验证“是不是全量引入导致体积大”这个判断。3.2 自动按需用 unplugin-vue-components 处理组件扫描和导入当项目里组件数量增长或团队不想维护手动导入列表时常用方案是unplugin-vue-components。这个插件会在编译阶段扫描模板中使用到的组件名自动生成导入语句和样式导入。Vite 项目配置// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), Components({ resolvers: [ElementPlusResolver()] }) ] })Webpack 项目配置// vue.config.js const Components require(unplugin-vue-components/webpack) const { ElementPlusResolver } require(unplugin-vue-components/resolvers) module.exports { configureWebpack: { plugins: [ Components({ resolvers: [ElementPlusResolver()] }) ] } }配置完成后模板里直接写el-button或Button插件会自动把它转换成对应组件导入并按需引样式。为什么能省体积因为这个插件生成的是具名导入语句比如import { ElButton } from element-plus同时自动加样式路径。相比全量app.use构建工具能顺着具名导入做 tree shaking把没用到的组件排除掉。需要注意这个插件只处理模板中出现过的组件。像ElMessage、ElNotification这类通过 JS 调用的组件模板中不会出现所以还需要配合unplugin-auto-importimport AutoImport from unplugin-auto-import/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ AutoImport({ resolvers: [ElementPlusResolver()] }) ] })3.3 样式也要按需不能只按需组件自动导入插件通常会通过 resolver 自动引样式但前提是组件库的 resolver 配置正确。如果发现样式还是全量最常见的原因是main.js里还留着import dist/index.css或者你在全局样式中引用了组件库主题变量而主题变量又连带引用了全量样式入口。改成按需后样式会被拆成多个小文件构建工具最终合并成一个或多个 CSS chunk。体积下降是一方面另一方面也能减少全局类名冲突。有一种情况要单独处理自定义主题。如果业务需要修改组件库的主题色而组件库的样式入口是 scss 变量按需引入后会发现主题变量没有被正确注入。此时需要配置额外的样式预处理器参数或者在业务侧覆盖对应的 CSS 变量。不同组件库做法不同建议查官方主题定制文档不要只靠全量 CSS 覆盖。3.4 全局组件、指令和函数式调用的特殊处理自动按需并不等于完全无脑。模板里扫描不到的地方仍然要手动处理。第一类是全局指令。比如v-loading、v-permission它们不会作为组件出现在模板里自动导入插件一般不会帮你注册。需要手动从组件库导入指令并在app.directive注册同时保留对应指令的样式。第二类是动态组件。如果写成component :iscurrentComponent /而currentComponent的值来自字符串构建工具无法在编译阶段知道它对应哪个组件。自动导入插件扫描不到运行时就会出现组件未注册或样式丢失。稳妥做法是把动态组件映射成一个显式对象或者手动注册那些可能出现的组件。第三类是函数式调用。像消息提示、对话框确认这类 API用auto-import可以自动引但如果项目里只用一两个也可以手动 import 并单独引样式。关键是不要忽略它们的样式入口否则按需之后按钮正常弹窗却没有背景色和动画。注意按需引入不是只按