一、前言
props 让数据"往下流",但子组件无权改它。那子组件想改数据怎么办?上报事件,让父组件自己改------这就是 emit(子传父)。它是"单向数据流"的配套通道:数据下行、事件上行,形成闭环。本篇讲透 defineEmits 用法与事件校验,末尾顺手揭开 v-model 的身世。
二、基础用法:defineEmits + $emit 接收
完整闭环三步:子组件声明事件 → 子组件触发事件 → 父组件监听并处理:
vue
<!-- src/components/CountButton.vue(子组件) -->
<template>
<button @click="handleClick">子组件点我 +10</button>
</template>
<script setup>
// 1. 声明要"上报"的事件(像 defineProps 一样显式声明)
const emit = defineEmits(['add-count'])
function handleClick() {
// 2. 触发事件,可携带数据(载荷)
emit('add-count', 10)
}
</script>
vue
<!-- App.vue(父组件) -->
<template>
<p>总数:{{ total }}</p>
<!-- 3. 监听子组件事件,拿到载荷去改自己的数据 -->
<CountButton @add-count="total += $event" />
</template>
<script setup>
import { ref } from 'vue'
import CountButton from './components/CountButton.vue'
const total = ref(0)
</script>
数据流走一遍:点按钮 → 子组件 emit('add-count', 10) → 父组件 total += 10 → props 流回子组件更新视图。数据改动永远发生在父组件,子组件只负责"喊一嗓子"。
三个细节:
- 事件名推荐 kebab-case (如
add-count):事件名不会自动做大小写转换,emit('addCount')配@add-count会失灵------统一 kebab-case 最稳 - 载荷可以有多个:
emit('change', val, index),监听处@change="(val, i) => ..."接收 - 模板里可以直接用
$emit(上面展示型场景),逻辑多就接进方法里
三、defineEmits 的校验写法
和 props 的 validator 类似,事件也能声明"载荷长什么样":
js
const emit = defineEmits({
// 有校验:返回 true 通过,false 控制台告警
'add-count': (num) => typeof num === 'number' && num > 0,
// 无载荷事件:校验写 null,表示"不校验但事件存在"
reset: null
})
// 合法:通过
emit('add-count', 10)
// 非法:控制台警告 "[Vue warn]: Invalid event arguments"
emit('add-count', '十')
事件校验同样是开发期提示,适合多人协作时约束"这个事件该传什么"。TypeScript 版(defineEmits 泛型)在 042 篇细讲。
四、一对组合拳:props 下行 + emit 上行
真实组件几乎都是双向配合,写个完整的"可增减计数器":
vue
<!-- src/components/Stepper.vue -->
<template>
<div class="stepper">
<button @click="emit('change', -1)">-</button>
<span>{{ model }}</span>
<button @click="emit('change', 1)">+</button>
</div>
</template>
<script setup>
defineProps({
model: { type: Number, required: true }
})
const emit = defineEmits(['change'])
</script>
vue
<!-- App.vue -->
<template>
<p>购买数量(最小 1,最大 5):</p>
<!-- 上下限校验逻辑在父组件------数据规则跟着数据走 -->
<Stepper
:model="count"
@change="(d) => count = Math.min(5, Math.max(1, count + d))"
/>
</template>
<script setup>
import { ref } from 'vue'
import Stepper from './components/Stepper.vue'
const count = ref(1)
</script>
注意职责划分:子组件只知道"有人点了我,增量是 ±1",完全不知道上下限存在;规则集中在数据的主人(父组件)身上。业务规则散落到子组件里,是组件库越用越乱的开端。
【截图位置:点击加减、数量被限制在 1-5 的运行效果】
五、v-model 的身世之谜(预告 027)
回想 019 篇:v-model = :value + @input 语法糖。把同样的思路放到自定义组件上:
vue
<template>
<!-- 你写的 -->
<MyInput v-model="msg" />
<!-- Vue 编译器实际生成的 -->
<MyInput :model-value="msg" @update:model-value="(v) => msg = v" />
</template>
看出来了吗?组件的 v-model 就是"props 下行 + emit 上行"的语法糖 :父组件传 modelValue prop,子组件 emit update:modelValue 事件,父组件收到就回写自己的数据。单向数据流一点没破坏------"改数据"的仍然是父组件,只是语法糖把这套样板代码藏起来了。
下一篇 027 就专门讲:怎么让自己的组件也支持 v-model。
六、踩坑记录
- 事件名大小写失灵 :
emit('addCount')搭配@add-count收不到------事件名不自动转换,要么全 kebab,要么两边一模一样 - 声明了却没触发 / 触发了没声明:defineEmits 数组里没写的事件,触发会有警告------新增事件记得同步声明
- 在 emit 回调里改 props :
@change="count = $event"是改父组件自己的数据 ✅;但@change="model = $event"(model 是 props)❌ 又回到改 props 的老坑 - 载荷对象忘了浅拷贝 :
emit('save', form)直接把 reactive 对象递出去,父组件一改子组件跟着变------双向数据流渗透,必要时{ ...form } - 事件命名动词不带宾语 :
emit('change')还行,emit('do')这种含糊事件名三个月后没人知道它干嘛------命名带业务含义(submit-order、remove-item)
七、今日小结
- emit 三步:子声明 defineEmits → 子触发 emit('事件', 载荷) → 父监听 @事件处理
- 事件名统一 kebab-case;校验用对象写法,无载荷写 null
- 标配组件形态:props 下行供数据,emit 上行报事件,规则集中在父组件
- 组件的 v-model = props(modelValue) + emit(update:modelValue) 的语法糖
下篇预告
既然 v-model 的原理就是 props + emit,那自己的组件怎么"享受"这个语法糖?下一篇 027 组件上的 v-model------modelValue、update:modelValue、defineModel 一个不落。