Vue 3.3 引入了一个重要的新特性 ------ defineModel(),
它让组件间的双向数据绑定变得更自然、更简洁。
但同时,也引发了很多开发者的疑问:
"
model和props到底有什么区别?""它们在底层是怎么实现的?"
本文将从编译器与运行时两个层面,深入剖析两者的差异与协作机制。
一、概念层面:Vue 中的数据传递两种模式
在 Vue 组件系统中,数据从父组件传递到子组件有两种基本方式:
| 名称 | 功能说明 |
|---|---|
props |
父组件向子组件传递只读数据(单向数据流) |
model |
父子组件间建立双向绑定(数据可从子更新到父) |
1️⃣ props:单向输入
xml
<!-- 父组件 -->
<Child :title="pageTitle" />
xml
<!-- 子组件 -->
<script setup>
const props = defineProps<{ title: string }>()
</script>
<template>
<h1>{{ props.title }}</h1>
</template>
-
父组件通过
:title="pageTitle"传值; -
子组件通过
props.title接收; -
若子组件尝试修改
props.title,Vue 会发出警告:markdown[Vue warn]: Attempting to mutate prop "title".
2️⃣ model:双向绑定
xml
<!-- 父组件 -->
<Child v-model:title="pageTitle" />
xml
<!-- 子组件 -->
<script setup>
const title = defineModel<string>('title')
</script>
<template>
<input v-model="title" />
</template>
- 父组件使用
v-model:title建立双向绑定; - 子组件的
title是一个可读写的 ref; - 子修改后会自动触发
emit('update:title'),同步父组件数据。
二、原理层面:编译器的行为差异
Vue 的 <script setup> 编译器在解析这两个宏函数时,生成的底层代码完全不同。
1️⃣ defineProps() 的编译展开
c
const props = defineProps<{ title: string }>()
➡️ 编译结果:
javascript
export default {
props: { title: String },
setup(__props) {
const props = __props
return { props }
}
}
defineProps()仅生成props声明。子组件只能读取,无法直接修改。
2️⃣ defineModel() 的编译展开
ini
const count = defineModel<number>('count')
➡️ 编译结果:
javascript
export default {
props: { count: Number },
emits: ['update:count'],
setup(__props, { emit }) {
const count = computed({
get: () => __props.count,
set: v => emit('update:count', v)
})
return { count }
}
}
defineModel()在编译阶段自动生成:
props定义;- 对应的
emits;- 一个可双向同步的
computed ref。
这就是它实现双向数据流的核心机制。
三、对比层面:结构化分析
| 特性 | defineProps |
defineModel |
|---|---|---|
| 作用方向 | 单向:父 → 子 | 双向:父 ⇄ 子 |
| 底层生成 | props | props + emits + computed |
| 子组件修改 | ❌ 不允许 | ✅ 可直接修改 |
| 触发事件 | 无 | 自动 emit('update:xxx') |
| 绑定语法 | :prop="value" |
v-model[:name]="value" |
| 典型场景 | 配置性参数、静态内容 | 状态同步、表单控件 |
| TypeScript 支持 | 可定义接口 | 自动类型推导 |
| 默认值定义 | { default: ... } |
{ default: ... } |
| 可组合性 | 只读引用 | 可与 watch/computed 配合双向操作 |
四、实践层面:父子组件双向同步案例
✅ 子组件:ChildCounter.vue
xml
<script setup lang="ts">
const count = defineModel<number>('count')
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">
Child Count: {{ count }}
</button>
</template>
✅ 父组件:Parent.vue
xml
<script setup lang="ts">
import ChildCounter from './ChildCounter.vue'
import { ref } from 'vue'
const parentCount = ref(0)
</script>
<template>
<div>
<ChildCounter v-model:count="parentCount" />
<p>Parent Count: {{ parentCount }}</p>
</div>
</template>
🔄 运行效果说明:
| 操作 | 子组件行为 | 父组件行为 |
|---|---|---|
| 点击按钮 | count.value++ |
自动触发 emit('update:count') |
父组件修改 parentCount |
子组件 count 自动更新 |
✅ 数据同步 |
双向流通的闭环:
objectivecParent state ⇄ props.count ⇄ computed setter
五、拓展层面:defineModel() 的强大特性
1️⃣ 支持多 model
ini
const title = defineModel<string>('title')
const checked = defineModel<boolean>('checked')
父组件:
ini
<Child v-model:title="text" v-model:checked="flag" />
编译器自动生成:
vbnet
props: { title: String, checked: Boolean },
emits: ['update:title', 'update:checked']
2️⃣ 默认值与类型推导
arduino
const model = defineModel<number>({ default: 10 })
- 自动给
props添加default; - TypeScript 类型自动继承;
- 父组件不传值时,
model.value默认是10。
3️⃣ 只读与必传模式
c
const readonlyModel = defineModel<string>({ required: true })
编译后生成:
yaml
props: { modelValue: { type: String, required: true } }
六、潜在问题与注意事项
| 问题场景 | 描述 | 解决方案 |
|---|---|---|
同名 props 与 model |
冲突(编译报错) | 避免重复命名 |
| 父组件未传入绑定 | 值为 undefined |
使用 { default: xxx } |
子组件内部直接修改 props |
Vue 警告 | 使用 model |
| 动态 model 名字 | 必须是常量字符串 | 不支持表达式 |
旧版 v-model |
modelValue 仍兼容 |
建议迁移新语法 |
七、底层实现思维导图
arduino
defineModel()
↓
┌──────────────┐
│ 编译阶段展开 │
└──────────────┘
↓
props + emits + computed
↓
双向数据同步:
props.get → 父→子
emit.set → 子→父
八、模型与属性的协同用法
有时组件既需要配置型参数(props),又需要同步状态(model):
xml
<script setup>
const label = defineProps<{ label: string }>()
const checked = defineModel<boolean>('checked')
</script>
<template>
<label>
{{ label }}
<input type="checkbox" v-model="checked" />
</label>
</template>
父组件:
ini
<MySwitch label="Enable Feature" v-model:checked="isEnabled" />
✅ 数据结构清晰:
label:静态描述;checked:可响应双向更新。
九、总结:两者的根本区别
| 对比项 | defineProps |
defineModel |
|---|---|---|
| 本质 | 只读输入接口 | 可写的双向绑定接口 |
| 方向 | 父 ➜ 子 | 父 ⇄ 子 |
| 修改数据 | 不允许(Vue 警告) | 允许(通过 setter) |
| 底层生成 | props | props + emits + computed |
| 使用场景 | 组件配置、样式、静态数据 | 表单、状态同步 |
| 使用体验 | 传统单向流 | 更自然的双向流 |
| TS 推导 | 需手动声明类型 | 自动推导类型 |
十、思考与展望
defineModel() 并不是简单的语法糖,而是:
- Vue 响应式设计的自然演化;
- props 与 emits 的统一抽象;
- 为双向状态同步建立更明确的编译约束。
未来 Vue 的编译器可能继续扩展:
- 支持多层嵌套的
v-model; - 类型推导更智能;
- 模型声明与表单组件的融合(如
FormKit风格)。
✅ 总结一句话:
在 Vue 3.3+ 中,
defineProps()定义单向输入接口,而
defineModel()定义双向绑定接口。它们的底层逻辑是互补的 ------
前者提供"父到子"的只读流,后者建立"父 ⇄ 子"的双向响应流。
本文部分内容借助 AI 辅助生成,并由作者整理审核。