维护之后迁移:一次只改变能够解释的行为
到这里,应当已经能在 Vue 2 中增加一个字段、修复组件事件、结束资源、维护 Vuex action 和 Router 守卫。迁移从这些已经可解释的行为出发。若源程序本来就在重复发送事件、丢失请求或泄漏监听器,先修复并保存原版本结果;否则新旧输出不同,很难判断是修复成功还是迁移破坏。
先分开版本变化和代码组织变化
Vue 2 Options → Vue 3 Options,主要控制运行时与相关 API 的变化;Vue 3 Options → Vue 3 Composition,主要控制逻辑组织方式。Vue 2.7 提供中间练习空间,但不是必须经过的迁移终点。可以根据组件库、浏览器和构建器依赖选择路线,不能仅凭“已经写 setup”判定迁移完成。
下面保持 Options 写法,分别在两个运行时观察同一个计数器。这样资源清理失败可以归因到生命周期名称和入口变化,而不需要同时猜测重写 composable 的问题。
在旧环境锁住事件与资源合同
保存 migration-old.html 到本章 Vue 2 实验目录。点击 +1 两次应显示 2;销毁实例后再派发相同事件,日志中的旧实例 count 仍是 2。
<!doctype html>
<meta charset="utf-8">
<button id="add">+1</button><button id="remove">销毁</button>
<div id="app"></div>
<script src="./node_modules/vue/dist/vue.js"></script>
<script>
const options = {
data() { return { count: 0 } },
methods: { increment() { this.count++ } },
mounted() { window.addEventListener('book-add', this.increment) },
beforeDestroy() { window.removeEventListener('book-add', this.increment) },
template: '<p id="value">{{ count }}</p>'
}
const vm = new Vue(options).$mount('#app')
document.querySelector('#add').onclick = () => {
window.dispatchEvent(new Event('book-add'))
console.log(vm.count)
}
document.querySelector('#remove').onclick = () => {
vm.$destroy()
vm.$el.remove()
}
</script>$destroy 结束实例机制,但不等同于移除根 DOM;实验显式调用 vm.$el.remove(),让两边的“销毁后看不到组件”一致。普通 Vue 管理的条件子组件,由父渲染过程负责移除 DOM,不能因此反推出手动销毁根实例也自动移除页面。
迁移到 Vue 3,仍使用 Options
另建空目录,保存 package.json 为 {"private":true,"dependencies":{"vue":"3.5.42"}},安装后用另一个端口启动同样的本地服务器。保存以下 migration-new.html;它不读取旧目录的 Vue 包。
<!doctype html>
<meta charset="utf-8">
<button id="add">+1</button><button id="remove">销毁</button>
<div id="app"></div>
<script src="./node_modules/vue/dist/vue.global.js"></script>
<script>
const options = {
data() { return { count: 0 } },
methods: { increment() { this.count++ } },
mounted() { window.addEventListener('book-add', this.increment) },
beforeUnmount() { window.removeEventListener('book-add', this.increment) },
template: '<p id="value">{{ count }}</p>'
}
const app = Vue.createApp(options)
const vm = app.mount('#app')
document.querySelector('#add').onclick = () => {
window.dispatchEvent(new Event('book-add'))
console.log(vm.count)
}
document.querySelector('#remove').onclick = () => app.unmount()
</script>创建入口从构造实例变成创建应用,挂载由 app.mount 完成;销毁由 app.unmount 驱动。Options 的 data、computed、methods 仍然成立,但清理钩子名是 beforeUnmount。把新例子故意改回 beforeDestroy,点击销毁后再点击 +1:DOM 虽已移除,旧实例 count 仍增加,说明外部监听没有释放。这条失败变体比只看页面是否消失更能证明迁移正确。
两边挂载容器的结构也不同:Vue 2 挂载可能替换目标元素,Vue 3 保留容器并在内部渲染。不要以两个页面的整个 HTML 字符串逐字相等作为正确标准;本例真正的合同是计数、事件次数、资源结束与可见结果。
把剩余差异对应到实际业务
第三篇输入组件迁到 Vue 3 时,需要同时调整默认模型输入输出、事件声明和监听器转发:value/input 转为 modelValue/update:modelValue;$listeners 合并进 $attrs,class/style 也进入 attrs;.native 不再使用。只改一端事件名会断开模型链,完整边界继续读模板契约回访和现代组件模型。
第二篇 Vue.set、数组下标等限制在 Vue 3 Proxy 体系下变化,但新旧对象身份和解构依赖也随之变化,继续读响应式比较。本章 Router 3 的 next 和 rejected Promise 契约,迁移后要连同调用者改成 Router 4 规则,见路由迁移。Vuex 可以先保持业务命令边界,再处理支持 Vue 3 的状态库版本;换成 Pinia 与升级 Vue 是两个可独立验证的决定,见Vuex 回访。
完成运行时迁移后,才按一项能力提取 composable,保留已经确定的输入、返回值和资源时机。比如把本例的监听器抽成 useCounterEvents;不必为了“现代化”把一个清楚的 Options 组件拆成许多单行函数。逻辑组织迁移给出完整的网络状态例子。
兼容构建是过渡环境,不是已经消除差异
@vue/compat 是提供部分 Vue 2 兼容行为的 Vue 3 构建,可以报告或按组件切换相关行为。接入时需要匹配的 Vue 3 运行时、兼容包、编译器和构建配置;运行警告来自业务或第三方依赖,要追到具体使用点。依赖 Vue 2 私有 VNode 字段、特定组件库内部行为的项目,不能仅靠 alias 保证兼容。
合理的过程是:先保存原版本可运行基线;选一个边界明确的组件;在隔离迁移环境接入配套编译器与兼容能力;逐项修复实际警告和行为差异;用相同输入验证;最后切换标准 Vue 3 构建。消除警告不等于证明正确,尤其是 CSS、资源路径、历史刷新和第三方组件要有实际观察。本章的双环境实验使用原生 Vue 2/Vue 3,没有把未运行的兼容构建接入流程冒充成验证结果。
练习:同样看不见,资源结果却不同
分别运行两个页面,执行 +1、+1、销毁、+1,记录 DOM 是否存在与保留实例 count。正确两边都是 1、2、销毁后保持 2;新页把 beforeUnmount 改为 beforeDestroy 的失败变体最终为 3。
再把步长设为 data 中 step: 2,increment 使用 this.step。两边执行同样输入应保持 2、4、销毁后4。这个变化检验方法仍读取当前实例,而不是把值冻结在迁移时生成的闭包中。给出实际修改与观察,说明本实验验证了哪些合同、没有验证哪些第三方组件。