emits:把事件交给父组件
组件基础:props、emits 与事件 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
props 向下、事件向上,这是 Vue 组件通信的基本方向。这一节掌握 defineEmits 的声明与触发、事件命名规范、v-model 在自定义组件上的展开形式,以及「父组件决定怎么改」的设计原则。
问题场景
子组件里有一个删除按钮,点击后要通知父组件从列表里移除该项。你尝试把 todos 数组作为 prop 传进来,子组件直接 splice——能跑,但父组件的状态被偷偷改了,而且这个组件再也无法用于「只展示不删除」的场景。正确做法是:子组件只负责「发出删除意图」,父组件决定删不删、怎么删。
核心解释
defineEmits 声明组件会触发的事件,返回一个 emit 函数:
<!-- components/TodoItem.vue -->
<script setup>
const props = defineProps({
todo: { type: Object, required: true },
});
const emit = defineEmits(["toggle", "remove"]);
function onToggle() {
emit("toggle", props.todo.id); // 第一个参数是事件名,后面是载荷
}
function onRemove() {
emit("remove", props.todo.id);
}
</script>
<template>
<li>
<input type="checkbox" :checked="props.todo.done" @change="onToggle" />
<span :class="{ done: props.todo.done }">{{ props.todo.title }}</span>
<button @click="onRemove">删除</button>
</li>
</template>
父组件用 @事件名 监听:
<script setup>
import { ref } from "vue";
import TodoItem from "./components/TodoItem.vue";
const todos = ref([
{ id: 1, title: "读 V04", done: false },
{ id: 2, title: "写练习", done: true },
]);
function toggle(id) {
const todo = todos.value.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
}
function remove(id) {
todos.value = todos.value.filter((t) => t.id !== id);
}
</script>
<template>
<ul>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@toggle="toggle"
@remove="remove"
/>
</ul>
</template>
命名规范:事件名在模板里用 kebab-case(@item-removed),声明和 emit 用 camelCase(itemRemoved)——和 props 一样由 Vue 自动转换。TypeScript 里用类型语法声明:
TypeScript 里用调用签名声明,参数类型会被校验:defineEmits<{ (e: "toggle", id: number): void }>()。
v-model 的展开:自定义组件上的 v-model="x" 等价于 :model-value="x" + @update:model-value="x = $event"。所以子组件要支持 v-model,只需声明 modelValue prop 并 emit update:modelValue:
<!-- components/StarRating.vue -->
<script setup>
const props = defineProps({ modelValue: { type: Number, default: 0 } });
const emit = defineEmits(["update:modelValue"]);
function setStar(n) {
emit("update:modelValue", n);
}
</script>
<template>
<button
v-for="n in 5"
:key="n"
type="button"
:class="{ active: n <= props.modelValue }"
@click="setStar(n)"
>
★
</button>
</template>
父组件写 <StarRating v-model="score" /> 即可双向绑定。多个 v-model 可以带参数:v-model:title / v-model:count,子组件对应 title prop 和 update:title 事件。
设计原则:子组件不关心父组件怎么处理,只发出语义清晰的事件(remove 而不是 click-delete-button);载荷只带父组件需要的信息(id 比整个对象更稳定)。
示例拆解
一个可复用的计数器组件,同时演示事件和 v-model 两种形态:
<!-- components/Stepper.vue -->
<script setup>
const props = defineProps({
modelValue: { type: Number, required: true },
min: { type: Number, default: 0 },
max: { type: Number, default: 99 },
});
const emit = defineEmits(["update:modelValue", "limit"]);
function step(delta) {
const next = props.modelValue + delta;
if (next < props.min || next > props.max) {
emit("limit", next < props.min ? "min" : "max");
return;
}
emit("update:modelValue", next);
}
</script>
<template>
<div class="stepper">
<button @click="step(-1)" aria-label="减少">−</button>
<span>{{ props.modelValue }}</span>
<button @click="step(1)" aria-label="增加">+</button>
</div>
</template>
父组件:
父组件写 <Stepper v-model="qty" :min="1" :max="5" @limit="onLimit" />,在 onLimit(which) 里按事件载荷显示「最多买 5 件 / 至少买 1 件」。注意 emit("update:modelValue", next) 之后父组件的 qty 变了,props 回流,子组件渲染新值——数据流始终是「子发事件、父改数据、数据下行」。
动手任务
- 给
Stepper加一个reset事件:长按数字 1 秒触发(提示:@mousedown+setTimeout+@mouseup清理),父组件收到后把数量重置为 min。 - 把
TodoItem的toggle事件改成带完整 todo 对象作为载荷,比较两种载荷在父组件里的写法差异,说出你更倾向哪种以及为什么。 - 用
v-model:title实现一个可编辑标题的EditableTitle组件。
常见错误
- emit 事件名大小写不一致:emit
update:modelValue但父组件监听@update:modelvalue,永远收不到。模板里用 kebab-case 或完整事件名都要与声明严格一致。 - 在子组件里改 props 数组/对象:绕过事件直接改,父组件状态失控。一律 emit。
- v-model 组件忘记 emit
update:modelValue:输入框能输入但父组件的值不变,表现为「输入后又弹回去」。 - 用事件传整个响应式对象又指望父组件拿到新值:emit 传的是当前引用,父组件若保存旧引用会看到旧数据。传 id 或不可变快照更稳。
检查与复盘
- 能写出 defineEmits 的数组语法和类型语法
- 能解释 v-model 在自定义组件上的等价展开
- 能说清「子组件只发意图、父组件决定行为」的设计理由
参考资料
- Vue 官方文档「组件事件」:声明、校验、v-model 展开
- Vue 官方文档「Props」:单向数据流
读完这一课了?
标记完成后,工作台会安排接下来的学习。