Skip to content

03|事件绑定:把用户动作接住,别让冒泡四处乱跑 ​

第一章概览 / 03。目标:用清晰的事件处理器改变状态,理解修饰符其实是浏览器事件 API 的声明式快捷键。

运行环境沿用 Vue 3.5.x + Vite + TypeScript。将第一个完整代码块保存为 src/App.vue;用鼠标点击,再只用键盘 Tab、Enter 与 Space 重跑一遍,才能同时看到事件与原生语义。

必会、熟练、深水区 ​

层级要点你会在哪用到
必会@click、方法处理器、内联语句、事件对象按钮、表单、输入与组件事件
熟练.stop、.prevent、.self、.once、键盘与鼠标修饰符菜单、快捷键、嵌套可点击区域
深入捕获阶段、.passive、修饰符顺序、动态事件名、处理器身份滚动性能、复杂交互、事件代理

默认姿势:让模板报事件名,让脚本处理事情 ​

vue
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const likes = ref(0)
const message = ref('')

function like(event: MouseEvent) {
  likes.value += 1
  message.value = `第 ${likes.value} 次点赞已登记;坐标 ${event.clientX}, ${event.clientY}`
}

function saveDraft(event: MouseEvent) {
  message.value = '草稿已保存,服务器还没睡。'
  const button = event.currentTarget as HTMLButtonElement | null
  button?.blur()
}
</script>

<template>
  <!-- 方法处理器:Vue 自动把原生事件作为第一个参数传入。 -->
  <button @click="like">👍 {{ likes }}</button>

  <!-- 内联表达式只适合非常短、意图明显的状态变化。 -->
  <button @click="likes = 0">清零</button>

  <!-- 需要额外参数时,显式传入 $event。 -->
  <button @click="saveDraft($event)">
    保存并收起焦点
  </button>

  <p aria-live="polite">{{ message }}</p>
</template>

内联表达式不是原罪;超过一行、包含分支、异步、复用需求或领域动作时,给它起一个动词名字。submitOrder 比模板里塞一串 if 更像人话,也更好测试。

修饰符速查:它们翻译成什么浏览器动作 ​

写法等价意图适用场景注意
@submit.preventevent.preventDefault()SPA 表单提交仍要做输入校验与提交防重
@click.stop阻止向祖先冒泡卡片内的删除按钮先问能否通过结构避免事件冲突
@click.self只有事件目标就是当前元素才处理点击遮罩关闭弹窗点击子元素不会关闭
@click.capture在捕获阶段监听罕见的拦截/分析需求改变事件顺序,应谨慎
@click.once只触发一次一次性确认不是防重复提交的完整方案
@keyup.enter仅 Enter 键触发快捷输入注意 IME 组合输入场景
@keyup.ctrl.enter.exactCtrl+Enter,且没有其他系统键编辑器发送.exact 防止 Alt/Shift 混入
@scroll.passive告诉浏览器不会取消滚动只读滚动监听不能和 .prevent 同用

一个不打架的卡片交互 ​

vue
<script setup lang="ts">
const emit = defineEmits<{ open: []; remove: [] }>()
</script>

<template>
  <article class="task-card" @click="emit('open')">
    <h2>可点击的任务卡</h2>

    <!-- 键盘用户通过原生按钮执行同一动作;两个按钮都不冒泡到卡片。 -->
    <button type="button" @click.stop="emit('open')">预览</button>
    <button type="button" aria-label="删除任务" @click.stop="emit('remove')">删除</button>
  </article>
</template>

这里的顺序不是装饰:@click.prevent.self 会先 preventDefault() 再判断 .self;修饰符会按书写顺序生成行为。能不用复杂修饰符组合就不用,读者的脑容量也有带宽上限。

键盘与指针:同一个动作,别写两套半截逻辑 ​

可点击的东西优先使用 <button> 或链接:它们天然能获得焦点,并为 Enter / Space 提供预期的键盘行为。把普通 div 加 @click 后再补 tabindex、role 和一串 @keyup,通常是在手工重造一个容易漏零件的按钮。

vue
<script setup lang="ts">
const emit = defineEmits<{ archive: [id: string] }>()
const taskId = 'task-42'

function archive() {
  emit('archive', taskId)
}
</script>

<template>
  <!-- 最少的代码,同时覆盖鼠标与键盘的原生激活行为。 -->
  <button type="button" @click="archive">归档任务</button>

  <!-- 只有整个卡片确实是一个可激活动作时,才承担键盘实现成本。 -->
  <article
    role="button"
    tabindex="0"
    @click="archive"
    @keydown.enter.prevent="archive"
    @keydown.space.prevent="archive"
  >
    自定义交互卡片
  </article>
</template>

第二种写法不是推荐捷径,而是展示它多了多少责任:键盘触发、焦点样式、禁用状态与读屏器描述都需要你自己补齐。能让原生语义完成的事情,就别把无障碍当作事后补丁。

动态事件与组件事件 ​

原生元素上 @click 监听浏览器事件;组件上 @save 监听子组件 emit('save') 的自定义事件。事件不会像 DOM 冒泡那样穿透组件祖孙三代,跨层通信请使用 props / emits、provide/inject 或状态方案,别对着爷爷组件喊话。

vue
<script setup lang="ts">
import { ref } from 'vue'

const eventName = ref<'mouseenter' | 'focus'>('mouseenter')
const audit = ref<string[]>([])

function record(event: Event) {
  audit.value.push(`${event.type}:按钮被关注了`)
}
</script>

<template>
  <button @[eventName]="record">悬停或聚焦我</button>
  <ChildEditor @save="(draft) => audit.push(`保存:${draft.title}`)" />
</template>

动态事件名是工具,不是日常语法糖。若事件类别由配置决定,它很合适;若代码读者需要先算半天才知道会监听什么,请换成显式分支。

事件处理器要管理动作,不要兼职保存整套页面真相 ​

一个好的处理器通常做三件事:验证这次动作是否允许、更新明确的状态、在异步结束后收束状态。它不需要直接拼 DOM,也不应该把所有视觉分支塞进回调。下面的“保存草稿”把重复点击、失败信息和结束状态放在同一条可读的动作路径里。

vue
<script setup lang="ts">
import { ref } from 'vue'

const draft = ref('')
const saving = ref(false)
const saveError = ref('')

async function save() {
  const text = draft.value.trim()
  if (!text || saving.value) return

  saving.value = true
  saveError.value = ''
  try {
    await saveDraftToServer(text)
  } catch {
    saveError.value = '保存失败,请检查网络后重试。'
  } finally {
    saving.value = false
  }
}
</script>

<template>
  <form @submit.prevent="save">
    <textarea v-model="draft" :disabled="saving"></textarea>
    <button :disabled="saving">{{ saving ? '保存中…' : '保存' }}</button>
    <p v-if="saveError" role="alert">{{ saveError }}</p>
  </form>
</template>

saveDraftToServer 是边界外的异步依赖;组件只负责把用户动作映射为可观察的状态。这种分工也让测试更简单:分别验证空文本早退、进行中锁定、成功与失败后的状态即可。

深水区:事件、状态、渲染的先后 ​

一次点击通常是:浏览器派发事件 → 处理器修改响应式状态 → Vue 将组件更新排队 → 当前同步调用栈结束后批量刷新 DOM。也就是说,在处理器中刚改完状态就量 DOM,读到的可能仍是旧结构;需要更新后的 DOM 时,下一站响应式会讲 nextTick 与刷新时机。此处先记住:事件处理器负责改变状态,DOM 更新不是每一行赋值后立刻发生。

异步处理器还多一个风险:用户可能连点两次,较晚返回的旧请求反而覆盖较新的状态。不是每个按钮都要上复杂并发控制,但保存、搜索、切换账户这类动作至少要明确策略:禁用重复操作、忽略过期结果,或用请求 ID / 取消信号保证“最后一次意图”获胜。事件名描述动作,状态名描述阶段;不要用一个 loading 布尔值同时猜“正在保存、保存成功还是保存失败”。

排错路线:一次点击到底去哪了 ​

  1. 先看浏览器事件。 元素是否真的可点击、被遮挡、禁用,或因默认行为离开了页面?先用原生语义和最小处理器验证。
  2. 再看传播路径。 事件目标、当前目标和父级监听器分别是谁?只有发生真实的嵌套冲突时才加 .stop 或 .self。
  3. 再看状态写入。 处理器是否修改了正确的 ref / reactive 字段?异步分支是否有早退、重复提交或过期结果覆盖?
  4. 最后看 DOM 时机。 若状态已经更新但新节点还不存在,不要用 setTimeout 猜时间;让下一步的 nextTick() 等待 Vue 完成本轮更新。

翻车博物馆 ​

症状常见误解修复
点击删除却打开卡片以为子元素事件不会冒泡用 .stop,或重新设计点击区域
表单提交后整页刷新忘了默认浏览器提交行为<form @submit.prevent="submit">
滚动监听仍卡在处理器里做重计算,或错误混用 .passive.prevent.passive 只用于绝不取消的监听;节流/可见性策略另行处理
Ctrl+Enter 也被 Shift+Ctrl+Enter 触发没限定系统键组合使用 .exact

练习:不依赖表单指令的快捷提交 ​

在 src/App.vue 创建一个textarea、一个提交按钮和一个 <p> 结果。输入值由 :value="draft" 显示,由 @input 从事件目标的 .value 写回draft;暂时不使用下一篇才教的 v-model。初值为空。

  • 普通Enter换行,精确Ctrl+Enter与按钮执行同一个submit函数。
  • trim后空白不提交;有效输入保存为 lastSubmitted 并清空draft。
  • 同时按Ctrl+Shift+Enter不能提交;通过文字输出确认结果,不需要列表或条件指令。

查看第03题基础答案。

完成第04~06篇后的回访:卡片快捷编辑器 ​

下面的变体使用表单 v-model、列表 v-for 与条件状态;先完成表单、条件、列表后回访,不是本页首次阅读的通关条件。

创建 src/App.vue,初始 draft 为空、结果列表为空、预览项为 null。每次有效提交生成单调递增的本地 id;不要用同一毫秒可能重复的时间戳冒充唯一身份。

  • 普通 Enter 只在 textarea 换行;精确的 Ctrl+Enter 提交,Ctrl+Shift+Enter 不提交。
  • <form> 阻止原生刷新;空字符串与全空白字符串都不新增结果。
  • 点击卡片空白区域会预览;卡片内另有原生“预览”按钮供键盘操作,删除按钮删除该项且不触发预览。
  • 写一句注释说明为什么 @scroll.passive.prevent 是互相冲突的浏览器承诺。

参考答案:查看第一章练习答案中的第 03 题。

回访验收清单 ​

  • 普通 Enter 只换行;只有 Ctrl+Enter 提交,Ctrl+Shift+Enter 不会因组合键不精确而误触发。
  • 连续提交两条相同文字会得到两个不同 id;提交空白文本不新增内容,表单也不整页刷新。
  • 点击卡片空白区域会预览;Tab 到原生“预览”按钮后可用 Enter/Space 执行同一动作,删除按钮不会打开预览。

通关自检:你能把每个修饰符翻译成浏览器事件行为,并优先选择可读的结构而非修饰符俄罗斯套娃。

用心记录每一步成长