我们维护着一个基于 Vue 3 的中后台系统,业务模块多达 1200+ 个,依赖里还混着几份早期的 CommonJS 组件库。每次冷启动要等上将近一分钟,HMR 在改一个深嵌套组件时也要 3 到 5 秒,开发体验一直被吐槽。经过三周的迁移,我们把冷启动从 47 秒压到了 4 秒左右,HMR 基本做到亚秒级。这篇文章把整个迁移过程拆开讲清楚,希望能给同样在犹豫要不要换 Vite 的团队一些参考。
一、为什么要迁移:先量化痛点
动手之前我们先做了一轮基线采集,把 Webpack 下的各项指标都记录下来,避免后续改进只能凭感觉。机器是 M1 MacBook Pro(16GB),Node 18.19.0,开发服务器空载。
| 指标 | Webpack 5 (vue-cli) | 说明 |
|---|---|---|
| 冷启动(dev server ready) | 47.3s | 每天平均触发 6-8 次 |
| 热更新(深层组件) | 3.2s - 5.1s | 改一个 80KB 的 SFC |
| 生产构建(vite build 同口径) | 112s | CI 上更慢,约 150s |
| 首屏依赖加载 | 3.8MB(gzip 980KB) | 含 vendor chunk |
| 开发期内存占用 | 2.1GB | 长时间运行后还会涨 |
这些数字里最影响幸福感的是冷启动和 HMR。中后台业务有个特点:联调时经常需要切换分支重启 dev server,47 秒意味着一次中断就是半分钟以上的真空期,工程师的注意力很容易被打断。
二、构建机制的本质差异:bundle vs native ESM
理解了为什么慢,才能理解为什么 Vite 快。Webpack 的开发模式和生产模式走的是同一套思路:把所有模块预先打包成一个大 bundle,再交给浏览器。无论你启动后用不用某个模块,它都得先编译一遍。
Vite 的开发模式则完全不同,它依赖浏览器原生的 ES Module 能力:
- 依赖预构建(esbuild 打包):把 node_modules 里成千上万个零散模块用 esbuild 合并成少量 ESM 文件。esbuild 用 Go 写,比 babel/webpack 快一到两个数量级。
- 源码按需编译:业务代码不打包,浏览器请求到哪个 .vue/.tsx,Vite 才即时编译哪个。这意味着项目无论多大,冷启动时间都基本恒定。
- HMR 精准替换:只把变更的模块通过 WebSocket 推给浏览器,不需要重新组装整个 bundle。
而生产构建阶段,Vite 仍然使用 Rollup(而不是 esbuild)来做 tree-shaking 和分包,因为 Rollup 在产物体积和代码可读性上更成熟。这是很多人容易混淆的点:Vite 的"快"主要来自开发期的 esbuild 预构建 + 原生 ESM,而不是生产构建。
# Webpack dev 模式请求链路
浏览器 ← index.html ← bundle.js (所有模块已打包)
# Vite dev 模式请求链路
浏览器 ← index.html
← /@vite/client (HMR 通道)
← /node_modules/.vite/deps/vue.js (预构建产物)
← /src/main.ts (按需编译,逐个请求)
三、依赖预构建配置:optimizeDeps 的关键参数
Vite 默认会扫描入口自动决定预构建哪些依赖,但 1200+ 模块的项目里,自动扫描经常会漏掉一些。典型症状是浏览器在第一次访问时卡在 "optimizing deps..." 几十秒,或者某些组件首次加载时报 500 Internal Server Error,刷新一次又好了——这就是预构建没覆盖全。
显式声明需要预构建的依赖是最稳的做法:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
export default defineConfig({
plugins: [vue(), AutoImport({ imports: ['vue'] })],
optimizeDeps: {
// 显式预构建:把 CommonJS、体积大、被多处引用的依赖都列进来
include: [
'vue',
'vue-router',
'pinia',
'axios',
'dayjs',
'lodash-es',
'echarts/core',
'echarts/charts',
'echarts/components',
'element-plus/es',
'element-plus/es/components/message/style/css',
'@vueuse/core',
],
exclude: ['@iconify/json'], // 体积太大且按需取,不预构建
},
build: {
target: 'es2018',
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['element-plus'],
'chart-vendor': ['echarts'],
'util-vendor': ['lodash-es', 'dayjs', 'axios'],
},
},
},
},
})
四、按需引入改造:从全量引入到自动导入
迁移前项目里大量使用 import { Button, Table } from 'element-plus',配合 babel 插件做按需引入。Vite 下推荐用 unplugin-vue-components 直接做到模板里写组件名即自动引入,连 import 都不用写:
// vite.config.ts (片段)
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
plugins: [
vue(),
Components({
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
dts: 'src/types/components.d.ts',
}),
]
ECharts 同理,我们用 @echarts/vue 的 tree-shakeable 写法,只注册用到的图:
// src/plugins/echarts.ts
import { use } from 'echarts/core'
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
use([BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
这一步直接把首屏依赖体积砍掉了 1.4MB。
五、迁移中真实遇到的三个坑
1. CommonJS 依赖在浏览器里炸开
项目里有一个老的内部 SDK 是用 CommonJS 写的,module.exports = ...。Vite dev 模式下浏览器看到 require() 直接抛错。预构建通常会自动转换,但这个 SDK 用了动态 require(path),esbuild 无法静态分析,导致运行时报 Module "xxx" has no exported member "default"。
解决方法有两种,我们选了第二种:
- 把 SDK 加进
optimizeDeps.include,让它被 esbuild 提前转成 ESM。 - 更彻底:把 SDK 重写成 ESM,从根上消除问题(我们花了两天做这件事,收益是后续再也没有类似的 require 报错)。
2. 动态 import 的路径不能再用变量拼接
业务里有按权限加载菜单的实现,原来用的是:
// 错误写法:Vite 无法静态分析
const module = await import(`./views/${name}.vue`)
Vite 的 import() 路径必须能被静态分析,至少要给出一个确定的 glob。改成:
// 正确写法:用 import.meta.glob 预扫描
const modules = import.meta.glob('./views/**/*.vue')
function loadView(name: string) {
const path = `./views/${name}.vue`
return modules[path]() // 返回 Promise<Component>
}
import.meta.glob 会在构建时把所有匹配的文件都收集起来,做 tree-shake 友好的懒加载分包。
3. 环境变量 process.env 的替换
代码里到处是 process.env.VUE_APP_API_BASE。Vite 用的是 import.meta.env.VITE_*,并且 process.env 在浏览器里根本不存在。批量替换后还要在 vite.config.ts 里补一个 define,兼容一些第三方库读取 process.env.NODE_ENV 的行为:
// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
define: {
// 兼容部分库对 process.env.NODE_ENV 的读取
'process.env.NODE_ENV': JSON.stringify(mode),
'process.env.VUE_APP_API_BASE': JSON.stringify(env.VITE_API_BASE),
},
}
})
顺便把所有 .env.production 里的 VUE_APP_ 前缀统一改成了 VITE_。
六、迁移前后对比
迁移完成后我们用同一台机器、同一个分支状态重新跑了一遍基线指标:
| 指标 | 迁移前 (Webpack) | 迁移后 (Vite) | 变化 |
|---|---|---|---|
| 冷启动 | 47.3s | 4.1s | -91% |
| HMR(深层组件) | 3.2-5.1s | 0.2-0.5s | -90% |
| 生产构建 | 112s | 68s | -39% |
| 首屏依赖(gzip) | 980KB | 612KB | -38% |
| 开发期内存峰值 | 2.1GB | 1.3GB | -38% |
注意:生产构建的提速没有开发期那么夸张,因为 Vite 在生产环境用的是 Rollup,跟 Webpack 是同一量级。真正的质变发生在 dev 阶段。
七、迁移清单(建议照着核对)
- 用
npx vite-cli-bridge或手动在现有项目里装 vite,不要一上来就删 Webpack,保留双轨运行两周做灰度对比。 - 扫描所有
require()写法,逐一改成 ESM。 - 扫描所有
import(path)动态导入,改用import.meta.glob。 - 批量替换
process.env.VUE_APP_*为import.meta.env.VITE_*。 - 把 element-plus、echarts 等大库改成按需引入(unplugin 系列)。
- 配置
optimizeDeps.include和manualChunks。 - CI 上加
--mode production和sourceMap: false进一步加速。 - 灰度完成后移除 webpack.config.js,清理 vue-cli 依赖。
结语
从 Webpack 到 Vite 不是一次纯粹的"工具替换",而是一次开发范式的迁移:从"先打包再运行"到"按需编译"。理解这个转变背后的机制,比记住几个配置项更重要。如果你也在维护一个上了规模的中后台项目,希望我们这次踩过的坑能帮你少走几天弯路。下一篇会聊聊我们在迁移后做的产物分析,把那 612KB 进一步压到 480KB 以下的细节。