Skip to content

D07 · 单向数据流 vs v-model ​

对应主课: L06 表单 + v-model、L14 组件通信 最后核对: 2026-09-23


1. 单向数据流 ​

Vue 的 Props 遵循单向数据流:父 → 子。子组件的顶层 prop 绑定只读;对象 prop 的嵌套字段不会因此自动深度只读,但子组件通常应发事件让父组件决定更新。单向数据流

以下是子组件 script setup 中的反例与替代写法;实际项目只保留 emit 路径:

typescript
// ❌ 子组件直接修改 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 与事件,两者不能完全当成同一段展开代码:

vue
<!-- v-model 写法 -->
<CustomInput v-model="message" />

<!-- 等价展开 -->
<CustomInput :modelValue="message" @update:modelValue="message = $event" />

子组件实现 ​

vue
<!-- 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 简写 ​

vue
<!-- 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 事件:

vue
<!-- 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>
vue
<!-- 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。表单修饰符

vue
<!-- 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 转换首个字符,其余字符保持原样:

vue
<!-- 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>
vue
<!-- 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 事件。

需要由父组件接收新对象时,显式替换模型值:

vue
<!-- 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>
vue
<!-- 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。下面是两种组件接口的调用示意:

vue
<!-- 组件提供完整的选中集合更新 -->
<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 对象的内部属性