我们维护着一个基于 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 同口径)112sCI 上更慢,约 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.3s4.1s-91%
HMR(深层组件)3.2-5.1s0.2-0.5s-90%
生产构建112s68s-39%
首屏依赖(gzip)980KB612KB-38%
开发期内存峰值2.1GB1.3GB-38%

注意:生产构建的提速没有开发期那么夸张,因为 Vite 在生产环境用的是 Rollup,跟 Webpack 是同一量级。真正的质变发生在 dev 阶段。

七、迁移清单(建议照着核对)

  1. npx vite-cli-bridge 或手动在现有项目里装 vite,不要一上来就删 Webpack,保留双轨运行两周做灰度对比。
  2. 扫描所有 require() 写法,逐一改成 ESM。
  3. 扫描所有 import(path) 动态导入,改用 import.meta.glob
  4. 批量替换 process.env.VUE_APP_*import.meta.env.VITE_*
  5. 把 element-plus、echarts 等大库改成按需引入(unplugin 系列)。
  6. 配置 optimizeDeps.includemanualChunks
  7. CI 上加 --mode productionsourceMap: false 进一步加速。
  8. 灰度完成后移除 webpack.config.js,清理 vue-cli 依赖。

结语

从 Webpack 到 Vite 不是一次纯粹的"工具替换",而是一次开发范式的迁移:从"先打包再运行"到"按需编译"。理解这个转变背后的机制,比记住几个配置项更重要。如果你也在维护一个上了规模的中后台项目,希望我们这次踩过的坑能帮你少走几天弯路。下一篇会聊聊我们在迁移后做的产物分析,把那 612KB 进一步压到 480KB 以下的细节。