Skip to content

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 + inputmodelValue + update:modelValue,或 defineModel()父子两端必须一起改,事件次数也要重测
:open.sync + update:openv-model:open + update:open事件名保留,父模板入口改变
@click.native 监听组件根 DOM声明组件事件,或让未声明监听器透传到单根 DOM多根、inheritAttrs: false 与重发 click 会改变落点和次数
$attrs 与 $listeners 分开监听器并入 $attrs,已声明 emits 会被排除转发策略和 emits 声明共同决定监听器去哪
filters 模板管道普通函数或 computedVue 3 模板表达式不再解析 filter 语法

规则可以再压缩成一句:Vue 3 要求组件更明确地声明“值怎么回写、事件由谁发出、未声明属性最终落到哪”。

Vue 2 夹具:把旧合同跑出来 ​

vue
<!-- 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>
vue
<!-- 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 夹具:显式重建同一合同 ​

vue
<!-- 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>
vue
<!-- 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 子组件根节点本来就是按钮,同时写了:

vue
<button @click="$emit('click', $event)">确定</button>

父级再写 @click="handleClick"。如果子组件没有在 defineEmits 中声明 click,监听器可能既作为未声明 attrs 落到根按钮,又收到手工 $emit('click'),一次点击触发两次。修复有两条,只选一条:

  1. 这个 click 真是组件业务事件:声明 click,继续显式 emit。
  2. 组件只是原样转发根按钮:删掉手工 emit,让原生监听器自然落到根节点。

这也是为什么“删掉 .native”不是完整迁移步骤;还必须审查 emits 和根节点。

迁移构建只是过桥,不是终点 ​

@vue/compat 可以按兼容标志暴露旧能力并发出警告,适合依赖复杂、必须分批迁移的应用。它不能证明第三方库不依赖内部 API,也不能替你检查每个组件的事件次数。使用时应给每批设置退出条件:相关警告归零、行为夹具通过、然后关闭对应兼容项;所有批次结束后再移除迁移构建。

练习:迁移金额编辑器而不改变六条行为 ​

从 Vue 2 夹具开始,独立写出 Vue 3.5 的 PriceEditor.vue 和 App.vue。输入合同固定为 amount: string、open: boolean、currency?: string;格式化不得修改 amount,abc 必须显示 —。

按以下顺序验收:

  1. 初始 12.50 显示人民币金额;输入 20 时父级 amount 只更新一次。
  2. 输入 abc 时父级保留草稿,展示值变为 —。
  3. 点击展开按钮,open 只翻转一次;重复点击可恢复。
  4. 聚焦 input,focus 计数只加一;点击区域,activate 计数只加一。
  5. 暂时把 activate 改名为 click 且遗漏 emits 声明,复现或解释双触发风险,再恢复显式合同。

失败解释必须落到责任:旧 value/input 不成对会让 model 单向失联;保留 .sync 会让 Vue 3 模板合同无效;遗漏 attrs 转发会让 focus 监听器落不到 input;机械重发 click 且不声明 emits 会造成重复。

参考答案:查看第一章练习答案中的第 13 题。

通关自检:你能用同一组输入在两个夹具中跑出相同结果,并解释每个旧语法背后的新责任,而不是只展示一张替换清单。

用心记录每一步成长