理解组件 v-model
Q: 父传子用 Props,子传父用 Emit,v-model 又是如何在组件间搭建通信桥梁的?
A: v-model 是 Vue 中的一个内置指令,除了可以在表单元素上做双向绑定以外,它还可以直接用在组件上,成为父子组件数据传输的桥梁。本质上,它是 Props 和 Emit 的语法糖,底层并未破坏单向数据流的规则。
从 Vue 3.4 开始,官方提供了一个全新的宏 defineModel,让组件 v-model 的使用体验达到了前所未有的简洁。
快速上手:星级评分组件
来看一个典型场景:父组件需要维护一个评分数据,子组件通过星星点击来修改这个评分。
父组件 App.vue:
<template>
<div class="app-container">
<h1>请对本次服务评分:</h1>
<Rating v-model="rating" />
<p v-if="rating > 0">你当前的评价为 {{ rating }} 颗星</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Rating from './components/Rating.vue';
const rating = ref(0);
</script>
子组件 Rating.vue:
<template>
<div class="rating-container">
<span v-for="star in 5" :key="star" class="star" @click="setStar(star)">
{{ model >= star ? '★' : '☆' }}
</span>
</div>
</template>
<script setup>
const model = defineModel();
function setStar(newStar) {
model.value = newStar;
}
</script>
核心要点:
defineModel()是一个编译宏,无需导入即可使用。- 它返回一个
ref值,子组件通过.value读写该值即可与父组件双向同步。 - 返回值可以直接和子组件的表单元素做
v-model绑定:<input v-model="model" />。
defineModel 的底层原理
虽然 defineModel 用起来像一个普通的 ref,但它底层仍然是基于 Props + Emit 实现的。编译器在编译阶段会将 defineModel() 展开为:
- 一个名为
modelValue的 prop(用于接收父组件数据) - 一个名为
update:modelValue的事件(用于通知父组件更新数据)
如果你使用的是 Vue 3.4 之前的版本,需要手动书写以下代码:
<script setup>
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
可以看到,defineModel 将这部分样板代码彻底消除了,大幅提升了开发体验。
defineModel 的进阶用法
Q: 只有一个 v-model 不够用怎么办?多个数据需要双向绑定该怎么做?
A: defineModel 支持命名参数和多个 v-model 绑定,同时还支持修饰符和自定义 setter,可以满足复杂场景下的数据通信需求。
1. 支持验证与默认值
// 使 v-model 必填
const model = defineModel({ required: true });
// 提供默认值
const model = defineModel({ default: 0 });
// 指定类型
const model = defineModel({ type: String });
这些配置和 Props 的验证规则完全一致,非常直观。
2. 带参数的 v-model
当父组件需要给 v-model 命名时,传一个参数即可:
父组件:
<MyComponent v-model:title="bookTitle" />
子组件中通过参数名接收:
<script setup>
const title = defineModel('title');
</script>
<template>
<input type="text" v-model="title" />
</template>
当需要绑定多个 v-model 时,命名就变得必不可少:
<UserName v-model:first-name="first" v-model:last-name="last" />
<script setup>
// 子组件通过命名区分
const firstName = defineModel('firstName');
const lastName = defineModel('lastName');
</script>
<template>
<input type="text" v-model="firstName" />
<input type="text" v-model="lastName" />
</template>
带名字时的验证作为第二个参数传入:
const title = defineModel('title', { required: true });
const count = defineModel('count', { type: Number, default: 0 });
3. 修饰符:对数据修改进行拦截
父组件可以像表单元素一样使用修饰符:
<MyComponent v-model.capitalize="myText" />
子组件通过解构获取修饰符:
<script setup>
const [model, modifiers] = defineModel();
console.log(modifiers); // { capitalize: true }
</script>
拿到修饰符后,需要子组件自行实现修饰符对应的逻辑,这实际上就是对子组件修改父组件数据时的一种限制。通过 defineModel 的 set 选项来实现:
<script setup>
const [model, modifiers] = defineModel({
set(value) {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
return value;
},
});
</script>
<template>
<input type="text" v-model="model" />
</template>
以星级评分为例,可以添加 .number 修饰符来确保传入的是合法数字:
const [model, modifiers] = defineModel({
required: true,
set(value) {
if (modifiers.number) {
if (isNaN(value)) {
value = 0;
} else {
value = Number(value);
}
if (value < 0) {
value = 0;
} else if (value > 5) {
value = 5;
}
return value;
}
},
});
这种设计让组件更加健壮,避免了外部传入非法数据导致的问题。
插槽:向子组件传递模板内容
Q: Props 只能传递 JS 数据,如果父组件想向子组件传递一段模板内容(HTML 结构),该怎么办?
A: 这正是插槽要解决的问题。父组件需要向子组件传递模板内容时,Props 就无能为力了。插槽机制让子组件预留「占位符」,父组件在使用时填充任意模板内容。
快速上手:卡片组件
子组件 Card.vue,定义了一个具名插槽 header 和一个默认插槽:
<template>
<div class="card">
<div class="card-header">
<slot name="header"></slot>
</div>
<div class="card-body">
<slot></slot>
</div>
</div>
</template>
父组件使用时,通过 v-slot 指定内容插入到哪个具名插槽:
<template>
<div>
<Card>
<template v-slot:header>我的卡片标题</template>
这是卡片的内容
</Card>
<Card>
<template v-slot:header>探险摄影</template>
<div class="card-content">
<img src="./assets/landscape.jpeg" class="card-image" />
<p>探索未知的自然风光,记录下每一个令人惊叹的瞬间。</p>
</div>
</Card>
</div>
</template>
要点梳理:
- 没有指定插槽名的内容会被放入默认插槽。
v-slot:插槽名可以简写为#插槽名:<template #header>探险摄影</template>。- 插槽名支持动态绑定:
<template #[slotName]>内容</template>。
插槽的默认内容
可以在 <slot> 标签之间书写默认内容,当父组件未提供对应模板时,默认内容会被渲染:
<slot>这是默认插槽的默认值</slot>
作用域插槽:子组件向父组件传递数据
Q: 父组件模板中的表达式只能访问父组件作用域,子组件模板中的表达式只能访问子组件作用域。那如果子组件的数据需要由父组件来决定如何渲染呢?
A: 这就用到了作用域插槽。子组件在设置插槽时通过动态属性向外暴露数据,父组件通过 v-slot 接收这些数据。
作用域规则回顾:
| 模板位置 | 可访问的作用域 |
|---|---|
| 父组件模板 | 只能访问父组件数据 |
| 子组件模板 | 只能访问子组件数据 |
| 父组件插入的插槽内容 | 只能访问父组件数据(除非使用作用域插槽) |
子组件暴露数据:
<div>
<slot :text="greetingMessage" :count="1"></slot>
</div>
父组件接收数据:
<MyComponent v-slot="slotProps">
{{ slotProps.text }} {{ slotProps.count }}
</MyComponent>
支持解构语法,写起来更简洁:
<MyComponent v-slot="{ text, count }">
{{ text }} {{ count }}
</MyComponent>
实战示例:通用列表组件
子组件通过作用域插槽将列表中的每一项数据暴露给父组件,由父组件决定每一项的渲染方式:
子组件 ListContainer.vue:
<template>
<div class="list-container">
<ul>
<li v-for="item in items" :key="item.id">
<slot name="item" :item="item">{{ item.defaultText }}</slot>
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
const items = ref([
{ id: 1, name: 'Vue.js', defaultText: 'Vue.js 是一个渐进式 JavaScript 框架。' },
{ id: 2, name: 'React', defaultText: 'React 是一个用于构建用户界面的 JavaScript 库。' },
{ id: 3, name: 'Angular', defaultText: 'Angular 是一个开源的 Web 应用框架。' },
]);
</script>
父组件自定义渲染逻辑:
<template>
<div class="app-container">
<ListContainer>
<template #item="{ item }">
<h3>{{ item.name }}</h3>
<p>{{ item.defaultText }}</p>
</template>
</ListContainer>
</div>
</template>
这种模式在 Vue 官方文档中有一个专门的称呼——无渲染组件:
一些组件可能只包括了逻辑而不需要自己渲染内容,视图输出通过作用域插槽全权交给了消费者组件。我们将这种类型的组件称为无渲染组件。
这种设计将逻辑与视图彻底解耦,组件负责数据获取与状态管理,视图由使用者自由决定,灵活性极高。
总结
本文涵盖了两大组件通信进阶机制:
| 机制 | 核心用途 | 关键 API | Vue 版本要求 |
|---|---|---|---|
| 组件 v-model | 父子组件数据双向同步 | defineModel() |
3.4+ 推荐 |
| 默认插槽 / 具名插槽 | 父传模板内容给子组件 | <slot> / v-slot:name |
通用 |
| 作用域插槽 | 子组件向父组件暴露数据,由父组件决定渲染 | v-slot="slotProps" |
通用 |
最佳实践建议:
- 优先使用
defineModel:如果你用的是 Vue 3.4+,defineModel是组件双向绑定的最佳选择,代码更简洁、类型推导更好。 - 合理命名 v-model:当组件需要多个双向绑定时,给每个 v-model 起一个语义化名字,让接口清晰可读。
- 插槽提升复用性:遇到「组件结构固定但内容多变」的场景,使用插槽而非 Props 传递模板,能极大提升组件的复用性。
- 作用域插槽打造无渲染组件:对于数据逻辑复杂但视图灵活的场景,无渲染组件模式是最优雅的解决方案。