2024 年我们把一个 Vue 2 项目升级到 Vue 3 + Vite + TypeScript,代码量 22 万行,32 个开发者参与,历时 5 个月。期间踩了 9 个大坑:第三方组件库不兼容、Composition API 转换困难、build 内存爆、E2E 测试用例改一半、SSR hydration 不一致、IE 弃用引发兼容性问题、Pinia 替代 Vuex 数据结构调整、template ref 改写、Element UI 到 Element Plus 迁移。本文复盘完整迁移流程 + 真实数据 + 决策。
项目背景
原项目(2021 年立项):
- Vue 2.6 + Vuex 3 + Vue Router 3
- Element UI(Vue 2 版本)
- webpack 4 + vue-cli 4
- Babel + ESLint + Jest + Cypress
- 22 万行代码,180 个页面,580 个组件
- npm install 7 分钟,dev 启动 90 秒,生产 build 12 分钟
升级动因:
1. Vue 2 已经 EOL(2023 年 12 月停止维护)
2. 性能问题:大列表渲染卡顿,Vuex mutation 写起来啰嗦
3. 招聘:新人都用 Vue 3 + Composition API,Vue 2 找不到人
4. 工具链:Vite 比 webpack 快 10x,DX 提升明显
5. TypeScript:Vue 3 类型推导比 Vue 2 强太多
升级技术选型
迁移目标栈:
- Vue 3.4
- Vite 5
- TypeScript 5.3
- Pinia 2(替代 Vuex)
- Vue Router 4
- Element Plus(替代 Element UI)
- Vitest(替代 Jest)
- Playwright(替代 Cypress)
候选方案:
1. 大爆炸式:全部重写 → 风险太高,业务停摆
2. 渐进式:vue-demi + @vue/composition-api → Vue 2 也能用 Composition API,过渡平滑
3. 路由级别迁移:新页面 Vue 3,老页面 Vue 2 共存(微前端)→ 太复杂
最终选方案 2:vue-demi 过渡 + 分批迁移
阶段 1:引入 Composition API(Vue 2)
# 1. 装 @vue/composition-api
$ npm i @vue/composition-api
# 2. main.ts 注入
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
Vue.use(VueCompositionAPI);
阶段 2:Vuex → Pinia
// Vuex(老)
// store/modules/user.ts
const state = {
name: '',
token: '',
permissions: []
};
const mutations = {
SET_NAME(state, name) { state.name = name; },
SET_TOKEN(state, token) { state.token = token; },
SET_PERMISSIONS(state, perms) { state.permissions = perms; }
};
const actions = {
async login({ commit }, { username, password }) {
const { data } = await api.login(username, password);
commit('SET_TOKEN', data.token);
commit('SET_NAME', data.name);
commit('SET_PERMISSIONS', data.perms);
}
};
export default { namespaced: true, state, mutations, actions };
// 调用:this.$store.dispatch('user/login', {...});
// 读取:this.$store.state.user.name;
// Pinia(新)
// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
// State
const name = ref('');
const token = ref('');
const permissions = ref([]);
// Getters
const isLoggedIn = computed(() => !!token.value);
const canEdit = computed(() => permissions.value.includes('edit'));
// Actions
async function login(username: string, password: string) {
const { data } = await api.login(username, password);
token.value = data.token;
name.value = data.name;
permissions.value = data.perms;
}
function logout() {
token.value = '';
name.value = '';
permissions.value = [];
}
return { name, token, permissions, isLoggedIn, canEdit, login, logout };
});
// 调用:
// const userStore = useUserStore();
// await userStore.login(username, password);
// console.log(userStore.name);
// 持久化:用 pinia-plugin-persistedstate
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
阶段 3:Webpack → Vite
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vue2 from '@vitejs/plugin-vue2'; // Vue 2 用这个
import path from 'path';
import { fileURLToPath, URL } from 'node:url';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
vue(), // 切到 Vue 3 后用这个
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
dts: 'src/auto-imports.d.ts'
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts'
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
target: 'es2020',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
element: ['element-plus'],
echarts: ['echarts'],
utils: ['lodash-es', 'dayjs']
}
}
}
},
optimizeDeps: {
include: ['vue', 'vue-router', 'pinia', 'element-plus', 'axios']
}
});
阶段 4:Vue 2 → Vue 3 切换
# 升级依赖
$ npm uninstall vue@2 vue-template-compiler @vue/composition-api
$ npm i vue@3 vue-router@4 pinia@2
$ npm i -D @vitejs/plugin-vue @vue/compiler-sfc
# 卸载 vue-demi(以前的兼容层)
$ npm uninstall vue-demi
# 全局搜索替换
$ grep -rln 'from "vue-demi"' src/ | xargs sed -i 's|from "vue-demi"|from "vue"|g'
# main.ts 写法变化
# 旧:new Vue({ render: h => h(App) }).$mount('#app');
# 新:createApp(App).use(router).use(pinia).mount('#app');
// main.ts 完整版
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
// 全局错误处理
app.config.errorHandler = (err, instance, info) => {
console.error('Vue error:', err, info);
Sentry.captureException(err);
};
app.mount('#app');
坑 1:Element UI 没 Vue 3 版本
Element UI 不支持 Vue 3,要换 Element Plus
两个库 API 大部分兼容,但有差异:
| Element UI | Element Plus |
|------------------------------|------------------------|
| el-table-column 默认 visible | 同名,但 column 改动多 |
| el-message 全局 | 必须 import 单独使用 |
| el-dialog visible.sync | v-model="visible" |
| el-form ref="form" | el-form ref="formRef" |
| this.$confirm() | ElMessageBox.confirm() |
| el-date-picker value-format | value-format 不一样 |
修法:写自动化 codemod 替换,人工 review
# 自动化迁移脚本
$ npm i -g @vue/cli-migrate-tool
$ vue-migrate src/ --from element-ui --to element-plus
# 或手动写 codemod
$ npx jscodeshift -t element-ui-to-plus.js src/
# codemod 例子:visible.sync → v-model
# transforms/visible-sync.js
module.exports = function(file, api) {
const j = api.jscodeshift;
return j(file.source)
.find(j.JSXAttribute, { name: { name: 'visible.sync' } })
.forEach(path => {
path.node.name.name = 'v-model';
})
.toSource();
};
坑 2:filters 移除
{{ price | currency }}
{{ date | formatDate('YYYY-MM-DD') }}
{{ formatCurrency(price) }}
{{ formatDate(date, 'YYYY-MM-DD') }}
坑 3:v-model 改动
坑 4:template ref 改写
...
...
坑 5:Vue 2 第三方组件库
用了 vue-quill-editor、vue-draggable、vue-virtual-scroller、vue-i18n@8
都不支持 Vue 3,要替换:
| Vue 2 包 | Vue 3 替代 |
|-------------------------|-------------------------|
| vue-quill-editor | @vueup/vue-quill |
| vue-draggable | vue-draggable-plus |
| vue-virtual-scroller | @vueuse/core useVirtualList |
| vue-i18n@8 | vue-i18n@9 (API 调整) |
| vuelidate@0.7 | @vuelidate/core |
| vue-meta | @vueuse/head |
| vue-property-decorator | 删,改 Composition API |
逐个调研 + 迁移,占了 30% 时间
坑 6:Build 内存爆
Vite build 大项目报错:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
原因:rollup 处理 22 万行代码 + manualChunks 配置不当
修法:
1. 加内存
$ NODE_OPTIONS=--max-old-space-size=8192 npm run build
2. 分 chunk
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('element-plus')) return 'element';
if (id.includes('echarts')) return 'echarts';
if (id.includes('lodash')) return 'lodash';
return 'vendor';
}
if (id.includes('/src/views/')) {
// 按 view 目录拆 chunk
const match = id.match(/src\/views\/([^/]+)/);
return match ? `view-${match[1]}` : 'common';
}
}
}
}
}
3. 关掉 sourcemap(prod 只在 staging 开)
build: { sourcemap: process.env.NODE_ENV === 'staging' }
坑 7:E2E 测试用例
// Cypress 老用例
describe('登录', () => {
it('成功登录', () => {
cy.visit('/login');
cy.get('.el-input__inner').first().type('admin');
// ...
});
});
// 问题:Element Plus 类名变了,.el-input__inner → .el-input__wrapper input
// 重写 selector,但 22 万行 +500 个测试用例,要批量改
// 改造方案:用 data-cy 自定义选择器
// 测试代码
cy.get('[data-cy="username-input"]').type('admin');
// 切到 Playwright
import { test, expect } from '@playwright/test';
test('成功登录', async ({ page }) => {
await page.goto('/login');
await page.locator('[data-cy="username-input"]').fill('admin');
await page.locator('[data-cy="password-input"]').fill('password');
await page.locator('[data-cy="submit-btn"]').click();
await expect(page).toHaveURL('/dashboard');
});
坑 8:TypeScript 全量改造
# 22 万行 .vue/.js 改 .ts/.vue + setup lang="ts"
# 不能一次改完,分阶段:
# 阶段 1:tsconfig.json 宽松模式
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"strict": false,
"noImplicitAny": false,
"skipLibCheck": true
}
}
# 阶段 2:核心模块改 ts(api / store / utils)
# 阶段 3:组件逐个改 lang="ts"
# 阶段 4:strict 开起来,修类型错误
# 工具:vue-tsc(类型检查)
$ npm i -D vue-tsc
$ vue-tsc --noEmit
# CI 加 type check
"scripts": {
"type-check": "vue-tsc --noEmit",
"build": "npm run type-check && vite build"
}
坑 9:SSR Hydration 不一致
老项目用 SSR(Nuxt 2),迁移到 Nuxt 3 时报 Hydration mismatch
原因:
1. 服务端渲染时间 vs 客户端不一致(Date.now())
2. 浏览器特定 API 在服务端跑了(window / localStorage)
修法:
当前时间:{{ now }}
迁移效果对比
指标 Vue 2 + Webpack Vue 3 + Vite 变化
=============================================================
代码行数 22w 20w(削减重复) -10%
npm install 7 min 2 min -71%
dev 启动 90s 3s -97%
HMR 速度 2-5s < 100ms -95%
prod build 12 min 4 min -67%
bundle size 4.2MB 2.8MB -33%
首屏 LCP 2.8s 1.6s -43%
TypeScript 类型覆盖 0% 85%
团队招聘速度 慢 快(Vue 3 是主流)
业务影响:
- 用户首屏快了 1.2 秒,转化率 +5%
- 开发体验提升,人均 PR 数 +30%
- 类型错误在编译期发现,生产 bug -40%
避坑清单
- 不要一次性大爆炸式迁移,渐进式安全得多
- 用 vue-demi + @vue/composition-api 在 Vue 2 上提前用 Composition API
- Vuex → Pinia 先在 Vue 2 上完成,降低后续切换风险
- Element UI → Element Plus 用 codemod 自动化,人工 review
- filters / v-model / 全局 API / template ref 都有 breaking,要逐项排查
- 第三方组件库 Vue 3 兼容情况要先调研清楚
- Build 内存爆调 NODE_OPTIONS + manualChunks
- E2E 测试用 data-cy 自定义 selector,降低 UI 变化的脆弱性
- TypeScript 改造分阶段,先 allowJs 宽松,再逐步 strict
- SSR 项目特别注意 hydration mismatch,浏览器 API 用 onMounted 或 ClientOnly
总结
Vue 3 迁移是 2024 年很多老项目的必经之路,5 个月的投入看起来长,但收益是长期的:首屏速度 +43%、bundle 体积 -33%、招聘容易、类型覆盖 85%。最大的认知改变:升级不是技术决策,是组织决策 — 32 个开发者协作迁移,需要充分的规范、培训和工具链支持。Composition API 的学习曲线比想象中陡(很多人卡在 ref vs reactive),但写多了之后就回不去 Options API 了。如果你的项目还在 Vue 2,2024 年是最佳迁移窗口 — Vue 2 已经 EOL,继续不升级技术债会越积越多。
—— 别看了 · 2026