Skip to content

07|组件 v-model ​

第二章:组件模型与通信纵览 / 07。组件 v-model 没有创造第二份共享状态,它只是把一个输入 prop 和一个更新事件写成同一条约定。

单模型的最小可运行例子 ​

vue
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>

<template>
  <label>
    姓名
    <input v-model="model" />
  </label>
</template>
vue
<script setup lang="ts">
import { ref } from 'vue'
import NameField from './NameField.vue'

const name = ref('Ada')
</script>

<template>
  <NameField v-model="name" />
  <p>父级值:{{ name }}</p>
</template>

在子组件中,model 是一个可读写 ref;给它赋值会发出更新事件。父级仍然持有真正的 name 状态。

把语法糖完全展开 ​

父级:

vue
<NameField
  :model-value="name"
  @update:model-value="name = $event"
/>

子级的等价核心:

vue
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>

<template>
  <input
    :value="props.modelValue"
    @input="emit(
      'update:modelValue',
      ($event.target as HTMLInputElement).value
    )"
  />
</template>

数据过程是“父级值下传 → 用户输入 → 子级发 update:modelValue → 父级赋值 → 新值再次下传”。这是一圈明确协议,不是子组件直接改父级 ref。

多个模型要用名字表达角色 ​

vue
<script setup lang="ts">
const firstName = defineModel<string>('firstName', { required: true })
const lastName = defineModel<string>('lastName', { required: true })
</script>

<template>
  <input v-model="firstName" aria-label="名" />
  <input v-model="lastName" aria-label="姓" />
</template>

父级使用:

vue
<NameEditor
  v-model:first-name="firstName"
  v-model:last-name="lastName"
/>

它们分别展开为 firstName / update:firstName 与 lastName / update:lastName。不要为了省一个对象 prop 就拆出十几个模型;只有调用方确实需要独立控制的值才应成为命名模型。

自定义修饰符在组件边界解释 ​

vue
<script setup lang="ts">
const [title, modifiers] = defineModel<string, 'normalize'>({
  required: true,
  set(value) {
    if (!modifiers.normalize) return value
    const trimmed = value.trim()
    return trimmed
      ? trimmed[0].toUpperCase() + trimmed.slice(1)
      : ''
  }
})
</script>

<template>
  <input v-model="title" />
</template>

父级写 <TitleField v-model.normalize="title" /> 时,子组件在发出更新前执行 set。修饰符是公开协议,应写明转换时机;不要把网络请求或不可逆副作用藏进转换器。

失败路径:子级默认值与父级未定义 ​

ts
const model = defineModel<string>({ default: '匿名' })

若父级绑定的 ref 初始是 undefined,子级会先显示“匿名”,父级却仍是 undefined,直到子级发出更新才同步。这种初始失配很难排查。受控输入优先使用 required: true,或由父级明确初始化;默认值只在“父级可以不控制”时使用。

另一个常见失败是再给 model 加 watcher,把每次变化原样写回 model。这会制造无意义回写甚至循环。派生显示用 computed,格式转换用 defineModel 的 get/set,保存动作仍由显式事件或父级处理。

练习:双字段姓名编辑器 ​

实现 FullNameEditor.vue:

  • 两个必需命名模型:firstName: string 与 lastName: string。
  • lastName 支持自定义 .normalize 修饰符:去掉首尾空格,并把非空结果的首字母转成大写。
  • 父级初始值为 Ada 与 lovelace,使用 v-model:first-name 和 v-model:last-name.normalize。
  • 编辑后父级实时显示完整姓名;清空姓氏时结果必须是空字符串,不能抛错。
  • 不在子级另存一份长期草稿,不使用 watcher 回写。

参考答案:查看第 2 章第 07 题参考答案。

通关自检:你能把任意组件 v-model 展开成精确的 prop、事件和父级赋值,并说明状态最终由谁拥有。

用心记录每一步成长