
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-ifvisible button clickemit(update:visible, false)关闭/button /div /template父组件使用方式template MyDialog v-model:visibledialogVisible / /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)} } } } /scriptVue3 直接移除了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被重命名光靠记忆容易漏。Vue2Vue3beforeCreate使用 setup 替代created使用 setup 替代beforeMountonBeforeMountmountedonMountedbeforeUpdateonBeforeUpdateupdatedonUpdatedbeforeDestroyonBeforeUnmountdestroyedonUnmountederrorCapturedonErrorCaptured如果项目还在用 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) /scriptscript 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 类型和模板引用的差异如果你在项目里引入 TypeScriptdefineProps、defineEmits会比 Options API 舒服太多。比如script setup langts interface Props { title: string count?: number } const props definePropsProps() 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()。如果你的项目是 Vitehistory 参数里可以用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 functionVue3 移除实例事件方法引入 mitt 或改用 provide/injectVue.useis not a function全局 API 变成app.use在 createApp 后调用 app.use(plugin)路由重复触发报错守卫里多次调用next改为返回值风格组件库样式错乱UI 库版本未随框架切换升级兼容 Vue3 的组件库版本并对比样式变量动态添加路由失败还在用addRoutes改成addRoute这里有一个容易被忽略的报错来源async组件写法。Vue2 里这样注册异步组件const Comp () import(./Comp.vue)Vue3 需要包一层defineAsyncComponentconst Comp defineAsyncComponent(() import(./Comp.vue))严格的 lint 和类型检查不会直接报错但运行到该组件时可能白屏。排查时如果找不到问题先看一眼异步组件的注册位置。6.2 几个容易忽略的隐蔽差异除了报错还有一些“不报错但行为变了”的地方。我挑几个影响大的列一下。第一$listeners被合并进$attrs。Vue2 里要给组件传递事件监听器时会取$listenersVue3 删掉了这个属性所有非 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中写refitemRefs会得到数组Vue3 推荐使用函数类型的 ref 来保存每个元素div v-foritem in list :refel setItemRef(el, item.id) :keyitem.id不是所有场景都会碰到但碰到时排查成本很高。6.3 迁移顺序建议经过这次项目我最推荐的迁移顺序是先让项目在新版框架下能跑再逐个模块改行为最后才做“体验优化型重构”。具体操作节奏是升级核心依赖构建工具能同时跑起来。处理模板语法层面的破坏性变更此时只保证页面内容和 Vue2 一致。再把路由、状态、组件库这些基础设施切换到位。等核心流程稳定后再按页面价值从高到低依次重构为script setup和 composables。每个阶段保留一个可回滚的提交点每个页面改完对比截图和接口数据。我个人在这一轮迁移中最深的体会是Vue3 真正的优势不是那些新语法本身而是它逼你把“隐藏规则”换成“显式代码”。比如事件总线以前写在 Vue 实例里看起来很 native实际数据流是散的改成 mitt 后每个订阅取消都明明白白。再比如$children看起来方便实际让组件依赖了树形结构改成 ref 或 provide/inject 后通信路径清晰多了。如果你也准备开始迁移别急着追求一次性切到最潮的写法先租一个稳定的桥头堡把能跑通的主干立起来再一步一步优化。这个过程中踩过的坑都会变成团队后面写代码时的默认常识。