13|Vue 2/3 模板契约
本篇比较版本行为。若还不熟悉 Vue 2 的 value/input、插槽、$attrs 与 $listeners,先运行Vue 2 组件契约中的完整组件,再判断下面哪些差异会影响现有接口。
Vue 学习笔记总览 / 第一章概览 / 13。目标:把迁移从“替换旧语法”变成“证明公开行为没有漂移”。
本页使用两个独立夹具:vue2-fixture/ 运行 Vue 2.7 + Vue CLI 5,vue3-fixture/ 运行 Vue 3.5.x + Vite + TypeScript。不要让两个项目共用 vue、模板编译器或锁文件;双版本验证的价值就在于让每段模板由它真正所属的编译器执行。
先写行为合同,再看语法差异
我们要迁移一个金额编辑器。无论版本如何,调用方都应该观察到同一组业务行为:
| 输入或动作 | 可观察结果 | 关键边界 |
|---|---|---|
初始金额 '12.50' | 显示 ¥12.50 | 格式化只负责展示,不改模型 |
输入 '20' | 父级金额更新一次,显示 ¥20.00 | 子组件不直接改 prop |
输入 'abc' | 父级保留草稿,展示 — | 非法草稿不伪装成 0 |
| 点击“展开/收起说明” | 父级 open 更新一次 | Vue 2 的 .sync 要迁移为命名 model |
| 聚焦真实输入框 | 父级 focus 计数加一 | listener 必须落到 input,而非包装层 |
| 点击组件区域 | activate 计数加一 | 不依赖已移除的 .native |
这张表是迁移完成条件。下面每一处替换,都要能解释它守住了表中的哪一行。
五组差异背后的责任变化
| Vue 2 合同 | Vue 3 合同 | 不能只做字符串替换的原因 |
|---|---|---|
组件 v-model = value + input | modelValue + update:modelValue,或 defineModel() | 父子两端必须一起改,事件次数也要重测 |
:open.sync + update:open | v-model:open + update:open | 事件名保留,父模板入口改变 |
@click.native 监听组件根 DOM | 声明组件事件,或让未声明监听器透传到单根 DOM | 多根、inheritAttrs: false 与重发 click 会改变落点和次数 |
$attrs 与 $listeners 分开 | 监听器并入 $attrs,已声明 emits 会被排除 | 转发策略和 emits 声明共同决定监听器去哪 |
filters 模板管道 | 普通函数或 computed | Vue 3 模板表达式不再解析 filter 语法 |
规则可以再压缩成一句:Vue 3 要求组件更明确地声明“值怎么回写、事件由谁发出、未声明属性最终落到哪”。
Vue 2 夹具:把旧合同跑出来
<!-- vue2-fixture/src/components/LegacyPriceEditor.vue -->
<script>
function formatMoney(value, currency) {
const amount = Number(value)
if (!value.trim() || !Number.isFinite(amount)) return '—'
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency
}).format(amount)
}
export default {
name: 'LegacyPriceEditor',
inheritAttrs: false,
props: {
value: {
type: String,
required: true
},
open: {
type: Boolean,
default: false
},
currency: {
type: String,
default: 'CNY'
}
},
filters: {
money: formatMoney
},
computed: {
inputListeners() {
return Object.assign({}, this.$listeners, {
input: (event) => this.$emit('input', event.target.value)
})
}
},
methods: {
toggleDetails() {
this.$emit('update:open', !this.open)
}
}
}
</script>
<template>
<section class="price-editor">
<label>
金额
<input
:value="value"
v-bind="$attrs"
v-on="inputListeners"
>
</label>
<p>展示值:{{ value | money(currency) }}</p>
<button type="button" @click="toggleDetails">
{{ open ? '收起说明' : '展开说明' }}
</button>
<p v-if="open">金额以字符串草稿保存,提交边界再校验。</p>
</section>
</template><!-- vue2-fixture/src/App.vue -->
<script>
import LegacyPriceEditor from './components/LegacyPriceEditor.vue'
export default {
name: 'App',
components: { LegacyPriceEditor },
data() {
return {
amount: '12.50',
open: false,
focusCount: 0,
activateCount: 0
}
}
}
</script>
<template>
<main>
<LegacyPriceEditor
v-model="amount"
:open.sync="open"
inputmode="decimal"
@focus="focusCount += 1"
@click.native="activateCount += 1"
/>
<output>
amount={{ amount }};focus={{ focusCount }};activate={{ activateCount }}
</output>
</main>
</template>这里的 @focus 是组件监听器,被 $listeners 转发给真实 input;@click.native 则直接监听组件根 DOM。二者虽然都写在父模板上,责任完全不同。
Vue 3.5 夹具:显式重建同一合同
<!-- vue3-fixture/src/components/PriceEditor.vue -->
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
currency?: string
}>(), {
currency: 'CNY'
})
const amount = defineModel<string>({ required: true })
const open = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{
activate: [event: MouseEvent]
}>()
function formatMoney(value: string, currency: string) {
const parsed = Number(value)
if (!value.trim() || !Number.isFinite(parsed)) return '—'
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency
}).format(parsed)
}
</script>
<template>
<section class="price-editor" @click="emit('activate', $event)">
<label>
金额
<input
v-model="amount"
inputmode="decimal"
v-bind="$attrs"
>
</label>
<p>展示值:{{ formatMoney(amount, props.currency) }}</p>
<button type="button" @click="open = !open">
{{ open ? '收起说明' : '展开说明' }}
</button>
<p v-if="open">金额以字符串草稿保存,提交边界再校验。</p>
</section>
</template><!-- vue3-fixture/src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import PriceEditor from './components/PriceEditor.vue'
const amount = ref('12.50')
const open = ref(false)
const focusCount = ref(0)
const activateCount = ref(0)
</script>
<template>
<main>
<PriceEditor
v-model="amount"
v-model:open="open"
@focus="focusCount += 1"
@activate="activateCount += 1"
/>
<output>
amount={{ amount }};focus={{ focusCount }};activate={{ activateCount }}
</output>
</main>
</template>Vue 3 版本不再使用 filter:formatMoney() 是普通纯函数。默认 model 由 defineModel<string>() 声明,命名 open model 替代 .sync。组件显式发送 activate,因此父级不用猜根元素,也不会把业务事件和 DOM click 混成一个名字。
@focus 没有列入 defineEmits,所以它留在 $attrs,再由 v-bind="$attrs" 落到真实 input。若将 focus 声明成组件事件,它会从 attrs 中移除,此时组件必须自己监听原生 focus 并 emit('focus'),否则父级计数不会变化。
最容易漏掉的“双触发”失败
假设 Vue 3 子组件根节点本来就是按钮,同时写了:
<button @click="$emit('click', $event)">确定</button>父级再写 @click="handleClick"。如果子组件没有在 defineEmits 中声明 click,监听器可能既作为未声明 attrs 落到根按钮,又收到手工 $emit('click'),一次点击触发两次。修复有两条,只选一条:
- 这个 click 真是组件业务事件:声明
click,继续显式 emit。 - 组件只是原样转发根按钮:删掉手工 emit,让原生监听器自然落到根节点。
这也是为什么“删掉 .native”不是完整迁移步骤;还必须审查 emits 和根节点。
迁移构建只是过桥,不是终点
@vue/compat 可以按兼容标志暴露旧能力并发出警告,适合依赖复杂、必须分批迁移的应用。它不能证明第三方库不依赖内部 API,也不能替你检查每个组件的事件次数。使用时应给每批设置退出条件:相关警告归零、行为夹具通过、然后关闭对应兼容项;所有批次结束后再移除迁移构建。
练习:迁移金额编辑器而不改变六条行为
从 Vue 2 夹具开始,独立写出 Vue 3.5 的 PriceEditor.vue 和 App.vue。输入合同固定为 amount: string、open: boolean、currency?: string;格式化不得修改 amount,abc 必须显示 —。
按以下顺序验收:
- 初始
12.50显示人民币金额;输入20时父级 amount 只更新一次。 - 输入
abc时父级保留草稿,展示值变为—。 - 点击展开按钮,open 只翻转一次;重复点击可恢复。
- 聚焦 input,focus 计数只加一;点击区域,activate 计数只加一。
- 暂时把
activate改名为click且遗漏 emits 声明,复现或解释双触发风险,再恢复显式合同。
失败解释必须落到责任:旧 value/input 不成对会让 model 单向失联;保留 .sync 会让 Vue 3 模板合同无效;遗漏 attrs 转发会让 focus 监听器落不到 input;机械重发 click 且不声明 emits 会造成重复。
参考答案:查看第一章练习答案中的第 13 题。
通关自检:你能用同一组输入在两个夹具中跑出相同结果,并解释每个旧语法背后的新责任,而不是只展示一张替换清单。