D07 · 单向数据流 vs v-model
对应主课: L06 表单 + v-model、L14 组件通信 最后核对: 2026-09-23
1. 单向数据流
Vue 的 Props 遵循单向数据流:父 → 子。子组件的顶层 prop 绑定只读;对象 prop 的嵌套字段不会因此自动深度只读,但子组件通常应发事件让父组件决定更新。单向数据流
以下是子组件 script setup 中的反例与替代写法;实际项目只保留 emit 路径:
// ❌ 子组件直接修改 props
const props = defineProps<{ count: number }>()
props.count++ // TypeScript 报只读错误;开发运行时也会警告,顶层赋值不会更新父状态
// ✅ 通知父组件修改
const emit = defineEmits<{ 'update:count': [value: number] }>()
emit('update:count', props.count + 1)2. v-model 的本质
组件上的 v-model 约定一个 prop 和对应的 update 事件;父级通常把事件值写回状态。原生 input/select/textarea 则由编译器和运行时根据控件类型处理 value/checked 与事件,两者不能完全当成同一段展开代码:
<!-- v-model 写法 -->
<CustomInput v-model="message" />
<!-- 等价展开 -->
<CustomInput :modelValue="message" @update:modelValue="message = $event" />子组件实现
<!-- CustomInput.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>Vue 3.4+ defineModel 简写
<!-- CustomInput.vue — Vue 3.4+ -->
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>
<template>
<input v-model="model" />
</template>defineModel 是编译宏,生成相应 prop/update 事件声明,并通过返回的 model ref 协调读写。这里声明 required,父组件需要提供模型值;如果子组件设置 default 而父绑定仍是 undefined,双方初始值可能不同。父级没有提供绑定时的本地模型行为,也不能概括成“总是同步父状态”。组件 v-model
3. 多个 v-model
分别保存为两个组件;父组件拥有 user,子组件只发送对应字段的 update 事件:
<!-- UserForm.vue -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName', { required: true })
const lastName = defineModel<string>('lastName', { required: true })
const email = defineModel<string>('email', { required: true })
</script>
<template>
<input v-model="firstName" aria-label="名">
<input v-model="lastName" aria-label="姓">
<input v-model="email" type="email" aria-label="邮箱">
</template><!-- UserFormDemo.vue -->
<script setup lang="ts">
import { reactive } from 'vue'
import UserForm from './UserForm.vue'
const user = reactive({ firstName: '小', lastName: '林', email: 'lin@example.com' })
</script>
<template>
<UserForm v-model:first-name="user.firstName" v-model:last-name="user.lastName" v-model:email="user.email" />
<p>{{ user.firstName }} / {{ user.lastName }} / {{ user.email }}</p>
</template>4. v-model 修饰符
原生文本输入的 .trim 去除输入值两端空白;.lazy 使用 change 时机同步;.number 尝试数字转换,无法解析时保留原字符串,清空输入也会得到空字符串,不能保证模型永远是 number。表单修饰符
<!-- NativeModifiers.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('')
const age = ref<number | string>('')
const text = ref('')
</script>
<template>
<input v-model.trim="name" aria-label="姓名">
<input v-model.number="age" aria-label="年龄">
<input v-model.lazy="text" aria-label="延后同步文本">
<p>姓名:{{ name }} / 年龄:{{ age }}({{ typeof age }})/ 文本:{{ text }}</p>
</template>组件自定义修饰符由组件实现。下面的 capitalize 转换首个字符,其余字符保持原样:
<!-- CapitalInput.vue -->
<script setup lang="ts">
const [model, modifiers] = defineModel<string, 'capitalize'>({
required: true,
set(value): string {
return modifiers.capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value
},
})
</script>
<template><input v-model="model" aria-label="首字母转换输入"></template><!-- CapitalDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import CapitalInput from './CapitalInput.vue'
const text = ref('')
</script>
<template>
<CapitalInput v-model.capitalize="text" />
<p>{{ text }}</p>
</template>set 在之后的模型赋值时读取 modifiers,届时解构已完成;闭包保存该绑定。宏还会提供对应修饰符 prop,但不会自动替你实现任意自定义转换。
5. 何时用 v-model vs props+emit
| 场景 | 推荐 |
|---|---|
| 表单输入组件 | ✅ v-model |
| 开关/选择器 | ✅ v-model |
| 弹窗 visible | ✅ v-model:visible |
| 表达“保存/删除”操作的按钮事件 | emit 语义明确的事件,如 save/delete |
| 编辑一整个对象 | 可以用 v-model 接收/发送新对象,也可以用业务事件表达提交 |
| 只读数据展示 | 只需 props |
选择看组件是否公开了“当前值 + 请求更新这个值”的接口,而不是看它长得像不像 input。单纯的父 → 子展示用 props,业务动作使用有含义的事件。
6. 常见反模式
反模式 1:绕过事件直接改共享对象
子组件的 props.user.name = 'New' 可能直接改掉父组件持有的同一个对象,通常不会触发顶层只读 prop 的警告。defineModel 返回对象时也一样:model.value.name = 'New' 没有经过 model.value 的 setter,不会自动产生整对象 update 事件。
需要由父组件接收新对象时,显式替换模型值:
<!-- UserEditor.vue -->
<script setup lang="ts">
interface User { name: string; age: number }
const user = defineModel<User>('user', { required: true })
function updateName(event: Event) {
user.value = { ...user.value, name: (event.target as HTMLInputElement).value }
}
</script>
<template><input :value="user.name" @input="updateName" aria-label="用户名称"></template><!-- UserEditorDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import UserEditor from './UserEditor.vue'
const user = ref({ name: 'Vue', age: 3 })
</script>
<template>
<UserEditor v-model:user="user" />
<p>{{ user.name }} / {{ user.age }}</p>
</template>这样父级接到 update:user 后决定替换对象。若产品要求“编辑草稿,点击保存才提交”,则应显式维护草稿并发 save 事件,而不是每个输入事件都提交。
反模式 2:用事件名称掩盖状态含义
受控的多选列表可以合理地提供 v-model:selected-ids,它编辑的是选中 id 集合;不能仅因它叫 UserList 就禁止 v-model。下面是两种组件接口的调用示意:
<!-- 组件提供完整的选中集合更新 -->
<UserList :users="users" v-model:selected-ids="selectedIds" />
<!-- 组件只报告一次选择操作,由父组件决定业务动作 -->
<UserList :users="users" :selected="selectedIds" @select="handleSelect" />两者表达的事件载荷与责任不同,应该根据组件契约选择,不能直接互换而不修改子组件。
7. v-model 的演进
8. 总结
- 单向数据流是 Vue 的核心原则,v-model 不违反它
- 组件 v-model 连接 prop 与 update 事件;原生控件按类型处理属性与事件
- Vue 3.4 的
defineModel让双向绑定的实现更简洁 - 受控选择、开关、可见性与对象编辑都可提供模型接口,业务动作另用语义事件
- 避免直接修改 props 对象的内部属性