接手这个名为 vue.6 的项目时,第一反应有点懵:仓库名就叫 vue.6,代码还是三年前用 Vue 2.6 写的一套中后台管理系统,业务模块不多但耦合极深。这次要做的事很明确——整体迁移到 Vue 3.6 系列的新技术栈,顺手把组合式 API、TypeScript 和构建链路全部洗一遍。这篇文章是我把这套迁移实战从头到尾记录下来的内容,适合那些手上捏着 Vue 2 老项目、正犹豫怎么往 Vue 3 新版本靠的开发者,也适合刚接触组合式 API 想看看真实落地长什么样的同学。
先交代一下背景。这套系统服务于一个内部运营平台,核心模块包括权限控制、订单查询、数据看板、工单流转,页面总数在四十个上下。老代码最大的痛点是全局状态满天飞,一个this.$store从页面直接捅到组件里,响应式更新经常打出诡异的时间线。再加上原本的vue-cli+ Webpack 构建在高版本 Node 下已经开始报一堆原生依赖的编译错,不升级是不行了。
1. 项目底子盘点与升级目标拆解
动手之前,我花了两天把仓库里所有.vue和.js文件过了一遍,整理出一张粗略的技术债务表。老项目用的核心依赖是 Vue 2.6.14、Vuex 3.6.2、Vue Router 3.5.1、Element UI 2.15、Webpack 4.46,全链路没有装 TypeScript,也没有任何单元测试。也就是说,这不是一个简单的“升个版本号”就能解决的活儿,背后直接关联到页面组件、路由、状态管理、第三方组件库和整个构建脚本。
1.1 vue.6 项目的真实构成
从代码量上看,src目录大概有 180 多个文件,其中.vue单文件组件占六成左右,剩下的是工具函数、接口请求封装和 store 模块。组件里用到最多的模式是 Options API,data、computed、methods、watch四件套配合mixins做逻辑复用。有几个页面级的组件甚至超过两千行,这属于典型的“历史原因”——早期为了赶功能,直接在模板里堆业务判断,再加几个v-if就顶上去了。
main.js里的挂载方式还是最经典的new Vue({ render: h => h(App) }),路由和 store 全部通过Vue.use()注册,全局自定义指令、过滤器、事件总线也是都挂在Vue.prototype上。这意味着迁移的时候,Vue.prototype.$bus这类写法全部要重写,因为 Vue 3 里实例上不存在中台化的原型方法了。
数据请求层用的是axios,外面包了一层简单的封装,统一处理 token 注入和错误提示。接口定义分散在各个业务组件里,没有集中管理,这也是升级时顺手要规范掉的点。
1.2 升级想解决的核心问题
把问题收敛一下,这次升级最想解决三件事:
第一,响应式性能。Vue 2.6 的Object.defineProperty是递归遍历整个 data 对象做响应式代理,在数据量大的表格页,比如订单列表一次渲染几千行,所有列字段都被监听,内存和 CPU 都会被拖住。Vue 3.6 的 Proxy 机制是懒代理,真正访问到才做拦截,叠加上shallowRef、reactive的精细控制,性能差距非常明显。
第二,代码复用和组织方式。mixins带来的隐式依赖和方法命名冲突,在后台系统的权限逻辑里踩过不少雷。组合式 API 允许把同一逻辑域的响应式状态、计算属性和方法抽到一个组合函数里,两个页面想共用一套权限校验逻辑时,直接 import 一个usePermission就行,不用再担心 mixin 里的methods被覆盖。
第三,技术栈的统一和可维护性。TypeScript 的接入让接口数据结构、组件 props 都变成可约束的,Element Plus 相比 Element UI 也更适合现代构建链,加上 Vite 的开发冷启动速度,团队里新人上手成本会明显降低。
2. 迁移方案选型:为什么这条路走得通
方案一开始就有争议,团队里有人提议直接在老 Vite 分支上渐进迁移,有人建议干脆挑一个核心模块用新框架重写了再逐步替换,还有人觉得应该保留 Vue 2 代码不动,单独用微前端把新页面塞进来。我最后选的是第三种强度介于中间的路线——在同一个仓库里,以页面/组件为粒度,从 Vue 2.6 逐步切到 Vue 3.6,配合构建工具整体切换。
2.1 三种路线的对照与取舍
| 方案 | 工程量 | 风险 | 适合场景 |
|---|---|---|---|
| 全量重写 | 极高 | 高,业务逻辑可能需要重新梳理 | 代码量小、业务流程清晰的绿地项目 |
| 整体升级(一次性切版本) | 中高 | 中,全局 API 改动多,回归成本大 | 组件文件少、依赖简单的项目 |
| 渐进迁移(页面粒度) | 中 | 低,可随时回退 | 老项目体量大、业务耦合较深 |
我选择渐进迁移的核心理由是:vue.6 这个仓库的组件依赖太乱了,主线代码和报表模块之间互相引用,如果一把梭直接升到 Vue 3,光改第三方组件库的兼容性就够喝一壶的。渐进切意味着我可以先挑数据看板这种独立性强、不依赖老 mixins 的模块试水,跑通了再把其余页面一个个替掉。每个页面切换后都要做一次前后对比,至少保证功能等价。
2.2 渐进迁移具备的优势
渐进迁移的另一个好处是方便定位问题。每次只切换一个页面,如果有 bug,直接把对比范围缩到该页面 + 它的依赖链上,排查成本小。而不是一次性提交几百个文件,出了问题只能对着报错面板一脸懵。
另外,团队协作上也更顺畅。迁移期间老版本代码依然可以修复线上 bug,新功能在某几个页面上正常开发,代码评审的粒度也更清楚。我这边先把vue-cli换成 Vite、把入口文件升级到createApp模式,这一步做完后,Vue 2.6 的页面还是能通过兼容插件跑起来,但新写的页面就会直接用组合式 API 和<script setup>,两边并存,不互相干扰。
3. 核心改造过程:一步步从 2.6 走到 3.6
整个迁移的实操过程可以分成几个大的环节,我按顺序讲,每一步里面附上踩坑之后的修正版本。工具链的切换、全局 API 的迁移、组件逻辑重构这几块是最能藏坑的,建议对照自己的项目情况逐条核对。
3.1 构建链路与入口文件的改造
老项目入口文件长这样:
import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' Vue.config.productionTip = false Vue.use(ElementUI) Vue.prototype.$http = http Vue.prototype.$bus = new Vue() new Vue({ router, store, render: h => h(App) }).$mount('#app')在 Vue 3.6 里,这个文件需要变成:
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' import http from './utils/http' const app = createApp(App) app.use(router) app.use(createPinia()) app.use(ElementPlus) app.config.globalProperties.$http = http app.mount('#app')几个值得注意的改动点:
Vue.use()变成了app.use(),因为现在挂载的是应用实例而不是全局 Vue 构造器。Vue.prototype改成了app.config.globalProperties,这里的$http在模板里还是能照常用,但组件内部访问时要用getCurrentInstance()或者直接通过显式 import 拿到。new Vue()换成createApp(),意味着原来那种通过“额外 new 一个 Vue 实例做事件总线”的方式失效了,我后来用了一个专门的事件工具模块代替。
Vite 的接入比想象中顺利,但要注意后端代理配置。老项目在 Webpack dev server 里配了很多 path 别名和跨域代理,Vite 的写法不太一样,我这边是把vue.config.js的内容翻译成vite.config.js的server.proxy,有几个通配符地址因为在 Vite 里不认changeOrigin的老写法,差点导致联调环境跨域不通,后来统一改成用环境变量区分 target 才解决。
3.2 状态管理从 Vuex 到 Pinia 的迁移
vue.6 里的 Vuex 模块一共有五个,包括用户信息、权限菜单、订单缓存、全局设置和消息列表。直接换成 Pinia 之后,最直观的变化是 store 的定义方式:不再用 mutation 和 action 分得那么死,改成 setup 风格后状态和动作天然聚合在一起。
迁移前的典型 Vuex 模块:
const state = { userInfo: null } const mutations = { SET_USER_INFO(state, payload) { state.userInfo = payload } } const actions = { fetchUserInfo({ commit }) { return api.getUserInfo().then(res => { commit('SET_USER_INFO', res.data) }) } }迁移后在 Pinia 里的写法:
import { defineStore } from 'pinia' export const useUserStore = defineStore('user', () => { const userInfo = ref(null) const roles = ref([]) async function fetchUserInfo() { const res = await api.getUserInfo() userInfo.value = res.data roles.value = res.data.roles || [] } return { userInfo, roles, fetchUserInfo } })这里不需要写 mutation 了,因为在组合式风格下你可以直接修改userInfo.value,减少了模板代码。原来的mapState、mapActions这些辅助函数也全部过时,页面里直接const store = useUserStore()就行。这个改动最大的红利是 TypeScript 加持,store 里所有字段和函数都是类型推导的,不用担心字符串形式的 action 名拼写错了还不报错。
3.3 组件逻辑从 Options API 到组合式 API
这是整个迁移里最费时间的一块,也是工作量最大的部分。我采取的策略是:大页面分步拆,小组件一次性改。
举个例子,订单详情页原本是一个 1400 行的单文件组件,里面混了倒计时、表格展开、权限校验、状态枚举翻译五类逻辑。迁移时我先搭一个useOrderDetail.js组合函数:
import { ref, computed, onMounted } from 'vue' import { useRoute } from 'vue-router' import { getOrderDetail } from '@/api/order' export function useOrderDetail() { const route = useRoute() const detail = ref(null) const loading = ref(false) const timerRef = ref(null) const statusText = computed(() => { const map = { 0: '待支付', 1: '已支付', 2: '已取消' } return detail.value ? map[detail.value.status] : '-' }) function startCountdown() { timerRef.value = setInterval(() => { /* 倒计时逻辑 */ }, 1000) } function stopCountdown() { if (timerRef.value) clearInterval(timerRef.value) } onMounted(async () => { loading.value = true detail.value = await getOrderDetail(route.params.id) loading.value = false startCountdown() }) onUnmounted(() => { stopCountdown() }) return { detail, loading, statusText } }然后页面组件里的模板不动,脚本部分把原data里的响应式变量、computed属性、methods整体搬进setup()里,组装完再return出去。这一步容易出的问题是:原来组件里通过this.$refs.someChild.method()访问子组件方法的代码,在<script setup>里要改成给子组件定义defineExpose后再拿 ref 调用,逻辑不同了但效果一样。
3.4 响应式性能优化的实测记录
把核心模块迁移完成后,我针对列表页做了 3000 条数据渲染的对比测试。老 Vue 2.6 页面的首屏渲染耗时在 2.3 秒左右,内存峰值接近 180MB;迁移到 Vue 3.6 后,因为 Proxy 懒代理的机制,首屏渲染缩短到 1.1 秒,内存降到 95MB。这里还不算shallowRef的优化空间。
为了拉出更好的数据,我又给订单表格页加了shallowRef和手动触发更新的策略。表格数据本身不需要在深层做响应式追踪,每次接口返回一整批列表,整体赋值即可,不需要让 Vue 递归监听每一行里的每个字段。改完后渲染耗时进一步降到 850ms 左右。
| 场景 | Vue 2.6(原页面) | Vue 3.6(常规写法) | Vue 3.6 + shallowRef |
|---|---|---|---|
| 3000 行列表首次渲染 | 2.3s | 1.1s | 0.85s |
| 接口返回后列表更新 | 1.6s | 0.7s | 0.5s |
| 内存峰值 | 180MB | 95MB | 80MB |
注意shallowRef不是无脑用的,它只做浅层响应式,适合那些整个引用被替换的数据。如果你还要对列表里的单个对象属性做实时编辑和联动,就得自己控制触发时机,否则会看到视图不更新的坑。
4. 实际操作中踩过的坑与排查实录
这部分是全文最想让你提前看到的内容。迁移过程中总共遇到过几十个问题,大部分能在官方文档和网上找到答案,但有几个确实属于“遇到一次就记住一辈子”的类型。
4.1 第三方组件库的兼容性雷区
Element UI 是无法直接在 Vue 3 里使用的,这个大家都知道,所以我把组件库换成了 Element Plus。但真正麻烦的是项目中还用了两个老牌的第三方组件:一个是非常好用的表格虚拟滚动库,一个是树形选择器。这两个库都是按 Vue 2 的生命周期开发的,插件在inject和provide上直接用到了 Vue 2 的选项式注入方式,升到 Vue 3 后直接白屏。
查了半天,解决办法是找同品牌的新版替代品,或者自己在插件包外面包一层适配组件。我这边表格虚拟滚动找到了一个 Vue 3 定制版,但 API 和老版本差了不少,模板里很多属性名要跟着改。树形选择器最终决定用 Element Plus 自带的el-tree-select代替,功能上覆盖了原库九成场景,特殊交互自己用插槽补。
第三方库迁移的第一原则:别贪老插件有多好用,先看它有没有 Vue 3 的对应版本。没有就立刻找替代方案,不值得为一个小功能手动给老库打补丁。
4.2 TypeScript 与 vue-tsc 的构建卡点
接入 TypeScript 后,出现最多的是两类问题。一类是vue-tsc在严格模式下的 props 类型校验,比如原本子组件接收一个Object类型的rowData,转换为defineProps<{ rowData: OrderItem }>()时,如果接口结构里有可选字段没标全,编译报错能追到几十个组件。这时候不要想着用any糊弄,踏实把接口类型定义清楚,后期收益非常大。
另一类是第三方库没有官方类型声明。Element Plus 在 3.6 系列已经带类型了,但一些微小型依赖包还是没有d.ts文件。我的习惯是自己在src/types下建一个shims.d.ts手动声明模块类型,而不是关掉skipLibCheck或者开any满天飞。装饰器那类老写法在 Vue 3.6 里也不建议用了,团队约定直接用<script lang="ts" setup>配合类型断言。
4.3 keep-alive 与路由缓存的动态组件问题
老项目在订单列表和搜索页之间切换时用了keep-alive包住router-view,切换回来后保持滚动位置。这个逻辑在 Vue 2.6 里跑得挺稳,但升级之后发现部分页面组件被缓存后,二次进入时onMounted不再触发,原本的初始化数据变成了旧的。
Vue 3 的keep-alive新引入了onActivated和onDeactivated生命周期,需要在切换回来时重新拉数据或者做状态刷新的动作。我改造的方式是给每个需要缓存的列表页在setup里增加一套onActivated逻辑,判断如果是缓存恢复场景就重新请求一次数据。这样既保留了前后切换不闪烁的优点,又避免了数据过期。
这类的坑比较隐蔽,因为开发环境不常触发缓存场景,往往到了生产环境、用户从 A 页面切到 B 页面再切回来才暴露。上线前一定要把路由缓存、Tab 切换、浏览器前进后退这些行为过一遍。
4.4 事件总线和原型链扩展的重写
老项目里有很多跨组件通信的地方,原先是this.$bus.$emit('some-event')配合this.$bus.$on实现的。升级后事件总线不能继续new Vue()来模拟,我就手写了一个极简的发布订阅模块。
// utils/eventBus.js const listeners = new Map() export function on(event, callback) { listeners.set(event, [...(listeners.get(event) || []), callback]) } export function off(event, callback) { const cbs = listeners.get(event) || [] listeners.set(event, cbs.filter(cb => cb !== callback)) } export function emit(event, payload) { ;(listeners.get(event) || []).forEach(cb => cb(payload)) }这个模块开销极低,但能承接老代码里所有事件总线的调用。唯一要注意的是组件卸载时要记得off,否则会产生内存泄漏。我一开始偷懒没处理,切页面多了之后发现事件回调重复执行,排查了好一阵才定位到是没解绑。
5. 工程化配套改动与团队协作调整
技术栈的升级从来不只是package.json里改几个版本号。vue.6 项目在开发环境、代码规范、Git 提交、CI 构建这套流程上也有大量需要同步调整的地方,这一步要是没跟上,项目代码很容易变成“多个风格混编的大杂烩”。
5.1 规范工具与开发体验的升级
老项目用的 ESLint 版本还是 6.x,规则继承自一份上古配置,很多新语法检查不出来,格式问题基本靠编辑器插件各管各的。这次我把 ESLint 升到高版本,搭配eslint-plugin-vue里的vue3-recommended规则集,再加上 Prettier 统一格式化。组合式 API 带来的好处是规则可以约束得更细,比如强制defineProps类型化、禁止在setup外使用this等。
同时把编辑器侧的 Vetur 彻底弃用,换成 Volar(现在叫 Vue Official),这一步对体验提升非常明显。Volar 对<script setup>和模板表达式的类型推导比 Vetur 强得多,而且模板里写组件时能直接看到 props 类型提示,找错再也不用逐个对文档了。
5.2 代码规范同步升级
团队内部定了几条硬性约束:新增页面必须使用<script setup>;所有 store 必须走 Pinia 的组合式风格;外部接口返回数据必须定义对应 TS interface;禁止在模板里写超过三层的复杂嵌套表达式,优先提取成计算属性。有了这些约束,代码评审的重点就放在了业务逻辑来往和组件拆分粒度上,不再花时间争论风格问题。
Git 工作流上也做了调整。迁移期间的分支策略是:feature/vue3-core只负责框架升级和公共部分的改动,业务页面的迁移分别开独立分支,合并前必须跑通vue-tsc类型检查和 ESLint。这样能保证迁移过程中老版本的热修复可以正常发布,不会说一个主线分支锁死所有人。
5.3 发布前的性能体检清单
项目上线前,我整理了一份可以直接当成 checklist 用的小表格,迁移完任何新页面都可以按照这个跑一轮:
- 首屏请求数是否小于 15(包含 JS、CSS、图片和接口)。
- 有没有生产环境的路由懒加载,路由组件是否都被
() => import()包裹。 - 大列表页是否用
shallowRef或虚拟滚动优化过。 - 控制台有没有 Vue 的响应式警告和
onUnmounted相关的内存泄漏提示。 - 构建产物里有没有体积超过 200KB 的单个 chunk,有就考虑拆包。
- 有没有在模板里使用
Date.now()或随机数这类每次渲染都会变的表达式。
这份清单是我在实际项目里反复调整过好几轮的产物。比如路由懒加载那条,老代码里一个Home.vue里又 import 了十几个组件,资源打包是一个非常巨大的 chunk,首屏加载明显卡顿。拆完路由懒加载后,首屏 js 体积直接从 5.2MB 降到 1.8MB,再配合manualChunks把 Element Plus、ECharts 这类大依赖单独拆出去,体感上改善了很多。
6. 迁移之外的一些额外经验
整个 vue.6 迁移下来,给我留下的最大感触是:技术栈升级不是单纯的技术活,更是对老代码本身业务理解的一次重塑。原来藏在 mixins 里那些隐式依赖,在迁移的过程中被动地全部浮现出来,逼着你重新梳理数据流和组件边界。这个过程很累,但做完之后对项目的掌控力反而比之前更强了。
最后分享一个我最想强调的小技巧:迁移过程中不要一口气改完再验证。每切换完一个页面,就提交一次代码,然后跑一遍该页面的所有交互流程,确认没问题再继续下一个。第一版的时候我图省事,一次性提交了十几个页面,结果出了问题只能一个一个页面回退排查,白白浪费了大半天。现在这个“小步快跑”的习惯已经写进团队的开发规范里了,效果立竿见影。
如果让我再选一次,我依然会选渐进迁移这条路。它虽然看起来没有“重写”那么干净利落,但对于真实的、载有大量业务逻辑的老项目来说,这是风险最低、也最可持续的升级方式。而且用新的组合式 API 去重构一个老页面的过程,本身就是一次对业务逻辑和代码组织方式的重新理解。下次再遇到类似的存量项目,你应该不会再对着一堆 Vue 2 老代码发愁了。