03|Emits 输出事件
第二章:组件模型与通信纵览 / 03。Props 把状态送进子组件,事件把“发生了什么”通知直接父级;事件本身不替父级修改状态。
前置与例子目标
你需要已经理解 props 是只读输入,并会用原生 @submit.prevent。下面用一个搜索框观察完整回程:用户提交、子组件整理载荷、父组件决定是否接纳。
<script setup lang="ts">
import { ref } from 'vue'
const draft = ref('')
const error = ref('')
const emit = defineEmits<{
submit: [query: string]
cancel: []
}>()
function submit() {
const query = draft.value.trim()
if (!query) {
error.value = '请输入关键词'
return
}
error.value = ''
emit('submit', query)
}
function cancel() {
draft.value = ''
error.value = ''
emit('cancel')
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="draft" aria-label="搜索关键词" />
<button type="submit">搜索</button>
<button type="button" @click="cancel">取消</button>
<p v-if="error" role="alert">{{ error }}</p>
</form>
</template><script setup lang="ts">
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'
const acceptedQuery = ref('尚未搜索')
</script>
<template>
<SearchBox
@submit="acceptedQuery = $event"
@cancel="acceptedQuery = '已取消'"
/>
<p>父级状态:{{ acceptedQuery }}</p>
</template>一次提交经过哪些边界
原生 submit
→ SearchBox.submit()
→ 空字符串:留在子组件并显示错误
→ 有效字符串:emit('submit', query)
→ App 的 @submit 监听器
→ App 修改 acceptedQuery
→ 新父级状态触发重新渲染这里有两个名字相同但层级不同的事件:表单的原生 submit 触发子组件函数,子组件再发出自己的 submit 事件。二者不会自动互相转发;代码明确写出中间转换。
defineEmits 声明了什么
类型式声明同时约束事件名和载荷:
const emit = defineEmits<{
submit: [query: string]
cancel: []
}>()因此 emit('submit', 42) 和 emit('close') 都会在类型检查时失败。父模板推荐用 kebab-case 监听多词事件,脚本和模板统一约定后就不要混用难以搜索的别名。
需要开发期运行时检查复杂载荷时,改用对象声明:
const emit = defineEmits({
submit: (payload: { query: string }) => payload.query.length > 0
})校验函数返回 false 会产生开发警告,但不会成为权限或安全边界。同一次 defineEmits 不同时写类型式和运行时式;选择与当前目标匹配的一种。
组件事件不会冒泡
假设结构是 App → SearchPanel → SearchBox。SearchBox 发出的 submit 只有 SearchPanel 能直接监听,App 不会像监听 DOM 冒泡事件那样自动收到。
如果 App 需要知道,SearchPanel 必须显式转发:
<script setup lang="ts">
import SearchBox from './SearchBox.vue'
const emit = defineEmits<{ submit: [query: string] }>()
</script>
<template>
<section>
<SearchBox @submit="emit('submit', $event)" />
</section>
</template>这看似多一行,却保留了每一层组件的公开合同。跨很多层且确实属于稳定上下文的能力,可在学完 provide/inject 后重新评估。
emit同步调用,不代表子prop已经同步更新
可以把一次 emit('change', next) 理解为寻找当前子组件接收的 onChange 监听器,再用next调用它。查找过程不会沿所有祖先递归发送,所以中间层需要转发。若父级监听器同步写自己的ref,这个写入会当场发生;父级重新渲染并把新prop下传,仍由更新队列完成。
因此不要这样连续请求两步,并假定第二次读到新prop:
emit('change', props.value + 1)
emit('change', props.value + 1)若当前value是2,两次都可能发3,而不是3、4。用户两次独立点击之间通常已经完成刷新,所以普通步进器没有这个问题。需要一个事件里增加2时,直接发 props.value + 2;若希望父级根据最新状态累加,把协议定义为携带delta的“增加请求”,由父级累加。事件载荷选“最终值”还是“变化量”会改变并发语义。
事件名应该描述事实
优先 submitted、removed、selection-change 这类调用方能理解的事实;少用 setParentQuery、changeInternalValue 这类泄漏实现的命令。父级可以在收到同一事实后选择更新状态、发请求或忽略它。
练习:有边界的数量步进器
实现 QuantityStepper.vue 与父级 App.vue:
- props:
value: number、min: number、max: number。 - events:
change携带合法的新数量;blocked携带'min' | 'max'。 - 点加减按钮时,子组件计算候选值。候选值在范围内就发
change,越界只发blocked。 - 子组件不得修改
value;父级收到change后更新自己的quantity。 - 从
value=2, min=1, max=3开始,操作“加、加、减”后父级数量应为2,并留下恰好一次max阻止记录。
参考答案:查看第 2 章第 03 题参考答案。
通关自检:你能从一次点击追踪到父级状态更新,并说明为什么祖先组件不会自动收到孙组件的事件。