Skip to content

03|Emits 输出事件 ​

第二章:组件模型与通信纵览 / 03。Props 把状态送进子组件,事件把“发生了什么”通知直接父级;事件本身不替父级修改状态。

前置与例子目标 ​

你需要已经理解 props 是只读输入,并会用原生 @submit.prevent。下面用一个搜索框观察完整回程:用户提交、子组件整理载荷、父组件决定是否接纳。

vue
<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>
vue
<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>

一次提交经过哪些边界 ​

text
原生 submit
  → SearchBox.submit()
  → 空字符串:留在子组件并显示错误
  → 有效字符串:emit('submit', query)
  → App 的 @submit 监听器
  → App 修改 acceptedQuery
  → 新父级状态触发重新渲染

这里有两个名字相同但层级不同的事件:表单的原生 submit 触发子组件函数,子组件再发出自己的 submit 事件。二者不会自动互相转发;代码明确写出中间转换。

defineEmits 声明了什么 ​

类型式声明同时约束事件名和载荷:

ts
const emit = defineEmits<{
  submit: [query: string]
  cancel: []
}>()

因此 emit('submit', 42) 和 emit('close') 都会在类型检查时失败。父模板推荐用 kebab-case 监听多词事件,脚本和模板统一约定后就不要混用难以搜索的别名。

需要开发期运行时检查复杂载荷时,改用对象声明:

ts
const emit = defineEmits({
  submit: (payload: { query: string }) => payload.query.length > 0
})

校验函数返回 false 会产生开发警告,但不会成为权限或安全边界。同一次 defineEmits 不同时写类型式和运行时式;选择与当前目标匹配的一种。

组件事件不会冒泡 ​

假设结构是 App → SearchPanel → SearchBox。SearchBox 发出的 submit 只有 SearchPanel 能直接监听,App 不会像监听 DOM 冒泡事件那样自动收到。

如果 App 需要知道,SearchPanel 必须显式转发:

vue
<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:

ts
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 题参考答案。

通关自检:你能从一次点击追踪到父级状态更新,并说明为什么祖先组件不会自动收到孙组件的事件。

用心记录每一步成长