1. 迁移决策与边界:先盘清楚“为什么”和“做到哪一步”
在决定把维护了快三年的运营后台从 Vue2 整体迁到 Vue3 之前,我犹豫了大概两三天。项目不算大,40 多个页面、20 来个公共组件,但耦合了老旧的 UI 组件库、一个自定义状态模块、还有几个靠$on/$off通信的业务文件。第一反应是“能用就行,迁移图什么”,但后来连续碰到几个同事在给项目补 TypeScript 类型时抱怨“Vue2 的 Options API 怎么写都不舒服”,再加上项目里某个第三方插件官方只剩 Vue3 版本,我才意识到这次不动不行。
这里想先说一句实话:Vue2 迁移到 Vue3 不是一次单纯换版本号的动作,而是一场围绕框架内核、工程链路、开发习惯的重构。如果你没有把“为什么迁、做到哪一步、迁砸了怎么办”这三个问题想清楚,后面大概率会陷入改一处崩一处的泥潭。
1.1 为什么迁:没有实际的收益,别轻易动框架
如果一个项目只是内部用的小工具,也没有继续迭代计划,那我其实不推荐跟风迁移。Vue2 虽然进入了维护末期,但稳定版本放在生产环境里不会自己坏掉。真正值得迁移的信号有三类:
第一类是生态倒逼。你依赖的 UI 库、图表库、权限插件要么停止维护,要么最新版只支持 Vue3。我们当时就是这个情况,某表格组件库的 Vue2 分支连续半年没更新,社区里长期挂着“已知 bug 不修”的 issue,硬要用下去成本会越来越高。
第二类是维护体验倒逼。团队要上 TypeScript、要做单元测试、要抽公共逻辑,Vue2 的 Options API 和this上下文对这些事情都很别扭。Vue3 的 Composition API 对类型推导友好得多,函数式逻辑抽离也更自然。
第三类是性能瓶颈倒逼。Vue3 的响应式系统基于Proxy,能在运行时监控对象属性的新增和删除,不需要像 Vue2 那样依赖Vue.set和Vue.delete。大表单、深层数据更新场景下的性能差异是能感知到的。
但“能迁”不等于“要迁”。如果项目已经是稳定维护期、又没有上面三类需求,那迁移带来的回归风险可能大于收益。这个判断一定要做在前面。
1.2 迁移边界:整包升级还是兼容模式渐进迁移
盘点完现状之后,你要定的是迁移策略。Vue3 官方提供过一个迁移构建版本@vue/compat,它可以让你保留大部分 Vue2 写法,同时逐步切换成 Vue3。听着很诱人,但实际用起来需要额外处理全局配置、编译器配置、依赖别名,而且它只是行为兼容,不是百分百替代。
我更推荐的做法是:先把项目分成“必须改的破坏性变更”和“可选的体验优化”两层。
必须改的包括:
v-model行为变化filter移除- 全局 API 迁移到应用实例
- 生命周期钩子名称调整
- 路由、状态管理库版本的强制切换
可选的包括:
- 把 Options API 改成 Composition API
- 把 mixins 改成 composables
- 把 Vuex 换成 Pinia
- 把 Webpack 换成 Vite
迁移范围写得越窄越好。我们当时给自己定的红线是:这一轮只保证功能一致、行为一致,不做交互重构,不顺便重写业务模块。等主流程全部跑通后,再挑核心页面逐个用 Composition API 重写。这样可以把“重构”和“迁移”分开,避免一次性承担两倍风险。
1.3 风险清单和回滚点
迁移开始前,先立好三个安全网。
第一,分支隔离。别在主干或者长期开发分支上直接动手,单独拉一条feature/vue3-migration,合并窗口设置明确的上限。
第二,基线记录。迁移前跑一遍完整的手工回归用例,重要的接口清单、页面清单、权限片段截图存档。不然后面切换完都不知道哪些行为被改坏了。
第三,回滚方案。如果在一到两周内核心模块没有跑通,允许把package.json、构建配置和源码头恢复,而不是硬扛。我们当时给团队定的原则是:任何一个阶段超过一天无法推进,就必须暂停,先回到上一个可运行提交。
注意:回滚不只是“切分支”这么简单。如果迁移过程中顺手升级了依赖,路由库和状态库的数据格式可能已经变了,回滚时要把锁文件和缓存一起还原,避免残留新依赖。
事实证明,这个边界划分帮了大忙,后面的迁移过程虽然磕磕绊绊,但整体没有出现“不可收拾”的失控状态。
2. 依赖全景扫描与升级路径
迁移开始后,我第一件事不是写代码,而是把package.json里的每一项依赖都过了一遍筛子。这一步看起来琐碎,但它决定了整个项目的骨架能不能立起来。
2.1 先做依赖资产盘点
把依赖分成这几类:
| 分类 | 典型示例 | 迁移动作 |
|---|---|---|
| 框架核心 | vue、vue-router、vuex | 必须升级到对应大版本或替换 |
| UI 组件库 | Element UI 等 | 替换为支持 Vue3 的版本或新组件库 |
| 工具函数库 | lodash、moment、axios | 一般无需动,但要确认兼容性 |
| 仅兼容 Vue2 的插件 | 某些权限指令库、进度条插件 | 找替代方案或自己封装 |
| 私有业务组件 | 项目内部公共模块 | 逐个改造 |
这个过程我习惯用一句话判断:这个依赖是不是只绑定 Vue2 的响应式机制或全局Vue对象?如果是,那它大概率是迁移路上的雷。
我们项目里最大的雷是 UI 组件库。原项目用的是 Vue2 老牌桌面端组件库,官方在 Vue3 推出后重新发布了一个新版本,但组件名、API、样式变量都有调整。我提前在另一个分支把主要页面跑了一遍,把高频组件的差异列成速查表,比如表格分页的事件名、弹窗的visible属性、表单校验的触发方式,这些都和 Vue2 版本不完全一致。
2.2 升级工具与代码扫描
依赖清单确认后,可以用工具做一轮自动扫描。Vue 官方提供过 codemod 工具,社区里也有基于语法树替换的重构工具。它不适合把复杂业务代码一步到位,但用来清理模板语法、批量替换filter、把slot-scope改成v-slot这类机械动作,效率很高。
我的建议是:自动替换只处理“无争议的等价改写”,其余一律人工确认。比如全局的filter转computed或方法调用,codemod 很难判断每个过滤器的依赖上下文,硬跑会生成一堆不合理的函数调用。
自动扫描完,再启动 ESLint 和 TypeScript 检查,看哪一类报错最密集。报错名单就是你后面排期的核心清单。我当时把报错分成了三档:红档是构建直接失败的,黄档是运行时行为不一致的,绿档是还没变成语法错误但已经废弃的写法和风格问题。
2.3 构建工具选择:Vite 还是继续 Webpack
这一步要提前决定,因为会直接影响配置文件的写法。
如果你不是特别依赖 Webpack 的深层自定义插件,我的建议是直接切到 Vite。Vue3 对 Vite 的生态支持是最好的,开发冷启动速度、依赖预构建、HMR 体验都提升明显。我们迁移后的一个最直观变化是,本地启动从原来的 20 多秒变成了 3 秒以内,团队开发效率提升很明显。
但切 Vite 需要注意一个问题:如果你有老旧的 Webpack loader 配置、自定义 proxy、复杂的多页面 html 模板,迁移成本会额外增加。可以把“换构建工具”和“换框架”解耦,先保留 Vue CLI 的 Webpack 配置把业务跑通,第二阶段再切 Vite。两种路径都可行,关键看团队是否愿意承担额外的学习成本。
3. 核心语法改造实录:v-model、filter、事件 API 与全局配置
打完地基,开始碰真正的雷区。这部分属于最熬人的阶段,因为很多代码表面上没报错,但行为已经悄悄变了。你以为是 Vue2 的某个细节记错了,其实是新版本把设计原则换掉了。
3.1 v-model 和 .sync 的双向绑定变化
Vue2 里,组件上的v-model默认等于是:value加input事件的语法糖。如果你要修改事件名,就要在组件里写model选项:
// Vue2 写法 export default { model: { prop: 'visible', event: 'change' }, props: { visible: Boolean } }Vue3 里这个写法直接失效了。v-model默认变成了:modelValue加update:modelValue事件,而且支持在同一个组件上挂多个v-model。原来的model选项被移除。
改造方式是:
<!-- Vue3 子组件 --> <script setup> defineProps({ visible: Boolean }) const emit = defineEmits(['update:visible']) </script> <template> <div v-if="visible"> <button @click="emit('update:visible', false)">关闭</button> </div> </template>父组件使用方式:
<template> <MyDialog v-model:visible="dialogVisible" /> </template>这个改动的价值不只是语法不同,它让一个组件可以同时拥有多个双向绑定通道,而不必再通过.sync去模拟。迁移时,凡是子组件里写过model选项的,优先改成update:事件模式。
3.2 filter 移除后的替代写法
Vue2 的filter在模板里用起来很顺手:
<template> <span>{{ price | formatPrice }}</span> </template> <script> export default { filters: { formatPrice(value) { return `¥${Number(value).toFixed(2)}` } } } </script>Vue3 直接移除了filter语法。这不是“语法变了”,而是模板编译层面已经不支持管道符号识别了。官方推荐用普通方法调用或者computed替代:
<template> <span>{{ formatPrice(price) }}</span> </template> <script setup> function formatPrice(value) { return `¥${Number(value).toFixed(2)}` } </script>全局注册的过滤器也需要逐个收编。我当时建议团队按一种简单规则处理:如果过滤器只在模板里用且有缓存需求,改成computed;如果要在多个页面复用,抽取为工具函数导入,或者封装成 composable。直接留在filters里的代码,构建器不会给你明确报错,但运行起来就是不输出内容,属于非常隐蔽的坑。
3.3 事件总线与全局 API 重写
Vue2 里有一个很常见的模式:用一个空的 Vue 实例做全局事件总线。比如:
// Vue2 bus import Vue from 'vue' export const bus = new Vue()然后在兄弟组件里用bus.$emit、bus.$on通信。Vue3 把 Vue 实例上的$on、$once、$off全部移除了,组件间聚合通信不再由框架内置,而是建议你去用额外的事件库。
我们最终的选择是引入mitt,体积小、API 简单,几乎是零成本替换:
import mitt from 'mitt' export const bus = mitt() // 订阅 bus.on('refresh-list', () => { loadList() }) // 触发 bus.emit('refresh-list') // 取消订阅 bus.off('refresh-list', handler)注意:
mitt的off必须传同一个函数引用,所以订阅时不要写匿名箭头函数,否则你永远取消不掉。这个和 Vue2 的$off行为一致,但踩坑的人特别多。
另一个高频翻车点是Vue.prototype。老的 HTTP 实例、消息提示、权限判断一般都会挂在Vue.prototype上。Vue3 没有全局构造函数的概念,要挂到应用实例的globalProperties上:
const app = createApp(App) app.config.globalProperties.$http = http app.config.globalProperties.$message = message这样this.$http在 Options API 里依然可用,在<script setup>里则通常直接通过 import 获取,不再依赖this。
3.4 生命周期和指令钩子的对应关系
生命周期是迁移中改动量最大但也最机械的一处。beforeDestroy和destroyed被重命名,光靠记忆容易漏。
| Vue2 | Vue3 |
|---|---|
| beforeCreate | 使用 setup 替代 |
| created | 使用 setup 替代 |
| beforeMount | onBeforeMount |
| mounted | onMounted |
| beforeUpdate | onBeforeUpdate |
| updated | onUpdated |
| beforeDestroy | onBeforeUnmount |
| destroyed | onUnmounted |
| errorCaptured | onErrorCaptured |
如果项目还在用 Options API,老名字大部分还能兼容,但如果你写<script setup>,必须用onMounted、onUnmounted这种显式导入的钩子函数。
自定义指令的钩子名也在变。Vue2 的bind、inserted、componentUpdated在 Vue3 里对应的是mounted、updated、unmounted,同时钩子参数也变成(el, binding, vnode, prevVnode)结构。我们项目里有一个权限指令,迁移时只改了钩子名,差点漏掉它判断参数中 DOM 节点的方式。
4. Composition API 重构:不必推翻一切,但要有取舍
很多文章把 Composition API 吹得很高,但在我实际迁移里,它并不是“非用不可”的选项。如果你的团队全员习惯 Options API,且项目复杂度不高,保留 Options API 完全能跑。Vue3 本身没有强制你切换写法。不过如果你像我一样想顺手解决 mixins 逻辑混乱的问题,那 Composition API 是值得投入的。
4.1 Options API 与 setup 的映射
最基本的映射关系是这样的。老代码:
export default { data() { return { list: [], loading: false } }, computed: { isEmpty() { return this.list.length === 0 } }, methods: { async loadList() { this.loading = true this.list = await fetchList() this.loading = false } }, mounted() { this.loadList() } }新的等价写法:
import { ref, computed, onMounted } from 'vue' export default { setup() { const list = ref([]) const loading = ref(false) const isEmpty = computed(() => list.value.length === 0) async function loadList() { loading.value = true list.value = await fetchList() loading.value = false } onMounted(loadList) return { list, loading, isEmpty, loadList } } }刚迁移时最容易错的地方有两个。一个是访问方式,ref响应式变量在 JavaScript 里必须写.value,模板里会自动解包;另一个是返回对象,忘了把页面模板用到的变量和方法在前面的return里暴露出来,页面就会白屏。
如果你不想写return一堆名字,直接用<script setup>是更现代的方案:
<script setup> import { ref, computed, onMounted } from 'vue' const list = ref([]) const loading = ref(false) const isEmpty = computed(() => list.value.length === 0) async function loadList() { loading.value = true list.value = await fetchList() loading.value = false } onMounted(loadList) </script><script setup>里声明的变量和函数,默认在模板里可用,不需要手动导出。这也是我后来新页面优先选择的写法。
4.2 让复用逻辑从 mixins 进化为 composables
在我迁移前,项目里有不少加载数据、分页、筛选的公共逻辑分散在多个 mixins 里。Vue2 的 mixins 看起来复用方便,但多个 mixin 合并时同名方法互相覆盖,数据来源也难查,时间一长就成了黑盒。
借助 Composition API,我可以把一套逻辑完整抽成一个函数,状态是函数内部创建的,谁调用谁持有自己的副本:
export function useListData(fetcher) { const list = ref([]) const loading = ref(false) async function loadList() { loading.value = true try { list.value = await fetcher() } finally { loading.value = false } } return { list, loading, loadList } }组件里的调用方式:
<script setup> import { useListData } from '@/composables/useListData' const { list, loading, loadList } = useListData(fetchOrderList) onMounted(loadList) </script>这种做法的好处是,每个组件可以按需组合多种通用能力,不再有隐形的合并规则。从 mixins 迁移成 composables 时,我先挑了一个最核心的列表页验证整套模式,再逐步推广到其他页面。
4.3 类型和模板引用的差异
如果你在项目里引入 TypeScript,defineProps、defineEmits会比 Options API 舒服太多。比如:
<script setup lang="ts"> interface Props { title: string count?: number } const props = defineProps<Props>() const emit = defineEmits<{ (e: 'update', value: number): void }>() </script>这种写法在 Vue2 里几乎不可能做到这么直观。不过要注意,使用defineProps时不能在<script setup>里用结构赋值,否则会破坏响应性。要拿到单个 prop,直接props.title即可;如果确实想结构,用toRefs。
5. 路由与状态管理迁移实战
框架语法改完之后,接下来是项目里最影响运行的部分:路由和状态。这个阶段不是单纯换包名,很多全局配置和调用方式都要跟着变。
5.1 Vue Router 3 到 4 的改造
Vue Router 3 的写法是:
import VueRouter from 'vue-router' Vue.use(VueRouter) const router = new VueRouter({ mode: 'history', routes })Vue Router 4 必须改成:
import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })mode: 'history'变成了一个独立函数createWebHistory(),hash 模式对应createWebHashHistory()。如果你的项目是 Vite,history 参数里可以用import.meta.env.BASE_URL;如果还在 Webpack,用process.env.BASE_URL。
路由守卫这里要提醒一下。Vue Router 4 仍然兼容next回调风格,但官方更推荐直接返回结果。老的:
router.beforeEach((to, from, next) => { if (!isLogin()) { next('/login') } else { next() } })可以改写成:
router.beforeEach((to) => { if (!isLogin()) { return '/login' } })不返回或返回true就放行,返回路径对象就执行跳转。这样能避免连续调用next导致的“重复导航”警告。
另外要检查所有router.addRoutes的调用。Vue Router 4 已经移除了这个方法,动态添加路由统一用router.addRoute。
5.2 Vuex 到 Pinia 的落地
状态管理这一层,我属于“先保留、后替换”派。第一轮迁移时,项目用的 Vuex 3 和 Vue Router 3 是配套的,Vuex 4 也可以在 Vue3 里继续用,所以先把Vue.use(Vuex)改成:
import { createStore } from 'vuex' const store = createStore({ state: {...}, mutations: {...}, actions: {...} })如果你决定进一步换成 Pinia,收益在于:没有 mutation 概念、不需要mapState这类辅助函数、天然支持 TypeScript、体积也更小。Pinia 的defineStore和 setup 风格非常搭:
import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useUserStore = defineStore('user', () => { const token = ref('') const isLogin = computed(() => !!token.value) function setToken(value) { token.value = value } return { token, isLogin, setToken } })组件里用:
import { useUserStore } from '@/stores/user' const userStore = useUserStore() userStore.setToken('abc')从 Vuex 迁到 Pinia 时,最需要留意的是“模块化”写法。Vuex 的 modules 在 Pinia 里每个 store 就是独立文件,不再有命名空间参数。排查数据变化时,直接看对应 store 的文件,心智负担小很多。
6. 常见问题与排查技巧实录
最后这部分是踩坑记录。迁移过程中我整理了几个高频问题,写成速查表后,团队参照着排查,效率提升明显。
6.1 高频报错速查
| 报错或现象 | 常见原因 | 处理方式 |
|---|---|---|
Cannot read properties of undefined (reading 'xxx') | 多因this.$refs.xxx或this.$children在生命周期更早阶段访问 | 在onMounted或不带 nextTick 的时机访问 |
| 模板里的过滤器不生效 | Vue3 移除了 filter | 改成方法调用或 computed |
事件总线$onis not a function | Vue3 移除实例事件方法 | 引入 mitt 或改用 provide/inject |
Vue.useis not a function | 全局 API 变成app.use | 在 createApp 后调用 app.use(plugin) |
| 路由重复触发报错 | 守卫里多次调用next | 改为返回值风格 |
| 组件库样式错乱 | UI 库版本未随框架切换 | 升级兼容 Vue3 的组件库版本并对比样式变量 |
| 动态添加路由失败 | 还在用addRoutes | 改成addRoute |
这里有一个容易被忽略的报错来源:async组件写法。Vue2 里这样注册异步组件:
const Comp = () => import('./Comp.vue')Vue3 需要包一层defineAsyncComponent:
const Comp = defineAsyncComponent(() => import('./Comp.vue'))严格的 lint 和类型检查不会直接报错,但运行到该组件时可能白屏。排查时如果找不到问题,先看一眼异步组件的注册位置。
6.2 几个容易忽略的隐蔽差异
除了报错,还有一些“不报错但行为变了”的地方。我挑几个影响大的列一下。
第一,$listeners被合并进$attrs。Vue2 里要给组件传递事件监听器时会取$listeners,Vue3 删掉了这个属性,所有非 prop 属性和事件监听器都统一放进$attrs。如果代码里有手工封装透传属性的函数,需要检查。
第二,$children被移除。Vue2 的this.$children可以拿到所有子组件实例,但这种访问方式脆弱。Vue3 里推荐用ref或者provide/inject获取特定子组件。我们项目里有一个布局组件依赖$children收集子菜单,迁移时改成在子组件 mounted 时主动上报,链路反而更清晰了。
第三,过渡动画的类名变了。Vue2 的v-enter、v-leave在 Vue3 里改成了v-enter-from、v-leave-from。如果你的全局样式里覆盖过这些类名,切换后动画会失效。
第四,v-for里的ref行为不再自动组装数组。Vue2 里在v-for中写ref="itemRefs"会得到数组;Vue3 推荐使用函数类型的 ref 来保存每个元素:
<div v-for="item in list" :ref="el => setItemRef(el, item.id)" :key="item.id">不是所有场景都会碰到,但碰到时排查成本很高。
6.3 迁移顺序建议
经过这次项目,我最推荐的迁移顺序是:先让项目在新版框架下能跑,再逐个模块改行为,最后才做“体验优化型重构”。
具体操作节奏是:
- 升级核心依赖,构建工具能同时跑起来。
- 处理模板语法层面的破坏性变更,此时只保证页面内容和 Vue2 一致。
- 再把路由、状态、组件库这些基础设施切换到位。
- 等核心流程稳定后,再按页面价值从高到低依次重构为
<script setup>和 composables。 - 每个阶段保留一个可回滚的提交点,每个页面改完对比截图和接口数据。
我个人在这一轮迁移中最深的体会是,Vue3 真正的优势不是那些新语法本身,而是它逼你把“隐藏规则”换成“显式代码”。比如事件总线以前写在 Vue 实例里,看起来很 native,实际数据流是散的;改成 mitt 后每个订阅取消都明明白白。再比如$children看起来方便,实际让组件依赖了树形结构;改成 ref 或 provide/inject 后通信路径清晰多了。
如果你也准备开始迁移,别急着追求一次性切到最潮的写法,先租一个稳定的桥头堡,把能跑通的主干立起来,再一步一步优化。这个过程中踩过的坑,都会变成团队后面写代码时的默认常识。