WinddSnow

Vue-3-Component-Communication-Part1-Props-defineProps-defineEmits-v-model

字数统计: 4.8k阅读时长: 21 min
2026/08/01

第106课:组件通信(上)——Props、definePropsdefineEmitsv-model 实现

Vue 组件树中的通信遵循单向数据流原则:数据从父组件通过 Props 向下流动到子组件,子组件通过事件(Emits) 向上通知父组件发生了某些事情。这一“Props down, Events up”的模式是 Vue 组件化架构的基石,它使得数据流可预测、易于调试。在此基础上,Vue 3 提供了 v-model 的增强语法,允许父子组件之间实现便捷的双向绑定——这本质上仍是 Props 和 Emits 的组合,但语法上更为简洁。理解 Props 的类型校验、单向数据流约束、Emits 的事件声明和验证、以及 v-model 的底层实现,是构建可复用、类型安全的 Vue 组件的基础。本节课将逐一拆解这四个核心概念,并通过实战构建一个完整的自定义表单控件来串联它们。


1. Props:父组件向子组件传递数据

Props 是父组件传递给子组件的只读数据。在组合式 API 中,使用 defineProps 宏来声明组件接收的 Props。Vue 会根据声明的类型在开发模式下进行类型校验,并给出控制台警告。

1.1 defineProps 的基本语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- ChildComponent.vue -->
<script setup>
// 1. 纯类型声明(TypeScript 语法)
interface Props {
title: string;
count?: number;
tags?: string[];
}

const props = defineProps<Props>();

// 2. 运行时声明 + 默认值(使用 withDefaults)
const props = withDefaults(defineProps<Props>(), {
count: 0,
tags: () => [], // 对象/数组默认值必须使用工厂函数
});

console.log(props.title); // 直接访问,无需 .value
</script>

<template>
<div>
<h2>{{ title }}</h2>
<p>Count: {{ count }}</p>
</div>
</template>

关键点

  • defineProps 是一个编译宏——它不需要从 Vue 中导入,Vue 的 SFC 编译器会自动处理它。它仅在 <script setup> 中可用。
  • defineProps 返回一个只读的响应式对象。在模板中可以直接访问属性(自动解包),在 <script> 中也可以通过 props.xxx 访问。
  • TypeScript 纯类型声明defineProps<Props>())是最简洁的方式,但这种方式不支持运行时默认值。如需默认值,使用 withDefaults(defineProps<Props>(), defaults)
  • 运行时声明(传入对象字面量)也支持,使用 typedefaultrequiredvalidator 等选项,类似于选项式 API 的 props 定义。适用于非 TypeScript 项目或需要自定义验证器的场景。
1
2
3
4
5
6
7
8
<script setup>
// 运行时声明(不使用 TypeScript 泛型)
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
tags: { type: Array, default: () => [] },
});
</script>

1.2 Props 的单向数据流原则

绝对不要在子组件内部直接修改 Props 的值。Props 是父组件传递下来的只读数据,修改它会破坏单向数据流,导致数据不一致,并且 Vue 会在开发模式下发出警告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup>
const props = defineProps<{ value: string }>();

// ❌ 错误:试图修改 Props
function handleChange() {
props.value = 'new value'; // 警告:Attempting to mutate prop "value"
}

// ✅ 正确:通过 Emits 通知父组件修改
const emit = defineEmits<{ 'update:value': [value: string] }>();
function handleChange(newVal) {
emit('update:value', newVal);
}
</script>

如果确实需要基于 Props 的初始值创建一个本地状态(例如,弹窗默认打开状态由父组件控制,但子组件内部也可以关闭),应当将 Props 的初始值赋给一个本地的 refreactive,然后操作该本地状态。

1
2
3
4
5
6
7
8
<script setup>
import { ref } from 'vue';

const props = defineProps<{ initialOpen: boolean }>();

// 将 Props 的初始值复制到本地 state,之后内部修改不影响父组件
const isOpen = ref(props.initialOpen);
</script>

1.3 Props 的校验:validator 选项

运行时声明的 Props 支持自定义验证函数。验证失败时 Vue 会在控制台输出警告(仅在开发模式)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup>
defineProps({
status: {
type: String,
required: true,
validator(value) {
return ['active', 'inactive', 'pending'].includes(value);
},
},
age: {
type: Number,
validator(value) {
return value >= 0 && value <= 150;
},
},
});
</script>

注意:自定义验证器返回 false 时仅发出警告,不会阻止组件渲染或抛出异常。这是开发时的辅助工具,不应依赖它来保证数据正确性。


2. defineEmits:子组件向父组件通信

Emits 是子组件向父组件发送消息的机制。在组合式 API 中,使用 defineEmits 宏声明组件可以触发哪些事件及其参数类型。

2.1 基本语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- ChildComponent.vue -->
<script setup>
// TypeScript 纯类型声明
const emit = defineEmits<{
submit: [data: { name: string; age: number }];
cancel: [];
}>();

// 或简化写法(Vue 3.3+)
const emit = defineEmits<{
'update:modelValue': [value: string];
submit: [];
}>();

function handleSubmit() {
emit('submit', { name: 'Alice', age: 25 });
}

function handleCancel() {
emit('cancel');
}
</script>

<template>
<button @click="handleSubmit">提交</button>
<button @click="handleCancel">取消</button>
</template>

父组件中监听事件:

1
<ChildComponent @submit="onSubmit" @cancel="onCancel" />

2.2 事件验证

defineEmits 也支持运行时验证——通过对象语法为每个事件提供验证函数。验证函数接收事件参数,返回 true(验证通过)或 false(验证失败,Vue 发出警告)。

1
2
3
4
5
6
7
8
9
10
<script setup>
const emit = defineEmits({
// 无参数事件:返回 true 即允许
click: null,
// 有参数事件:验证函数返回 boolean
submit: (payload) => {
return payload && typeof payload.name === 'string' && typeof payload.age === 'number';
},
});
</script>

与 Props 验证类似,Emit 验证失败仅发出警告,不阻止事件触发。

2.3 Emits 的最佳实践

  • 始终声明组件触发的所有事件(通过 defineEmits),即使 TypeScript 类型推导无法覆盖,也要在运行时声明中列出。这能生成更好的组件文档,且 Vue 会自动将声明的事件从 $attrs 中排除,避免传递到根元素。
  • 事件名使用 camelCase,父组件中使用 kebab-case 监听。这是 Vue 官方推荐的一致命名风格。
  • 携带明确的事件参数:事件参数应包含足够信息,避免父组件需要通过 $event 隐式获取数据。例如 emit('update', id, newValue)emit('update', { id, newValue }) 比仅触发事件让父组件猜测更清晰。

3. v-model:Props 与 Emits 的语法糖

v-model 是 Vue 中最常用的双向绑定指令。在 Vue 3 中,v-model 的底层机制被统一为:一个名为 modelValue 的 Prop + 一个名为 update:modelValue 的 Emit。同时 Vue 3 支持多个 v-model 绑定自定义修饰符

3.1 单个 v-model 的底层实现

1
2
3
4
5
<!-- 父组件 -->
<CustomInput v-model="username" />

<!-- 等价于: -->
<CustomInput :modelValue="username" @update:modelValue="username = $event" />

子组件实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- CustomInput.vue -->
<script setup>
const props = defineProps<{
modelValue: string;
}>();

const emit = defineEmits<{
'update:modelValue': [value: string];
}>();

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

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

3.2 多个 v-model 绑定

Vue 3 允许在一个组件上使用多个 v-model,每个绑定到一个特定名称的 Prop 和 Emit。

1
2
3
4
5
<!-- 父组件 -->
<UserForm
v-model:name="userName"
v-model:email="userEmail"
/>

子组件实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- UserForm.vue -->
<script setup>
const props = defineProps<{
name: string;
email: string;
}>();

const emit = defineEmits<{
'update:name': [value: string];
'update:email': [value: string];
}>();
</script>

<template>
<input :value="name" @input="emit('update:name', ($event.target as HTMLInputElement).value)" />
<input :value="email" @input="emit('update:email', ($event.target as HTMLInputElement).value)" />
</template>

命名规则:v-model:propName 对应 Prop propName 和 Emit update:propName。默认的 v-model(无参数)对应 modelValueupdate:modelValue

3.3 v-model 修饰符

Vue 3 允许为 v-model 定义自定义修饰符,例如 .trim.capitalize。修饰符通过 modelModifiers Prop 传递(默认 v-model)或 {propName}Modifiers(具名 v-model)。

1
2
3
<!-- 父组件使用修饰符 -->
<CustomInput v-model.trim="text" />
<CustomInput v-model:title.capitalize="title" />

子组件处理修饰符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<script setup>
const props = defineProps<{
modelValue: string;
modelModifiers?: { trim?: boolean };
title?: string;
titleModifiers?: { capitalize?: boolean };
}>();

const emit = defineEmits<{
'update:modelValue': [value: string];
'update:title': [value: string];
}>();

function handleInput(e) {
let value = e.target.value;
// 处理修饰符
if (props.modelModifiers?.trim) {
value = value.trim();
}
emit('update:modelValue', value);
}

function handleTitleInput(e) {
let value = e.target.value;
if (props.titleModifiers?.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1);
}
emit('update:title', value);
}
</script>

修饰符的命名规则

  • 默认 v-model 的修饰符通过 modelModifiers prop 传递。
  • 具名 v-model:name 的修饰符通过 nameModifiers prop 传递。
  • 修饰符对象中包含对应修饰符名称为键,值为 true 的属性(如果父组件使用了该修饰符)。

4. v-model 与计算属性的配合

当子组件的 v-model 逻辑较复杂时(如在 emit 之前需要转换数据),可以结合可写计算属性简化代码。可写计算属性的 get 返回 Prop 值,set 中调用 emit。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup>
import { computed } from 'vue';

const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();

// 可写计算属性充当“双向绑定代理”
const value = computed({
get() {
return props.modelValue;
},
set(newVal) {
emit('update:modelValue', newVal);
},
});
</script>

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

这种模式的好处是**子组件内部可以像操作本地状态一样使用 value**(包括使用 v-model 绑定到原生元素),而无需手动编写事件处理函数。适用于 Props 到 Emit 的 1:1 映射场景。


5. 综合实战:自定义星级评分组件

以下示例将 Props、defineEmitsv-model 整合为一个可复用的星级评分组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
<!-- StarRating.vue -->
<script setup>
interface Props {
modelValue: number; // 当前评分(0~5)
max?: number; // 最大星数(默认 5)
readonly?: boolean; // 是否只读
modelModifiers?: {
half?: boolean; // 支持半星修饰符
};
}

const props = withDefaults(defineProps<Props>(), {
max: 5,
readonly: false,
});

const emit = defineEmits<{
'update:modelValue': [value: number];
}>();

function setRating(index: number) {
if (props.readonly) return;

let newValue = index + 1; // index 从 0 开始,评分从 1 开始
// 处理 .half 修饰符:再次点击同一颗星取消半星
if (props.modelModifiers?.half) {
if (props.modelValue === newValue) {
newValue -= 0.5;
} else if (props.modelValue === newValue - 0.5) {
newValue = props.modelValue; // 不做变化(保持半星)
}
} else {
if (props.modelValue === newValue) {
newValue = 0; // 再次点击取消评分
}
}

emit('update:modelValue', newValue);
}

function getStarClass(index) {
const filled = props.modelValue - index;
if (filled >= 1) return 'star star-full';
if (filled > 0 && filled < 1) return 'star star-half';
return 'star star-empty';
}
</script>

<template>
<div class="star-rating" :class="{ readonly }">
<span
v-for="i in max"
:key="i"
:class="getStarClass(i - 1)"
@click="setRating(i - 1)"
>

</span>
<span class="rating-text" v-if="!readonly">{{ modelValue || '未评分' }}</span>
</div>
</template>

<style scoped>
.star-rating {
display: inline-flex;
align-items: center;
gap: 4px;
}
.star {
font-size: 24px;
cursor: pointer;
transition: color 0.2s;
user-select: none;
}
.star-full {
color: #f59e0b;
}
.star-half {
background: linear-gradient(to right, #f59e0b 50%, #d1d5db 50%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.star-empty {
color: #d1d5db;
}
.readonly .star {
cursor: default;
}
.rating-text {
margin-left: 8px;
font-size: 14px;
color: #666;
}
</style>

父组件使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup>
import { ref } from 'vue';
import StarRating from './StarRating.vue';

const rating = ref(3);
const halfRating = ref(2.5);
</script>

<template>
<StarRating v-model="rating" />
<p>当前评分:{{ rating }}</p>

<!-- 使用 .half 修饰符支持半星 -->
<StarRating v-model.half="halfRating" />
<p>半星评分:{{ halfRating }}</p>

<!-- 只读模式 -->
<StarRating :model-value="4" readonly />
</template>

设计要点

  • 通过 v-model 绑定评分值,父组件无需关心内部实现。
  • 支持 maxreadonly Props 配置组件行为。
  • 通过 modelModifiers.half 实现半星修饰符,允许 .5 评分。
  • 子组件完全通过 emit('update:modelValue') 通知父组件更新,遵循单向数据流原则。
  • 只读模式下禁用点击,CSS 隐藏光标指示。

课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) 在 Vue 3 <script setup> 中,声明组件接收的 Props 应使用哪个编译宏?
    A. defineProps
    B. withDefaults
    C. defineEmits
    D. defineComponent

  2. (单选) Vue 3 中,v-model="value" 的底层等价写法是什么?
    A. :value="value" @change="value = $event"
    B. :modelValue="value" @update:modelValue="value = $event"
    C. :prop="value" @input="value = $event.target.value"
    D. :value="value" @update="value = $event"

  3. (填空) Vue 3 中,要在一个组件上绑定多个 v-model,需要使用 ______ 语法指定不同的绑定名称。

  4. (多选) 关于 Vue 3 Props 的说法,哪些是正确的?
    A. 子组件可以直接修改 Props 的值以更新父组件状态。
    B. defineProps 返回的对象在 <script setup> 中是响应式的。
    C. Props 的默认值如果是对象或数组,必须使用工厂函数返回。
    D. 使用 TypeScript 泛型声明 Props 时,可以通过 withDefaults 设置默认值。

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要实现一个颜色选择器组件(ColorPicker)。要求如下:

  • 使用 v-model 绑定当前颜色值(十六进制字符串,如 #ff0000)。
  • 提供 12 个预设颜色块,点击预设颜色更新 v-model
  • 提供一个文本输入框,允许用户手动输入十六进制颜色值,实时更新 v-model
  • 使用 v-model:opacity 绑定第二个值:不透明度(0~1 的数字),通过滑块调节。
  • 使用 definePropswithDefaults 定义 Props(如 presetColors 数组)。
  • 使用 defineEmits 声明所有事件。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 Vue 3 单文件组件。提示词中需明确指定 v-model 的默认和具名绑定、Props 和 Emits 的类型定义、以及颜色输入校验逻辑。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请创建一个 Vue 3 颜色选择器组件。需要创建文件 src/components/ColorPicker.vue

  • 使用 <script setup lang="ts">
  • defineProps 声明:modelValue: string(颜色值,默认 '#000000')、opacity: number(不透明度,默认 1)、presetColors: string[](默认 ['#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', '#ff6600', '#6600ff', '#00ff66', '#666666'])。使用 withDefaults 设置默认值。
  • defineEmits 声明:'update:modelValue': [value: string]'update:opacity': [value: number]
  • 模板布局:左侧预设颜色网格(display: grid),每个色块点击时 emit('update:modelValue', color)。右侧上方显示当前颜色的预览方块(背景色为 modelValue,叠加不透明度)。下方文本输入框绑定一个本地 ref(初始值为 modelValue),监听 @input 时校验输入是否为有效十六进制颜色(正则 /^#[0-9a-fA-F]{6}$/),有效则 emit('update:modelValue', value)。下方不透明度滑块(type="range" min="0" max="1" step="0.01"),:value="opacity"@inputemit('update:opacity', parseFloat(e.target.value))
  • 所有颜色色块和预览块添加边框和圆角样式。添加 JSDoc 注释。完成后输出完整文件内容。

四、面试真题与参考答案

题目(阿里前端面试题):

请详细解释 Vue 3 中 Props 和 Emits 的设计原则(单向数据流),以及 v-model 是如何基于这两个概念实现的。如果父组件通过 Props 传递了一个对象给子组件,子组件直接修改了该对象的内部属性,这会破坏单向数据流吗?Vue 会如何反应?请说明原因。

参考答案

Vue 的 Props 和 Emits 遵循“Props down, Events up”的单向数据流原则:数据从父组件通过 Props 向下流动到子组件,子组件不能直接修改 Props,而是通过触发事件(Emits)将修改意图告知父组件,由父组件更新自己的数据源并重新通过 Props 传递新值。这种模式确保了数据来源的可追溯性,任何状态变更都有明确的“所有者”,避免了组件间隐式的相互影响。

v-model 是 Props 和 Emits 的语法糖。默认的 v-model 展开为 :modelValue="value" @update:modelValue="value = $event"。具名 v-model:name 展开为 :name="value" @update:name="value = $event"。子组件通过 defineProps 接收对应的 Prop,通过 defineEmits 触发对应的更新事件。

如果父组件传递给子组件一个对象,子组件直接修改该对象内部的属性(如 props.user.name = 'Bob'),这在技术上不会报错,因为 Vue 的 Props 只读保护是浅层的——它仅阻止替换整个 Prop 的引用(props.user = newObject),但不阻止修改嵌套对象内部的属性。然而,这破坏了单向数据流的可预测性——父组件不会收到任何通知,它持有的数据源被悄无声息地修改了,导致状态不一致。Vue 在开发模式下不会对此发出警告(因为深层监听开销巨大),但这是公认的反模式。正确的做法仍然是通过 Emit 通知父组件修改,或使用 Pinia 等状态管理方案管理共享状态。如果确实需要就地编辑对象,应当将对象深拷贝到本地 ref,编辑完成后再通过 Emit 统一提交。


课后练习答案

一、概念自测答案

  1. A

    • 解析:defineProps 是用于声明组件 Props 的编译宏。withDefaults 用于设置默认值,defineEmits 用于声明事件,defineComponent 是选项式 API 的辅助函数。
  2. B

    • 解析:Vue 3 中 v-model 的底层是 :modelValue Prop 和 @update:modelValue Emit 的组合。A 是 Vue 2 的行为;C、D 语法不准确。
  3. v-model:name

    • 解析:v-model:propName 用于创建具名 v-model,对应 Prop propName 和 Emit update:propName
  4. B、C、D

    • 解析:A 错误,子组件不能直接修改 Props,这违反了单向数据流原则。B 正确,defineProps 返回一个只读的响应式对象。C 正确,对象/数组默认值必须使用工厂函数,以避免多个实例共享同一引用。D 正确,withDefaults(defineProps<Props>(), defaults) 是 TypeScript 泛型声明 + 默认值的标准写法。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请用 Vue 3 <script setup lang="ts"> 实现一个颜色选择器组件 ColorPicker.vue。要求:

  • 使用 v-model 绑定颜色值(十六进制),v-model:opacity 绑定不透明度。
  • defineProps + withDefaults 定义 Props(modelValue, opacity, presetColors)。
  • defineEmits 声明 update:modelValueupdate:opacity
  • 预设颜色块点击更新值,文本输入框校验后更新值,滑块更新不透明度。
  • 输出完整单文件组件。”
CATALOG
  1. 1. 第106课:组件通信(上)——Props、defineProps、defineEmits、v-model 实现
    1. 1.1. 1. Props:父组件向子组件传递数据
      1. 1.1.1. 1.1 defineProps 的基本语法
      2. 1.1.2. 1.2 Props 的单向数据流原则
      3. 1.1.3. 1.3 Props 的校验:validator 选项
    2. 1.2. 2. defineEmits:子组件向父组件通信
      1. 1.2.1. 2.1 基本语法
      2. 1.2.2. 2.2 事件验证
      3. 1.2.3. 2.3 Emits 的最佳实践
    3. 1.3. 3. v-model:Props 与 Emits 的语法糖
      1. 1.3.1. 3.1 单个 v-model 的底层实现
      2. 1.3.2. 3.2 多个 v-model 绑定
      3. 1.3.3. 3.3 v-model 修饰符
    4. 1.4. 4. v-model 与计算属性的配合
    5. 1.5. 5. 综合实战:自定义星级评分组件
    6. 1.6. 课后练习
      1. 1.6.1. 一、概念自测(选择题 / 填空题)
      2. 1.6.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.6.3. 三、Agent 模式下的提示词示例
      4. 1.6.4. 四、面试真题与参考答案
    7. 1.7. 课后练习答案
      1. 1.7.1. 一、概念自测答案
      2. 1.7.2. 二、AI 编程任务参考答案(提示词示例)