跳到主要内容

keystone-web 从 Vue 2 到 Vue 3:一场两段式迁移的复盘

· 阅读需 16 分钟
feilx
the biulight site owner

把一个有 331 个 .vue 组件、仍在持续迭代的管理后台从 Vue 2 迁到 Vue 3,最危险的想法是: “把依赖升级完,再把报错逐个修掉。”真正的迁移对象并不只有 Vue,而是路由、状态管理、UI 库、 表格封装、构建链、测试和数百个已经被用户使用的交互。

这次升级实际上分成了两段:先将 Vue 2.x + Vue CLI 迁到 Vue 2.7 + Rsbuild 并上线;随后让它在线上 稳定运行约半年,在这段时间持续清理技术债、删除不再需要的代码、尽量精简实现;最后才将已经稳定的 Vue 2.7 应用升级到 Vue 3。第二段在专用分支中阶段化完成,旧版本继续承接发布,迁移版本验收后再 整体切换。它没有演变成业务重写,也没有趁机换 Pinia 或 Vite;这份克制比任何 API 转换技巧都更重要。

阶段主要工作结束条件
第一段:构建迁移Vue CLI → Vue 2.7 + Rsbuild,保持业务行为新构建链上线并稳定运行
稳定与清债期熟悉业务边界,删除无用代码,使用跨 Vue 2.7 / Vue 3 的 API业务边界逐步明确,待迁移代码范围收敛
第二段:运行时迁移Vue 3、Router 4、Element Plus、VXE Table 4 与测试体系升级移除兼容层,完成自动化与手工验收

第一段:先把构建系统换掉,再给业务留出稳定期

第一段从 Vue CLI 5 迁到 Rsbuild 时,应用仍运行在 Vue 2.7。目标不是借机升级框架,而是将构建链 现代化并消除已经没有价值的历史配置:移除 Vue CLI 及其插件、删除 vue.config.js 等旧配置,改用 rsbuild.config.mjs;SVG 从不兼容 Rspack 的 svg-sprite-loader 改为原生 asset/source 加载, 组件的使用方式保持不变。

采用这条路线也有现实原因:接手时我对这个项目并不熟悉,手上缺少完整的业务与技术文档。此时若同时 替换构建器、运行时、组件库并修改业务逻辑,任何异常都很难回答“它来自旧行为、构建链、Vue 3,还是 我尚未理解的业务规则”。先把范围收缩为 Vue 2.7 + Rsbuild 上线,才能在不改变主要业务语义的前提下 建立对系统的认识。

这次迁移也统一了浏览器端环境变量边界。业务代码从 process.env.VUE_APP_* 改为 import.meta.env.VUE_APP_*;少数第三方包所需的 process.env 则只在构建配置中提供兼容 shim。 这样既避免浏览器里的 process is not defined,也不需要为了兼容旧写法继续在业务代码中传播 Node.js 运行时假设。

Rsbuild 上线后,项目在 Vue 2.7 状态继续正常迭代约半年。这段稳定期的重点不是继续验证已经上线的 构建工具,而是让团队通过日常需求和真实业务流程,逐步确认业务边界与历史行为。在这个过程中,团队 持续清理技术债、删除非必要代码,让后续需要维护和迁移的范围更小、更清晰。等到 Vue 3 迁移启动时, 面对的是一套业务基线清楚、代码范围已经收敛的系统,不再需要一边迁移一边猜测旧逻辑。

这也是两段式路线最大的收益——先完成构建工具切换,再留出理解业务和收敛代码的稳定阶段,避免把 构建系统变化、Vue 运行时升级和组件库替换压在同一批改动中。第二阶段因此可以把注意力集中在 Vue 3 迁移本身。

Vue 2.7 不是终点,而是 Vue 3 的预备跑道

这半年的重构并非只做删除。Vue 2.7 原生提供了 Composition API,因此在新增或调整逻辑时,优先选用 refreactivecomputedwatchonMounted 等 Vue 2.7 与 Vue 3 都支持的能力;保留既有 Options API,但不再继续扩散只服务于 Vue 2 的写法。这样做不会改变已经稳定的页面风格,却能让每一次 局部维护都向 Vue 3 靠近。

this.$set 是典型例子。Vue 2 需要它来让新增对象属性或数组索引更新具备响应式;Vue 3 基于 Proxy, 普通赋值已经会响应。预备期内,改到相关代码时就倾向写成 object[key] = valuearray[index] = value,而不是继续引入 $set。这既减少了后续迁移的工作量,也让代码表达的意图更 直接。

ESLint 在这个过程中承担的是“逐步收紧的迁移护栏”,而不是一次性把历史代码全部打红。Vue 2.7 阶段, 为避免存量项目立刻被遗留写法阻断,曾对 v-bind.sync$set / $delete 相关的废弃规则保留豁免; 同时约束新增或重构代码不要继续引入这些 Vue 2 专属模式。正式切到 Vue 3 后,配置目标才改为 Vue 3.5 并移除豁免,让 lint 明确报出 .sync、旧销毁生命周期、.native$listeners 等不再可用的写法, 而不是让它们在运行时才暴露。

这条护栏最终找出了 18 个文件中的 48 处 $set 调用,并将它们全部替换为普通对象或数组赋值;项目里 没有实际的 $delete 调用。14 个文件中的 20 处 .native 以及 destroyed / beforeDestroy 生命周期, 则属于切换到 Vue 3 运行时之后才能处理的破坏性变更:它们分别改为 Vue 3 的事件透传方式和 unmounted / beforeUnmount。后者不能提前放进 Vue 2.7 代码库。重要的不是这些数字本身,而是节奏: 先让新的维护代码不再制造 Vue 2 专属债务,再在运行时切换后用规则集中清理只能由 Vue 3 支持的写法。

插槽也遵循同一原则。Vue 2.7 已支持 Vue 3 使用的 v-slot / # 语法,因此在持续重构中,模板插槽 应从旧的 slot="name"slot-scope="scope" 迁向 #name="scope"。例如:

<!-- Vue 2 旧写法:Vue 3 不支持 -->
<template slot="default" slot-scope="scope">
{{ scope.row.name }}
</template>

<!-- Vue 2.7 与 Vue 3 都支持 -->
<template #default="scope">
{{ scope.row.name }}
</template>

不过这不是一次安全的全文替换。全仓检索到 122 个 slot="..." 命中,其中混有真正需要迁移的模板、 JSX render 中合法的 slot 属性,以及注释里的死代码。正确做法是先让新增和改动的模板采用 v-slot,再在 Vue 3 阶段逐个分类处理存量命中;把 JSX 一并替换反而会制造新的问题。

AI 的角色:先生成可审查的计划,再分批执行

全仓盘点完成后,Fable 5 基于已核实的依赖、特殊用法和范围边界生成迁移 PRD;Opus 4.7 与 Sonnet 5 再按阶段、任务批次和验证门槛实施。Claude Code 则把手工验收中发现的现象、根因和规则持续沉淀到 迁移记录与项目记忆中。AI 加速了盘点、模式化修改与信息整理,但每一个结论仍须由代码、测试、构建 结果或手工观察验证。

这套协作方式、一次路由参数误判造成的回归,以及自动化和人工验收如何互补,详见 AI 参与生产系统迁移:把模型能力放进可验证的交付流程

先做选择:为什么不是长期渐进迁移

进入第二段时,项目已经是 Vue 2.7、Vue Router 3、Vuex 3、Element UI 2、VXE Table 3、 vue-i18n 8 和 Rsbuild 的组合。目标是 Vue 3.5、Vue Router 4、Vuex 4、Element Plus、VXE Table 4 和 vue-i18n 9,同时保留已经稳定的 Rsbuild 与既有 Vuex 模块结构。

一开始看起来有三条路:全量重写、长期使用 @vue/compat 渐进迁移,或用微前端让两套运行时并行。 最后都不适合这个项目:

  • 331 个组件的全量重写会把业务回归风险推到最高;
  • 在本项目中,Element UI 2 与 VXE Table 3 不具备可作为发布形态的 Vue 3/compat 支持,两项核心 UI/表格依赖仍须整体迁至对应的 Vue 3 版本;
  • 管理后台共享登录态、布局、权限与路由,专门建设微前端的成本远高于收益。

于是选择“阶段化的一次性迁移”。@vue/compat 只在中间阶段以 MODE 2 运行,用运行时警告当作 迁移待办扫描器;发布前必须移除。这个边界很关键:兼容层可以帮助发现问题,但不能成为问题被搁置的 理由。

先固定什么都不改时的基线

迁移第一步不是改 main.js,而是建立可回归的事实基线。

项目先补齐并跑通 Playwright 冒烟链路:登录、Dashboard、Campaign 列表和分页、报表、组织管理 弹窗。它们覆盖了权限路由、VXE Table、Element UI 表单和常见异步请求。与此同时,用 pnpm analyze 记录生产包体:迁移前未压缩总大小为 15345.0 kB,gzip 后为 7397.6 kB。

这份基线让后续每一个“看起来像 Vue 3 问题”的现象都有判断依据:它是原有问题、迁移回归,还是 尚未覆盖的新路径。没有它,构建通过只会给人错误的安全感。

分阶段切换:先打通运行时,再迁业务

迁移没有按目录从上到下改,而是按依赖关系推进。

第一阶段替换框架基础设施:Rsbuild 的 Vue 2 插件换成 Vue 3 插件,new Vue() 改为 createApp()Vue.prototype 改到 app.config.globalProperties,路由切到 createRouter() / createWebHistory(),并将动态路由注册从 addRoutes 改为逐条 addRoute。 Vuex 3 升 Vuex 4 后,已有五个 namespaced 模块的业务 API 基本不需要改;vue-i18n 9 则采用 legacy 模式,降低了模板国际化调用的改造量。

第二阶段才整体替换第三方库:Element UI → Element Plus,VXE Table 3 → 4,并接入与 Element Plus 对应的 VXE 渲染插件。紧接着处理 Vue 3 的机械性变化,例如 .syncv-model:prop、 指令钩子改名、过时插槽语法、$listeners$scopedSlots 和 test-utils v2 的配置变化。

机械替换也有边界。全仓有 151 处 .sync,可以分批转换并结合 diff 与构建检查;但 slot="..." 命中了 122 个文件,其中既有待迁的模板语法,也有合法的 JSX 属性和注释。对它全局替换会直接破坏 代码。类似地,路由守卫、全局指令、BiuTable / selectgrid 这类 VXE Table 深度封装,以及 BiuDialog / BiuForm 这样的基础组件,都必须按实际行为人工审查。

业务页面放到最后,并按依赖与规模推进:先公共页、登录、Dashboard,再到系统配置、报表、订单, 最后才是近百个组件的 Campaign 模块。每批变更独立提交,并在进入下一批前运行构建、单测和对应 页面的手工验证。这样出现回归时,排查范围仍然可控。

每个阶段采用的验证目标也不同:基础设施阶段先验证应用能到登录页;替换组件库后验证 Dashboard 和一个 VXE Table 列表;公共组件阶段为每一批组件找一个宿主页面;业务模块阶段再执行相应 E2E 与 完整手工清单。最后才同时要求 lint、单测、生产构建、E2E、兼容警告清理和包体对比。把验证放在 阶段门槛上,比在最后堆一次全量测试更容易定位问题。

验收阶段发现:最棘手的错误往往不在构建期

下面几个问题都没有被“构建成功”捕获,却最能说明为什么迁移需要真实页面和真实交互的回归。

动态组件渲染出 [object Promise]

Vue 2 会把 :is 接收的 () => import('./Component.vue') 当作异步组件工厂;Vue 3 不再隐式做 这个判断。Campaign 详情页按媒体类型切换子组件时,页面没有报错,只是渲染出了 [object Promise]

修复是显式使用 defineAsyncComponent(() => import('./Component.vue'))。这是一个很好的提醒: 静态检查能发现类型和语法问题,但无法替代“实际点开动态分支”的验证。

Vue Router 4 中 paramsquery 不能凭习惯替换

迁移时若向没有动态路径段的路由传 params,Vue Router 4 会丢弃它并给出警告;而把本应填入必填 动态段的值改放到 query,又会直接抛出 Missing required param,使跳转失败。

最终采用的规则不是“push 用 query、resolve 用 params”,而是只看目标路由的 path

  • 没有声明动态段的值,一律用 query
  • 必填动态段必须用 params
  • 可选动态段两者都可用,但要保持同一路由的读取约定一致。

这条规则后来也指导了可分享 Campaign 链接和内部跳转的收敛,避免了“页面能打开但详情参数丢失”的 静默回归。

这里还有一个值得记录的过程教训:最初的错误启发式是“内部 push() 用 query,构造可分享链接的 resolve() 用 params”。它听起来合理,却忽略了路由定义本身;一次批量替换后,普通列表点击也因 缺少必填 id 而被阻断。修复后,这条规则被单独写入项目记忆。对于 AI 参与的大范围迁移,记录 “曾经为什么改错”与记录最终写法同样重要。

Element Plus 的日期问题会静默污染业务值

Element UI 的日期格式使用 yyyydd 等小写 token;Element Plus 基于 dayjs,正确写法是 YYYYDD。错误 token 不只让输入框显示 Su/02/yyyy 这类乱码,打开面板后会发现实际选中日期 已经错位。

另一个更隐蔽的问题是 default-time="['00:00:00', '23:59:59']"。在 Element Plus 中,裸时间 字符串会被解析为 Invalid Date;当日期范围控件没有绑定值时,内部日期计算继而变成 NaN,日历 面板整体失效。解决方式是使用完整且合法的日期时间字符串,例如 '2000-01-01 00:00:00'。这类差异无法靠 API 名称替换发现,必须在“编辑已有数据”和“新建空数据” 两种场景中分别打开控件核验。

全局性能补丁会破坏组件交互

Campaign 表单中的 vue-multiselect 出现了鼠标点选即关闭、值无法回填的问题,程序化 element.click() 却正常。根因不是组件库或弹窗焦点,而是旧项目引入的 default-passive-events:它把 mousedown 也强制设为 passive,导致组件内部用于保持焦点的 preventDefault() 失效。

修复不是给每个下拉框补事件,而是替换全局补丁,只将 scrollwheeltouch* 等真正可能阻塞 滚动的事件设为 passive,保留鼠标按键事件的默认行为。迁移过程中遇到“许多地方同时出问题”时, 先检查全局拦截器、样式覆盖和事件补丁,往往比逐页修补更有效。

自动化回归不等于验收:角色和空状态必须进入清单

迁移中的 Playwright 冒烟用例覆盖了登录、Dashboard 和核心列表,但一次后续排查发现,它只使用 管理员账号。管理员的权限路由恰好绕过了非管理员才会经过的 Campaign 详情重定向,因此路由参数的 问题没有在 E2E 中出现,而是在手工以普通角色进入详情时才暴露。

这不是“自动化测试没价值”,而是覆盖模型不完整。对于权限驱动的管理后台,回归清单至少还应包含:

  • 一种非管理员角色的登录、菜单生成和深链接访问;
  • 有数据与无数据两种表单状态,尤其是日期、默认值和清空操作;
  • 列表替换、分页或媒体类型切换后的滚动位置与筛选条件;
  • 弹窗、Popover、上传和动态组件等只在后续交互出现的分支;
  • 浏览器控制台中的 Vue、路由和组件库警告。

手工 QA 的价值不是重复点一遍自动化脚本,而是专门覆盖权限、真实数据形态和视觉交互这些很难在 一次通用冒烟中穷尽的变量。每个发现都应回流为新的自动化用例、手工检查项,或至少是一条有边界的 迁移规则。

构建产物:不能只看是否超过预算

最终移除 @vue/compat 后,生产包体为 17566.2 kB(未压缩)和 8569.6 kB(gzip),相对基线增长 14.5% 和 15.8%,超过了最初设定的 ±10% 目标。

分析结果表明,这不是一次偶然的打包回归:本次刻意保持 Element Plus 全量引入以控制迁移范围,而 VXE Table 4 的运行时也更重。若要把体积拉回预算,需要单独规划 Element Plus 按需引入和相关验证, 而不是在迁移收尾时混入一次性能优化。把“功能迁移正确”与“下一阶段的优化机会”分开记录,能让交付 结论保持诚实,也不会掩盖后续工作。

这次迁移留下的做法

  1. 先定义不变项:本次不改业务逻辑、不换状态管理、不换构建器,避免范围膨胀。
  2. 让兼容层只做临时诊断工具,并把移除它作为验收条件。
  3. 将可机械化的修改和需要人工判断的修改分开管理,拒绝“一把梭”的全局替换。
  4. 用登录、列表、表单、详情和弹窗建立真实冒烟基线;构建通过不是功能通过。
  5. 每一类迁移差异都记录成“现象、根因、修复规则、回归场景”,让后续维护不必重新踩坑。

Vue 2 到 Vue 3 的迁移不是为了把所有组件写成 <script setup>。对一个正在运行的业务系统来说, 更有价值的结果是:应用在原生 Vue 3 运行时上稳定交付,关键交互得到验证,且每一条兼容性债务都有 明确的边界和去向。

评论