理解组件 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>

拿到修饰符后,需要子组件自行实现修饰符对应的逻辑,这实际上就是对子组件修改父组件数据时的一种限制。通过 defineModelset 选项来实现:

<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" 通用

最佳实践建议:

  1. 优先使用 defineModel:如果你用的是 Vue 3.4+,defineModel 是组件双向绑定的最佳选择,代码更简洁、类型推导更好。
  2. 合理命名 v-model:当组件需要多个双向绑定时,给每个 v-model 起一个语义化名字,让接口清晰可读。
  3. 插槽提升复用性:遇到「组件结构固定但内容多变」的场景,使用插槽而非 Props 传递模板,能极大提升组件的复用性。
  4. 作用域插槽打造无渲染组件:对于数据逻辑复杂但视图灵活的场景,无渲染组件模式是最优雅的解决方案。