Vue Props 与 Emit
组件化的核心是隔离——每个组件管自己的数据和样式。但隔离不等于孤立:父子组件之间必须有通信机制。Vue 提供两个核心 API:Props 让父组件向子组件传数据,Emit 让子组件向父组件发事件。本章详细讲清。
1. Props:父传子的数据通道
Props 是组件的输入。父组件通过属性的形式把数据传给子组件,子组件用 defineProps 接收。
<!-- UserCard.vue 子组件 -->
<template>
<div class="card">
<h3>{{ name }}</h3>
<p>年龄:{{ age }}</p>
</div>
</template>
<script setup>
// 写法一:字符串数组(简单但无校验)
defineProps(["name", "age"]);
// 写法二:对象 + 类型(推荐,有警告)
defineProps({
name: String,
age: Number,
});
</script>
<!-- 父组件 -->
<template>
<!-- 把数据通过 props 传给子组件 -->
<UserCard :name="user.name" :age="user.age" />
<UserCard name="小红" :age="18" />
</template>
<script setup>
import { ref } from "vue";
import UserCard from "./UserCard.vue";
const user = ref({ name: "小明", age: 20 });
</script>几个要点:
- 父组件用
:name="..."(v-bind)把数据传下去;静态值可以不写冒号name="小红"。 - 子组件用
defineProps声明能接收哪些 props(不需要 import,编译宏)。 - 两种声明方式:字符串数组(简单)、对象(带类型校验,推荐)。
- props 在模板里直接用名字(
{{ name }});在 JS 里通过返回值访问(const props = defineProps(...)然后props.name)。
2. Props 的详细校验
对象写法可以给每个 prop 加更多约束——类型、必填、默认值、自定义校验。Vue 在开发模式下会检查这些约束,违反时打印警告。
<script setup>
// 详细的 props 定义:类型、必填、默认值、自定义校验
defineProps({
// 基础类型检查
name: String,
age: Number,
isAdmin: Boolean,
// 多个允许的类型
id: [String, Number],
// 必填 + 类型
email: {
type: String,
required: true,
},
// 有默认值
role: {
type: String,
default: "user",
},
// 对象/数组的默认值要用工厂函数
tags: {
type: Array,
default: () => [],
},
// 自定义校验函数
rating: {
validator: (val) => val >= 1 && val <= 5,
},
});
</script>
<!-- 仅运行时声明(无 TS 类型)。
需要 TS 类型推导时用 withDefaults + 泛型 -->- 类型:可以是单个构造函数,也可以是数组(多个允许类型)。
- required: true:必填,否则警告。和 default 互斥。
- default:默认值。对象/数组必须用工厂函数
() => []返回,避免多实例共享引用。 - validator:自定义校验函数,返回 false 时警告。
3. TypeScript 写法
用 TypeScript 时,可以用泛型直接写 props 的类型,IDE 自动补全和类型检查都更准:
<script setup lang="ts">
// TypeScript 写法:用泛型直接写类型
interface Props {
name: string;
age?: number; // 可选
tags?: string[];
}
const props = withDefaults(defineProps<Props>(), {
age: 18,
tags: () => ["default"],
});
// 现在 props.name 是 string 类型,IDE 自动补全
console.log(props.name.toUpperCase());
</script>需要默认值时用 withDefaults(因为泛型写法不能直接和对象写法合并)。这是 Vue 3 + TS 项目的标准写法。
4. 单向数据流(重要!)
Vue 的 props 是单向的:父组件改了,子组件自动更新;但子组件不能直接改 props。这是 Vue 的核心约束:
- ❌
props.name = '新名字'—— 直接修改 props 会被警告(且不生效)。 - ✅ 把 props 转成本地 ref:
const localName = ref(props.name),改本地副本。 - ✅ 通过 emit 通知父组件修改:子组件
emit('update:name', '新名字'),父组件改原数据。 - ✅ 用 computed 派生新值:
const upper = computed(() => props.name.toUpperCase())。
这个约束的好处是数据流向清晰——永远父 → 子,不会出现"两边互相改"的混乱局面。大型项目里这是减少 bug 的关键。
5. Emit:子传父的事件通道
子组件想通知父组件"发生了什么"(被点击了、值变了),用 emit 触发自定义事件。父组件用 @事件名 监听,和原生 DOM 事件一样的语法。
<!-- 子组件 Counter.vue -->
<template>
<button @click="onClick">+1</button>
</template>
<script setup>
import { ref } from "vue";
// 声明可以触发的事件(可选,但推荐——有校验和 IDE 提示)
const emit = defineEmits(["increase", "reset"]);
const count = ref(0);
function onClick() {
count.value++;
// 触发 increase 事件,把当前 count 作为数据传出去
emit("increase", count.value);
}
</script>
<!-- 父组件 -->
<template>
<Counter @increase="onIncrease" @reset="onReset" />
<p>当前总数:{{ total }}</p>
</template>
<script setup>
import { ref } from "vue";
import Counter from "./Counter.vue";
const total = ref(0);
function onIncrease(n) {
total.value = n;
}
function onReset() {
total.value = 0;
}
</script>几个要点:
- 子组件用
defineEmits声明能触发哪些事件(可选,但推荐写——有类型提示和校验)。 - 触发用
emit('事件名', 数据),数据可以是任何类型(数字、对象、数组)。 - 事件名推荐用短横线(
item-click),不用驼峰(itemClick)——模板里的事件名会被自动转小写。
6. Emit 的 TypeScript 写法
用 TS 时可以声明每个事件的参数类型,触发时类型不匹配会报错:
<script setup lang="ts">
// TypeScript 写法:声明事件的 payload 类型
const emit = defineEmits<{
// 签名:事件名 + 参数类型
(e: "increase", count: number): void;
(e: "change", name: string, age: number): void;
(e: "reset"): void;
}>();
emit("increase", 1); // ✅
emit("increase", "wrong"); // ❌ 类型错误
</script>7. v-model 在组件上的应用
第 6 章我们提过自定义组件可以用 v-model。现在能讲清原理了——它就是 props + emit 的语法糖:
<!-- 子组件 CustomInput.vue -->
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(["modelValue"]);
defineEmits(["update:modelValue"]);
</script>
<!-- 父组件 -->
<template>
<CustomInput v-model="msg" />
<!-- 等价于: -->
<CustomInput
:model-value="msg"
@update:model-value="msg = $event"
/>
</template>
<!-- 多 v-model:Vue 3 支持给每个 v-model 加参数 -->
<template>
<UserForm v-model:name="name" v-model:age="age" />
</template>子组件接收 modelValue prop,触发 update:modelValue 事件。父组件的 v-model 自动展开成这两个绑定。Vue 3 还支持多个 v-model(v-model:name、v-model:age),让一个组件暴露多个双向绑定通道。
8. 父子通信的完整心智模型
- 父传子:props。父组件像传函数参数一样把数据传下去。
- 子传父:emit。子组件像触发回调一样通知父组件。
- 父直接调子:通过 ref 拿到子组件实例,调用其暴露的方法(
defineExpose)。少用,优先用 props/emit。 - 跨层级:provide / inject。祖先组件 provide 数据,后代任意层级 inject 直接拿。
- 全局状态:Pinia。任意两个组件都能共享(后续专门一章)。
9. 实战:可关闭的对话框
把 props 和 emit 组合起来,写一个最常见的对话框组件:
<!-- Dialog.vue -->
<template>
<div v-if="visible" class="dialog">
<h3>{{ title }}</h3>
<slot></slot>
<button @click="close">关闭</button>
</div>
</template>
<script setup>
defineProps({
visible: Boolean,
title: { type: String, required: true },
});
const emit = defineEmits(["close"]);
function close() {
emit("close"); // 通知父组件:用户要关对话框了
}
</script>
<!-- 父组件用法 -->
<template>
<button @click="show = true">打开</button>
<Dialog :visible="show" title="提示" @close="show = false">
<p>你确定要删除吗?</p>
</Dialog>
</template>
<script setup>
import { ref } from "vue";
import Dialog from "./Dialog.vue";
const show = ref(false);
</script>注意这种设计:子组件不直接改 props(visible),而是通过 emit 通知父组件"用户要关了",由父组件真正改 show。这就是单向数据流的优雅之处——数据归属清晰,组件可复用、可测试。
小结
这一章你掌握了 Vue 父子组件通信的核心:Props 传数据(单向)、Emit 发事件、v-model 是 props + emit 的语法糖。理解单向数据流是写出可维护 Vue 应用的关键。下一篇我们看组件从生到死的生命周期。
← 上一篇 Vue 组件基础
下一篇 Vue 生命周期 →