Vue2 升级 Vue3 项目实战:从 Webpack 到 Vite 的全链路迁移指南
前言
上半年,我所在的团队对一个中大型 IoT 设备管理平台进行了技术栈升级。这个项目最初基于 Vue 2 + Vue CLI(Webpack)构建,随着业务规模增长,开发体验和构建性能的瓶颈日益突出:冷启动时间长、HMR 速度慢、生产构建内存溢出......于是我们启动了向 Vue 3 + Vite 的全链路迁移。
整个升级覆盖了路由、状态管理、全局 API、组件库、构建工具、CSS 预处理器等多个维度。本文将完整复盘整个升级过程,记录总结。
升级路线图
我们将整个升级拆分为四个阶段,按依赖关系依次推进:
阶段一 → 组件级重构:Options API → Composition API
阶段二 → 构建配置优化:node-sass → Dart Sass
阶段三 → 构建工具迁移:Webpack → Vite
阶段四 → 工程化完善:自动导入、类型声明
下面逐一展开。
阶段一:组件级 Vue 3 化重构
1.1 为什么先做组件重构?
在迁移构建工具之前,我们先花了几个月时间把几乎所有页面从 Options API 重写为 Composition API。这样做的好处是:
- 降低耦合:逻辑按功能拆分,不再散落在 data/methods/computed 中
- 问题诊断维度隔离:如果先切构建工具,代码中还混着 Vue 2 时代的模式(Vue.prototype、Options API 等),构建异常时难以区分是工具配置问题还是代码兼容性问题。先在代码层面完成 Vue 3 化,Vite 迁移阶段只涉及构建配置变更,排查路径清晰可控
- 渐进式交付:每个页面独立重构、独立测试、独立上线,风险可控
1.2 入口文件改造
Vue 3 不再使用 new Vue(),取而代之的是 createApp 的链式 API:
升级前(Vue 2 + Options API 风格):
javascript
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import VueRouter from 'vue-router'
import Vuex from 'vuex'
import ElementUI from 'element-ui'
Vue.use(ElementUI)
Vue.use(VueRouter)
Vue.use(Vuex)
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
升级后(Vue 3 + createApp):
javascript
import { createApp } from 'vue'
import App from './App.vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const app = createApp(App)
// 全局注册 Element Plus
app.use(ElementPlus)
// 全局注册图标组件
import * as Icons from '@element-plus/icons-vue'
Object.keys(Icons).forEach((key) => {
app.component(key, Icons[key])
})
// 状态管理从 Vuex 迁移到 Pinia
const pinia = createPinia()
app.use(pinia)
// 事件总线从 Vue.prototype.$bus 迁移到 mitt
import mitt from 'mitt'
app.config.globalProperties.$Bus = mitt()
// 全局挂载 axios 替代 Vue.prototype.$http
import axios from 'axios'
app.config.globalProperties.$axios = axios
app.use(router)
app.mount('#app')
1.3 路由迁移
升级前 :使用 VueRouter 构造函数 + new
javascript
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const router = new VueRouter({
mode: 'hash',
routes: [...]
})
升级后 :使用 createRouter 工厂函数
javascript
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(),
routes: [...]
})
// 路由守卫中使用 Pinia Store
import { useAppStore } from '@/store/app'
router.beforeEach((to, from, next) => {
const store = useAppStore()
// 权限校验逻辑...
})
1.4 组件重构示例
以一个设备列表页面为例,展示从 Options API 到 Composition API 的转变:
升级前:
vue
<script>
export default {
data() {
return {
tableData: [],
loading: false,
queryParams: {
page: 1,
pageSize: 20,
keyword: ''
}
}
},
computed: {
filteredData() {
return this.tableData.filter(item =>
item.name.includes(this.queryParams.keyword)
)
}
},
mounted() {
this.fetchData()
},
methods: {
async fetchData() {
this.loading = true
try {
const res = await getDeviceList(this.queryParams)
this.tableData = res.data
} finally {
this.loading = false
}
}
}
}
</script>
升级后:
vue
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { getDeviceList } from '@/api/device'
const tableData = ref([])
const loading = ref(false)
const queryParams = reactive({
page: 1,
pageSize: 20,
keyword: ''
})
const filteredData = computed(() =>
tableData.value.filter(item =>
item.name.includes(queryParams.keyword)
)
)
async function fetchData() {
loading.value = true
try {
const res = await getDeviceList(queryParams)
tableData.value = res.data
} finally {
loading.value = false
}
}
onMounted(() => {
fetchData()
})
</script>
阶段二:构建配置优化
2.1 node-sass → Dart Sass
node-sass 已于 2020 年被官方宣布弃用,且对 Node.js 版本有严格依赖。我们将项目迁移到 sass(Dart Sass 的纯 JavaScript 实现)。
diff
// package.json
{
"devDependencies": {
- "node-sass": "^7.0.0",
- "sass-loader": "^7.3.1",
+ "sass": "^1.33.0",
+ "sass-loader": "^10.2.0",
}
}
注意 sass-loader 从 7.x 升级到 10.x 时,prependData 选项已更名为 additionalData,需要在 vue.config.js 中同步修改。此外,sass 和 sass-loader 均为构建时依赖,统一放在 devDependencies 中。
2.2 Babel 插件补充
为了兼容 Vue Router v4 中 .mjs 文件的可选链语法(?.),增加了两个 Babel 插件:
javascript
// vue.config.js - chainWebpack 配置
config.module
.rule('mjs')
.test(/\.mjs$/)
.include.add(/node_modules/)
.end()
.type('javascript/auto')
.use('babel-loader')
.loader('babel-loader')
.options({
presets: ['@vue/cli-plugin-babel/preset'],
plugins: [
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-proposal-nullish-coalescing-operator',
],
})
阶段三:Webpack → Vite 构建工具迁移
这是整个升级中最核心也是最复杂的一步,涉及整个项目的文件变更。
3.1 package.json 变更
diff
{
"scripts": {
- "dev": "vue-cli-service serve",
- "build": "vue-cli-service build",
+ "dev": "vite",
+ "build": "NODE_OPTIONS='--max-old-space-size=2048' vite build",
+ "preview": "vite preview",
+ "webpack:dev": "vue-cli-service serve",
+ "webpack:build": "vue-cli-service build"
},
"devDependencies": {
+ "@vitejs/plugin-vue": "^4.6.2",
+ "@vue/compiler-sfc": "^3.5.28",
+ "vite": "^4.5.14",
+ "vite-plugin-compression": "^0.5.1",
+ "vite-plugin-require-transform": "^1.0.21",
+ "rollup-plugin-visualizer": "^5.12.0",
}
}
说明几个关键点:
- 保留 webpack 脚本作为回退方案,降低迁移风险
- 构建命令增加
--max-old-space-size=2048:由于项目依赖较多(Three.js、ECharts 等),生产构建时需要更大内存 vite-plugin-require-transform:用于兼容老代码中残留的require()语法
3.2 vite.config.js 完整配置
将 vue.config.js(CommonJS 格式)迁移为 vite.config.js:
javascript
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import viteCompression from 'vite-plugin-compression'
import { visualizer } from 'rollup-plugin-visualizer'
import requireTransform from 'vite-plugin-require-transform'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
return {
base: mode === 'production' ? './' : '/',
plugins: [
vue(),
// 兼容老代码中的 require() 语法
requireTransform({
fileRegex: /src\/.*\.(js|vue)$/,
}),
// 生产环境 Gzip 压缩
mode === 'production' &&
viteCompression({
verbose: true,
disable: false,
threshold: 10240,
algorithm: 'gzip',
ext: '.gz',
}),
// 生产环境打包分析
mode === 'production' &&
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
filename: 'dist/report.html',
}),
],
resolve: {
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
alias: {
'@': path.resolve(__dirname, 'src'),
// 修复 Element Plus 语言包路径
'element-plus/lib/locale/lang/zh-cn':
'element-plus/es/locale/lang/zh-cn.mjs',
'element-plus/lib/locale/lang/en':
'element-plus/es/locale/lang/en.mjs',
},
},
server: {
host: '0.0.0.0',
port: 7070,
open: true,
hmr: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
build: {
outDir: 'dist',
assetsDir: 'static',
sourcemap: false,
commonjsOptions: {
transformMixedEsModules: true,
},
},
define: {
__VUE_I18N_FULL_INSTALL__: true,
__VUE_I18N_LEGACY_API__: true,
__INTLIFY_PROD_DEVTOOLS__: false,
},
css: {
preprocessorOptions: {
scss: {
silenceDeprecations: ['legacy-js-api'],
},
},
},
}
})
3.3 Webpack 配置对照表
| 功能 | Webpack (vue.config.js) |
Vite (vite.config.js) |
|---|---|---|
| 路径别名 | configureWebpack.resolve.alias |
resolve.alias |
| 开发服务器 | devServer |
server |
| 代理 | devServer.proxy |
server.proxy |
| Gzip 压缩 | compression-webpack-plugin |
vite-plugin-compression |
| 打包分析 | webpack-bundle-analyzer |
rollup-plugin-visualizer |
| 环境变量 | process.env.NODE_ENV |
loadEnv(mode, ...) |
| CSS 预处理 | css.loaderOptions.sass |
css.preprocessorOptions.scss |
| 输出目录 | outputDir |
build.outDir |
| 公共路径 | publicPath |
base |
3.4 require → ESM import 改造
Vite 基于原生 ESM,不支持 Webpack 的 require.context。路由自动注册是最典型的改造场景。
Webpack 版本:
javascript
const requireComponent = require.context('../views', true, /\.vue$/)
const names = requireComponent.keys()
names.forEach(name => {
const componentConfig = requireComponent(name)
// 注册路由...
})
Vite 版本:
javascript
const modules = import.meta.glob('../views/**/*.vue', { eager: true })
for (const path in modules) {
const componentConfig = modules[path]
// 注册路由...
}
3.5 静态资源处理
Vite 中 index.html 必须放在项目根目录,不再是 public/ 目录下:
arduino
- public/index.html → index.html(根目录)
同时,index.html 中需要显式引入入口 JS:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IoT 设备管理平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
3.6 第三方库兼容处理
部分老旧的第三方库在 Vite 中需要特殊处理:
DataV 大屏组件 :原 @jiaminghi/data-view 不兼容 Vite,替换为社区维护的 @kjgl77/datav-vue3:
diff
- import dataV from '@jiaminghi/data-view'
+ import DataV from '@kjgl77/datav-vue3'
Three.js :three.meshline 需要加入预构建依赖列表:
javascript
optimizeDeps: {
include: ['immer', 'qrcodejs2-fixes', 'three.meshline'],
}
阶段四:工程化完善
4.1 自动导入配置
引入 unplugin-auto-import 和 unplugin-vue-components 实现 API 和组件的自动导入,彻底告别手动 import:
javascript
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// 在 vite.config.js 的 plugins 中添加
AutoImport({
imports: [
'vue',
'vue-router',
'pinia',
'vue-i18n',
{ '@/lang': ['t'] } // 国际化函数自动导入
],
resolvers: [ElementPlusResolver()],
dts: 'src/auto-imports.d.ts', // 生成类型声明
vueTemplate: true, // 模板中也支持自动导入
}),
Components({
dirs: ['src/components'],
extensions: ['vue'],
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
}),
配置后,组件中无需再手动导入 Vue API:
vue
<script setup>
// 不再需要这些 import
// import { ref, computed, onMounted } from 'vue'
// import { ElMessage } from 'element-plus'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function handleClick() {
ElMessage.success('操作成功') // 自动导入
}
onMounted(() => {
// 自动导入
})
</script>
4.2 Vue I18n 配置
由于项目中使用了 Vue I18n v9 的 Legacy API 模式,需要在 Vite 中设置特性标志:
javascript
define: {
__VUE_I18N_FULL_INSTALL__: true,
__VUE_I18N_LEGACY_API__: true,
__INTLIFY_PROD_DEVTOOLS__: false,
}
遇到的坑与解决方案
坑 1:生产构建 ESLint 校验警告
现象 :Vite 构建时报 'xxx' is declared but its value is never read 警告。
原因 :使用了 unplugin-auto-import 后,部分 Vue API 在 .vue 文件中被自动导入,但 ESLint 无法识别,认为变量未使用。
解决:
- 生成
auto-imports.d.ts类型声明文件供 TypeScript/ESLint 识别 - 在 ESLint 配置中扩展
auto-imports的 globals
坑 2:@vue/compiler-sfc 版本不匹配
现象:Vite 构建时出现编译错误。
原因 :@vitejs/plugin-vue 和 vue 版本存在依赖差。
解决 :确保 @vue/compiler-sfc 与 vue 的版本号匹配(同一主版本下的 minor 版本必须一致):
json
{
"devDependencies": {
"vue": "^3.2.47",
"@vue/compiler-sfc": "^3.2.47",
"@vitejs/plugin-vue": "^4.6.2",
"vite": "^4.5.14"
}
}
注意:从 Vue 3.3 开始,
@vue/compiler-sfc已内置于vue主包中,无需单独安装。如果项目使用的 Vue 为 3.3+,可直接移除该依赖项。
坑 3:require 语法残留
现象 :部分老代码中存在 require() 调用,Vite 无法直接处理。
解决 :使用 vite-plugin-require-transform 插件作为过渡方案,同时逐步将 require 改为 import:
javascript
import requireTransform from 'vite-plugin-require-transform'
// vite.config.js
plugins: [
requireTransform({
fileRegex: /src\/.*\.(js|vue)$/,
}),
]
升级效果对比
| 指标 | 升级前 (Webpack) | 升级后 (Vite) | 提升 |
|---|---|---|---|
| 冷启动时间 | ~45s | ~3s | 15x |
| HMR 热更新 | ~2-5s | <100ms | 20-50x |
| 生产构建时间 | ~180s | ~45s | 4x |
| 构建内存占用 | OOM 风险(需手动限制) | 正常(8GB 上限) | 稳定 |
| 开发体验 | 修改代码后等待 | 即时生效 | 显著提升 |
总结与建议
总结了升级过程中的经验教训,几点经验值得分享:
-
分期推进,而非大爆炸式重构。四个阶段各有明确的交付物和验证标准,每个阶段都可以独立上线,降低了风险。
-
先做组件级重构,再做构建工具迁移。把 Options API → Composition API 的改造放在第一阶段,一方面让团队熟悉 Vue 3 生态,另一方面 Composition API 的纯 ESM 导入风格天然适配 Vite,后续迁移更顺畅。
-
保留回退路径 。在
package.json中保留了webpack:dev和webpack:build脚本,万一 Vite 构建出问题,可以随时切回 Webpack 发布。 -
关注第三方依赖的 Vite 兼容性 。像
@jiaminghi/data-view、three.meshline这类老旧库可能存在兼容问题,需要提前调研替代方案或配置optimizeDeps。 -
ESLint / TypeScript 配置要及时跟进 。引入
unplugin-auto-import后,ESLint 会报变量未使用的警告,需要生成类型声明文件并更新 ESLint 配置。 -
利用 Vite 的插件生态 。
vite-plugin-compression(Gzip)、rollup-plugin-visualizer(打包分析)、unplugin-vue-components(组件自动导入)都是开箱即用的,不需要像 Webpack 那样手写复杂的 chain 配置。
本文主要是对 Vue 技术栈升级过程的一次系统性复盘,总结了我们在迁移中的关键决策、遇到的问题与解决思路。如果存在纰漏和不合理之处,欢迎在评论区交流指出。