07|组件 v-model
第二章:组件模型与通信纵览 / 07。组件
v-model没有创造第二份共享状态,它只是把一个输入 prop 和一个更新事件写成同一条约定。
单模型的最小可运行例子
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>
<template>
<label>
姓名
<input v-model="model" />
</label>
</template><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 状态。
把语法糖完全展开
父级:
<NameField
:model-value="name"
@update:model-value="name = $event"
/>子级的等价核心:
<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。
多个模型要用名字表达角色
<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>父级使用:
<NameEditor
v-model:first-name="firstName"
v-model:last-name="lastName"
/>它们分别展开为 firstName / update:firstName 与 lastName / update:lastName。不要为了省一个对象 prop 就拆出十几个模型;只有调用方确实需要独立控制的值才应成为命名模型。
自定义修饰符在组件边界解释
<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。修饰符是公开协议,应写明转换时机;不要把网络请求或不可逆副作用藏进转换器。
失败路径:子级默认值与父级未定义
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、事件和父级赋值,并说明状态最终由谁拥有。