03|事件绑定:把用户动作接住,别让冒泡四处乱跑
第一章概览 / 03。目标:用清晰的事件处理器改变状态,理解修饰符其实是浏览器事件 API 的声明式快捷键。
运行环境沿用 Vue 3.5.x + Vite + TypeScript。将第一个完整代码块保存为 src/App.vue;用鼠标点击,再只用键盘 Tab、Enter 与 Space 重跑一遍,才能同时看到事件与原生语义。
必会、熟练、深水区
| 层级 | 要点 | 你会在哪用到 |
|---|---|---|
| 必会 | @click、方法处理器、内联语句、事件对象 | 按钮、表单、输入与组件事件 |
| 熟练 | .stop、.prevent、.self、.once、键盘与鼠标修饰符 | 菜单、快捷键、嵌套可点击区域 |
| 深入 | 捕获阶段、.passive、修饰符顺序、动态事件名、处理器身份 | 滚动性能、复杂交互、事件代理 |
默认姿势:让模板报事件名,让脚本处理事情
<!-- 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.prevent | event.preventDefault() | SPA 表单提交 | 仍要做输入校验与提交防重 |
@click.stop | 阻止向祖先冒泡 | 卡片内的删除按钮 | 先问能否通过结构避免事件冲突 |
@click.self | 只有事件目标就是当前元素才处理 | 点击遮罩关闭弹窗 | 点击子元素不会关闭 |
@click.capture | 在捕获阶段监听 | 罕见的拦截/分析需求 | 改变事件顺序,应谨慎 |
@click.once | 只触发一次 | 一次性确认 | 不是防重复提交的完整方案 |
@keyup.enter | 仅 Enter 键触发 | 快捷输入 | 注意 IME 组合输入场景 |
@keyup.ctrl.enter.exact | Ctrl+Enter,且没有其他系统键 | 编辑器发送 | .exact 防止 Alt/Shift 混入 |
@scroll.passive | 告诉浏览器不会取消滚动 | 只读滚动监听 | 不能和 .prevent 同用 |
一个不打架的卡片交互
<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,通常是在手工重造一个容易漏零件的按钮。
<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 或状态方案,别对着爷爷组件喊话。
<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,也不应该把所有视觉分支塞进回调。下面的“保存草稿”把重复点击、失败信息和结束状态放在同一条可读的动作路径里。
<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 布尔值同时猜“正在保存、保存成功还是保存失败”。
排错路线:一次点击到底去哪了
- 先看浏览器事件。 元素是否真的可点击、被遮挡、禁用,或因默认行为离开了页面?先用原生语义和最小处理器验证。
- 再看传播路径。 事件目标、当前目标和父级监听器分别是谁?只有发生真实的嵌套冲突时才加
.stop或.self。 - 再看状态写入。 处理器是否修改了正确的 ref / reactive 字段?异步分支是否有早退、重复提交或过期结果覆盖?
- 最后看 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不能提交;通过文字输出确认结果,不需要列表或条件指令。
完成第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 执行同一动作,删除按钮不会打开预览。
通关自检:你能把每个修饰符翻译成浏览器事件行为,并优先选择可读的结构而非修饰符俄罗斯套娃。