接手一个后台项目,npm run dev 要等 40 秒,改一行代码热更新又要三四秒。开发效率低到同事开始用 --force 赌运气。
花了一个下午梳理,问题分布在四个地方,都不算深奥,但叠在一起就很致命。
先别猜,加个分析器
优化最忌讳凭感觉。先让构建产物自己说话:
npm i -D rollup-plugin-visualizer
// vite.config.ts
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({ open: true, gzipSize: true, brotliSize: true }),
],
});
跑一次 npm run build,会弹出一张依赖体积图。我们的图上,echarts 和 lodash 两个块加起来占了 62%,而这些包几乎全量进来了,实际用到的不足两成。
第一处:依赖预构建没吃上
Vite 会把 CommonJS 依赖预打包成 ESM,但如果依赖列表写得不全,它会在运行时现场探测,每次启动都重新做一遍。
export default defineConfig({
optimizeDeps: {
include: [
'echarts/core',
'echarts/charts',
'echarts/components',
'echarts/renderers',
'dayjs',
'axios',
],
exclude: ['@app/local-lib'], // 本地包不要预构建,否则改了不生效
},
});
另外确认 node_modules 没被挂进监控范围。Windows 或 Docker 环境下可以显式关掉:
export default defineConfig({
server: {
watch: {
ignored: ['**/node_modules/**', '**/.git/**'],
},
},
});
这一步单独就砍掉了 12 秒。
第二处:barrel 文件把整棵树拖进来了
这是最隐蔽的一个。项目里有个 components/index.ts,把上百个组件统一导出:
// ❌ components/index.ts —— 看起来整洁,实际是性能黑洞
export { default as Table } from './Table.vue';
export { default as Chart } from './Chart.vue';
// …还有一百多行
而业务代码里写 import { Table } from '@/components'。开发阶段浏览器要逐个请求这一百多个文件,页面加载慢;生产构建时 Tree Shaking 也会被这条"桶"拦住,因为副作用无法可靠判断。
改法很朴素——直接指向具体文件:
// ✅ 只加载真正用到的那个模块
import Table from '@/components/Table.vue';
如果调用点太多不好全改,至少给 package.json 标上无副作用,让打包器敢做裁剪:
{
"sideEffects": ["*.css", "*.scss"]
}
第三处:图表库按需引入
echarts 全量引入约 1MB,按需之后只剩 300KB 出头:
// src/charts/setup.ts
import * as echarts from 'echarts/core';
import { LineChart, BarChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
LegendComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
LineChart,
BarChart,
GridComponent,
TooltipComponent,
LegendComponent,
CanvasRenderer,
]);
export default echarts;
组件库同理。element-plus 之类一定要配合 unplugin-vue-components 做自动按需注册,不要为了省事全局 app.use()。
第四处:分包策略
默认分包按文件顺序切,结果每次发版用户都要重下整个 vendor。按运行时更新频率分层,缓存命中率会高很多:
export default defineConfig({
build: {
target: 'es2020',
sourcemap: false, // 上传到监控平台,别跟着发布包走
chunkSizeWarningLimit: 800,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
// 框架层:几乎不变,长期缓存
if (/[\\/](vue|vue-router|pinia|@vue)[\\/]/.test(id)) return 'vendor-core';
// 图表单独一块:体积大但改动少
if (id.includes('echarts') || id.includes('zrender')) return 'vendor-chart';
// 其余三方依赖
return 'vendor';
},
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
},
},
});
这里的关键是分层依据更新频率,而不是体积。vendor-core 可能半年不动一次,用户就能一直吃缓存;业务代码天天变,单独切出来,发布后只有它会失效。
顺手做掉的小事
export default defineConfig({
build: {
cssCodeSplit: true, // 按页面拆 CSS,首屏少下几百 KB
reportCompressedSize: false, // 大项目关掉,构建快一截
minify: 'esbuild', // 比 terser 快得多,压缩率差距很小
},
});
还有一条不在配置里但很值钱:检查 package.json 里的 @types/* 是否被误装成了 dependencies。类型包进生产依赖除了拖慢安装没有任何好处。
结果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| dev 冷启动 | 40.2s | 3.1s |
| 热更新 | 3.4s | 0.4s |
| 首屏 JS(gzip) | 1.9MB | 480KB |
| 构建耗时 | 96s | 38s |
复盘
这四步的性价比排序非常明确:先解决 barrel 文件,再补依赖预构建,然后按需引入,最后才是分包。前两个改动量最小、收益最大,而且不需要理解打包器的内部机制。
另外,别把 --force 当成日常工具。它每次都会清掉预构建缓存,用得越多越慢。