Vue 2 升 Vue 3 + Vite + TS 五个月实录:22 万行代码 9 个坑

22 万行 Vue 2 代码升 Vue 3 + Vite + TypeScript 全实录:vue-demi 渐进式迁移 + Vuex→Pinia + Element UI→Plus + 9 大坑(filters/v-model/template ref/第三方库/build OOM/E2E/TS/SSR hydration)。dev 启动 90s→3s,prod build 12min→4min,首屏 -43%。

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 移除









坑 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)

修法:


迁移效果对比

指标                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%

避坑清单

  1. 不要一次性大爆炸式迁移,渐进式安全得多
  2. 用 vue-demi + @vue/composition-api 在 Vue 2 上提前用 Composition API
  3. Vuex → Pinia 先在 Vue 2 上完成,降低后续切换风险
  4. Element UI → Element Plus 用 codemod 自动化,人工 review
  5. filters / v-model / 全局 API / template ref 都有 breaking,要逐项排查
  6. 第三方组件库 Vue 3 兼容情况要先调研清楚
  7. Build 内存爆调 NODE_OPTIONS + manualChunks
  8. E2E 测试用 data-cy 自定义 selector,降低 UI 变化的脆弱性
  9. TypeScript 改造分阶段,先 allowJs 宽松,再逐步 strict
  10. 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

MongoDB 4.4 升 7.0 + 副本集变分片实录:6 个真实坑

2026-5-19 12:24:20

技术教程

Node.js MySQL 连接池打满事故复盘:6 大坑和真实修法

2026-5-19 12:29:17

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索