Vue 组件v-model

v-model 如何实现父子组件数据同步?

一、先给结论

组件上的 v-model 不是子组件直接修改父组件状态,而是“prop + update 事件”的语法糖:

<Child v-model="value" />

<!-- Vue 3 中近似展开为 -->
<Child
  :modelValue="value"
  @update:modelValue="value = $event"
/>

完整链路是:

父组件状态 value
  ↓ props 下发 modelValue
子组件展示 modelValue
  ↓ 用户输入或子组件逻辑产生新值
子组件 emit('update:modelValue', newValue)
  ↓ 父组件监听事件并修改 value
响应式系统触发更新,再次向子组件传值

本质仍然是单向数据流:数据向下,事件向上v-model 只是把这套约定标准化并简化书写。

二、原生元素上的v-model

原生元素和组件的展开规则不同,不能简单概括为所有场景都是 value + input

<input v-model="message" />

<!-- 文本输入框可近似理解为 -->
<input
  :value="message"
  @input="message = $event.target.value"
/>
元素绑定属性监听事件值的典型形态
文本 input / textareavalueinput字符串
checkboxcheckedchange布尔值、数组或 Set
radiocheckedchange选项值
selectselectedchange选项值或数组

所以面试时应区分:

  • 原生元素:Vue 根据元素类型选择 DOM 属性和事件。
  • 组件:Vue 根据组件约定生成 prop 和自定义事件。

三、Vue 3 手动实现组件v-model

父组件

<UserInput v-model="username" />

子组件

<script setup>
const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:modelValue'])

function handleInput(event) {
  emit('update:modelValue', event.target.value)
}
</script>

<template>
  <input :value="props.modelValue" @input="handleInput" />
</template>

子组件不能直接修改 props.modelValue。它应该发出事件,让父组件修改自己的状态,再把新值传下来。

用计算属性封装读写

<script setup>
import { computed } from 'vue'

const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])

const model = computed({
  get: () => props.modelValue,
  set: value => emit('update:modelValue', value)
})
</script>

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

这里的计算属性没有创建一份无关的副本,而是把读取代理到 prop,把写入转换为 emit

四、Vue 3.4+ 的defineModel

Vue 3.4 开始,官方推荐使用 defineModel()

<!-- Child.vue -->
<script setup>
const model = defineModel({
  type: String,
  default: ''
})
</script>

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

defineModel<script setup> 的编译期宏,不是普通运行时函数。它会生成对应的 prop 和 update:modelValue 事件,概念上接近:

const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

默认值的同步陷阱

如果子组件给 defineModel 设置默认值,但父组件没有传入对应 model,子组件和父组件可能暂时拥有不同的值:

<!-- Child.vue -->
<script setup>
const model = defineModel({ default: 1 })
</script>

<!-- Parent.vue -->
<Child />

此时子组件可能是 1,父组件对应状态仍是 undefined。因此要根据 API 设计决定 model 是否必填,不能把子组件默认值误认为父子已经完成同步。

五、多个v-model

Vue 3 可以通过参数区分多个独立 model:

<UserName
  v-model:first="firstName"
  v-model:last="lastName"
/>

近似展开为:

<UserName
  :first="firstName"
  @update:first="firstName = $event"
  :last="lastName"
  @update:last="lastName = $event"
/>

命名规则是:

v-model:foo="value"
prop: foo
event: update:foo

手动声明

<script setup>
defineProps({
  first: String,
  last: String
})

const emit = defineEmits([
  'update:first',
  'update:last'
])
</script>

使用defineModel

<script setup>
const first = defineModel('first', { type: String })
const last = defineModel('last', { type: String })
</script>

<template>
  <input v-model="first" />
  <input v-model="last" />
</template>

六、Vue 2 的组件v-model

Vue 2 默认协议是:

prop: value
event: input
<Child v-model="value" />

<!-- 近似展开为 -->
<Child :value="value" @input="value = $event" />

子组件:

export default {
  props: { value: String },
  methods: {
    updateValue(value) {
      this.$emit('input', value)
    }
  }
}

model 选项

Vue 2 可以自定义默认 prop 和事件:

export default {
  model: {
    prop: 'checked',
    event: 'change'
  },
  props: { checked: Boolean },
  methods: {
    toggle(value) {
      this.$emit('change', value)
    }
  }
}

这样 <BaseCheckbox v-model="checked" /> 使用的就是 checked + change

.sync 修饰符

<Child :title.sync="title" />

<!-- 近似展开为 -->
<Child
  :title="title"
  @update:title="title = $event"
/>

.sync 是 Vue 2 针对任意 prop 的更新约定,不是完整的 v-model 替代品。Vue 3 使用 v-model:title 这类命名 model 表达相同场景。

七、v-model 修饰符

原生输入的内置修饰符

<input v-model.trim="text" />
<input v-model.number="count" />
<input v-model.lazy="text" />
  • .trim:去除输入字符串首尾空白
  • .number:尝试将输入转换为数字
  • .lazy:通常从 input 时机改为 change 时机

修饰符改变的是读取或更新逻辑,不是给数据永久增加类型。

Vue 3.4+:通过defineModel 处理自定义修饰符

<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }

    return value
  }
})
</script>

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

父组件:

<CustomInput v-model.capitalize="text" />

命名 model 的修饰符名称对应参数名:

v-model:title.capitalize
titleModifiers

手动读取修饰符

没有使用 defineModel 时,默认 model 可以声明 modelModifiers

<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: {
    default: () => ({})
  }
})

const emit = defineEmits(['update:modelValue'])

function handleInput(event) {
  let value = event.target.value

  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }

  emit('update:modelValue', value)
}
</script>

对于 v-model:title.capitalize,对应 prop 是 titleModifiers,而不是 modelModifiers

八、编译与运行时边界

1. 不是双向引用

父组件传入的是当前值,子组件拿到的是 prop。真正修改父状态的是父组件生成的事件监听器:

<Child v-model="value" />

<!-- 事件触发时相当于执行 -->
value = $event

2. 绑定目标必须可写

<Child v-model="user.name" />  <!-- 通常可以写回 -->
<Child v-model="getName()" />   <!-- ❌ 没有可写回目标 -->
<Child v-model="'fixed'" />     <!-- ❌ 常量不可赋值 -->

3. 对象 model 不是深度协议

如果 model 是对象,子组件拿到的可能是同一个对象引用。直接修改内部属性可能绕过 update 事件:

props.modelValue.name = 'new name' // 不推荐

更清晰的写法是发出新对象:

emit('update:modelValue', {
  ...props.modelValue,
  name: 'new name'
})

具体是否复制对象取决于业务数据设计,但组件应明确谁拥有状态、谁负责更新。

4. 事件和 prop 名称必须匹配

默认协议必须同时使用 modelValueupdate:modelValue;命名 model 必须同时使用 fooupdate:foo。只改一侧,绑定就不会按预期工作。

九、设计原则

适合使用 v-model

  • 输入框、选择器、日期选择器等表单控件
  • 复杂表单组件的当前值同步
  • 一个组件有明确的“当前值 / 更新值”协议
  • 一个组件有多个独立的可编辑值

不应滥用:

  • 用它承载任意业务动作
  • 子组件直接修改 prop
  • 同时维护父状态和子状态两个真实数据源
  • 用它替代语义明确的 submitconfirmclose 等业务事件

例如弹窗更适合:

<Modal :open="isOpen" @close="handleClose" />

而不是把所有打开、关闭逻辑都隐藏在一个复杂的 v-model 中。

十、核心源码模型

下面是帮助理解的简化模型,不代表 Vue 源码:

function expandComponentVModel(value) {
  return {
    modelValue: value,
    'onUpdate:modelValue': nextValue => {
      value = nextValue
    }
  }
}

组件上的 v-model 可以理解为:

模板语法
  ↓ 编译展开
prop + update 事件
子组件发出新值
父组件更新状态
响应式系统触发重新渲染

真实 Vue 还会根据原生元素类型、参数、修饰符和版本生成不同的运行时指令或 vnode props;示例只是用于理解核心协议。

十一、Vue 2 与 Vue 3 对比

对比项Vue 2Vue 3
默认 propvaluemodelValue
默认事件inputupdate:modelValue
自定义默认协议model: { prop, event }v-model:name
多值绑定多个 .sync多个命名 v-model
修饰符读取手动设计modelModifiers / nameModifiers / defineModel
推荐声明props + $emitdefineModeldefineProps + defineEmits

十二、面试官深挖

1.v-model 的本质是什么?

参考答案: 它是 prop 和更新事件的语法糖。Vue 3 默认是 modelValue + update:modelValue。父组件持有状态,子组件发事件请求更新,底层仍是数据向下、事件向上。

2. 原生元素和组件的展开规则一样吗?

参考答案: 不一样。原生元素根据类型处理 valuecheckedselected 和不同事件;组件遵循 modelValue / update:modelValue 或命名 model 协议。

3. 子组件为什么不能直接修改 model prop?

参考答案: prop 属于父组件,直接修改会破坏单向数据流。子组件应 emit 更新事件,由父组件修改状态后重新下发。

4. Vue 2 和 Vue 3 有什么区别?

参考答案: Vue 2 默认是 value + input,可用 model 自定义;Vue 3 默认是 modelValue + update:modelValue,通过参数支持多个 model,3.4+ 推荐用 defineModel

5..sync 和命名v-model 有什么关系?

参考答案: 两者都基于 prop 和 update 事件,但 .sync 是 Vue 2 针对任意 prop 的语法,Vue 3 用 v-model:title 统一表达多个双向值。

6.defineModel 是什么?

参考答案: 它是 <script setup> 编译期宏,会生成 prop 和 update: 事件,简化了手动声明,但没有改变 v-model 的通信本质。

7. 多个v-model 解决什么问题?

参考答案: 一个组件可能有多个相互独立的可编辑值。命名 model 将它们映射到不同 prop 和事件,避免把所有值塞进一个对象。

8. 修饰符如何传给组件?

参考答案: 手动实现时,默认 model 使用 modelModifiers,命名 model 使用 nameModifiers;3.4+ 可以通过 defineModel 返回的第二个值获取修饰符,并在 setter 中转换值。

9.defineModel 的默认值有什么风险?

参考答案: 父组件未传入 model 时,子组件默认值和父状态可能暂时不同步。要明确 model 是否必填,不能把子组件默认值当成父子已经同步。

10.v-model 可以绑定任意表达式吗?

参考答案: 不能。编译器最终需要生成可写回的赋值目标,变量和可写属性通常可以,函数调用、常量和只读表达式不可以。

十三、常见错误回答

  • v-model 就是双向绑定”:应补充它实际是 prop + event,仍遵循单向数据流。
  • “所有 v-model 都是 value + input”:这是 Vue 2 默认协议或文本输入的简化说法。
  • “子组件可以直接改 modelValue”:应通过 update: 事件通知父组件。
  • .sync 就等于 Vue 3 的 v-model”:思想相近,但语法和适用版本不同。
  • defineModel 会创建本地副本”:它默认封装的是 prop 读取和 update 事件,不等于无条件复制数据。

十四、可迁移的理解

核心关键词

语法糖单向数据流propupdate 事件编译展开命名 model

一句话本质

组件 v-model 是父组件状态通过 prop 向下传递、子组件通过 update: 事件向上请求更新的标准化协议。

1 分钟面试回答

Vue 组件上的 v-model 本质是 prop 和事件的语法糖。Vue 3 默认展开为 :modelValue@update:modelValue,父组件持有状态,子组件通过 emit 请求更新,仍然是数据向下、事件向上。Vue 2 默认是 value + input,Vue 3 支持多个命名 v-model,3.4+ 可以用 defineModel 简化声明。子组件不能直接修改 prop,而应该发出更新事件。

3 分钟回答应补充

原生 input、checkbox、select 的展开差异;Vue 2 的 model / .sync;Vue 3 的手动实现、defineModel 和自定义修饰符。

10 分钟回答应补充

模板编译和运行时指令、model 参数命名规则、默认值同步风险、对象引用边界、可写表达式限制,以及为什么业务动作更适合使用语义明确的事件。

参考资料