缺陷单上并排写着两条现象:点击任务行里的“删除”,删除逻辑执行了,整行的选中逻辑也跟着执行;在新增任务的表单里按 Enter,处理函数刚改完状态,页面却刷新了,刚才的变化转眼消失。
这不是第 4 章的 v-for、key 或响应式更新出了错。前一个现象来自点击事件沿 DOM 路径继续向外传播,后一个现象来自浏览器仍在执行表单提交的默认行为。状态能不能正确渲染,和用户动作怎样抵达处理函数,是数据流中相邻但不同的两段;现在要补上的正是后一段。
第 2 章里我们已经用过 @click,也知道它能把按钮动作接到组件状态上:
<button @click="count++">加 1</button>按钮被点了一下,count 增加,页面跟着更新。我们没有查询 DOM,也没有手动调用 addEventListener(),甚至没有自己把监听器移除。Vue 把这段连接写得很短,但浏览器里的事件并没有因此消失。点击从哪里开始、会经过哪些元素、默认行为是否还会执行,仍然遵循原生 DOM 事件模型。
要把这两条缺陷解释清楚,需要把第 2 章已经会用的 @click 往浏览器内部再追一层:事件怎样经历捕获、目标与冒泡,浏览器默认行为为什么能在处理函数之后继续发生,Vue 的修饰符又分别改变了哪一步。任务看板接下来遇到的点击、提交、按键与组件自定义事件,都可以放回这条链路检查;@click.stop.prevent 也不再只是一个消除现象的写法,而是对传播路径和默认行为的两处明确干预。
模板里的事件监听不是写在 HTML 字符串里的魔法属性。Vue 会编译模板,并在组件挂载、更新和卸载的过程中管理对应的监听器。浏览器最终接收到的仍然是事件监听逻辑,所以理解原生事件模型会直接提高你排查 Vue 交互问题的速度。

如果用原生 JavaScript 给按钮绑定事件,我们通常要先找到元素,再注册回调:
const button = document.querySelector('.save-button')
function saveDraft(event) {
console.log('保存草稿', event)
}
button.addEventListener('click', saveDraft)当这段逻辑属于一个可反复创建和销毁的界面模块时,还要在恰当时机移除同一个函数引用:
button.removeEventListener('click', saveDraft)Vue 模板把“找到这个元素”和“管理监听器生命周期”接了过去。完整写法是 v-on:事件名,@ 是 v-on: 的简写。下面两个按钮的含义完全相同:
<template>
<button v-on:click="saveDraft">保存草稿</button>
<button @click="saveDraft">保存草稿</button>
</template>
<script setup>
function saveDraft() {
console.log('草稿已保存')
}
</script>实际项目几乎都使用 @click,但知道它来自 v-on:click 很有用。以后看到 v-on:[eventName]="handler" 这样的动态参数,就不会把它当成另一套语法。
事件名也不限于 click。输入框可以监听 input、change、focus、blur、keydown 和 keyup;表单最常监听 submit;指针设备还有 pointerdown、pointermove 等事件。Vue 没有重新发明这些名字,模板监听的是浏览器提供的原生事件。
下面是一个可以直接放进 Vue 工程运行的单文件组件:
<!-- ClickCounter.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
const lastAction = ref('还没有操作')
function increase() {
count.value++
lastAction.value = `增加到 ${count.value}`
}
function reset() {
count.value
页面初始显示“当前数量:0”和“还没有操作”。点击“增加”后,两个响应式数据同时变化;点击“归零”后,数量回到 0。这里的方法只描述业务动作,没有 querySelector,也没有直接修改某个段落的 textContent。Vue 会根据状态重新计算需要更新的 DOM。
模板事件监听最舒服的分工是:模板说明“什么动作触发什么意图”,处理函数说明“这个意图怎样改变状态”。监听器仍然建立在浏览器事件之上,但组件代码不必自己维护元素查询和销毁时机。
Vue 允许把简单表达式直接写进事件绑定,也允许绑定一个函数。两种写法都常见,关键是让模板保持可读。
下面这些都属于内联处理器:
<template>
<button @click="count++">加 1</button>
<button @click="count += 5">加 5</button>
<button @click="message = '已读'">标记已读</button>
</template>表达式很短,而且结果能从模板直接看出来,这时内联写法没有问题。它不需要在 <script setup> 中额外声明三个只用一次的小函数。
不过,内联并不等于可以把一整段流程塞进引号。只要出现校验、分支、多次赋值、异步请求或错误处理,就应该把逻辑移到函数里。模板如果变成 @click="loading = true; error = ''; submit(); closePanel()",读者已经很难判断动作顺序,也不容易单独测试它。
直接写函数名时,Vue 会把原生事件对象作为第一个参数传给它:
<!-- SaveButton.vue -->
<script setup>
import { ref } from 'vue'
const status = ref('尚未保存')
function save(event) {
status.value = `已通过 ${event.type} 保存`
}
</script>
<template>
<button type="button"
这里的 @click="save" 是方法处理器。save 是合法的标识符,Vue 会把它当成函数引用。@click="save()" 则属于内联处理器,因为模板在执行一段调用表达式。两者看起来只差括号,但参数行为不同:前者自动拿到事件对象,后者不会凭空把事件传进去。
你还会遇到属性路径,例如 @click="actions.save"。只要表达式是可识别的标识符或属性访问路径,它仍然可以作为方法处理器。

列表中的按钮通常既要知道当前数据,也要知道原生事件。这时可以显式传入 Vue 在模板中提供的 $event:
<!-- MessageList.vue -->
<script setup>
import { ref } from 'vue'
const selectedId = ref(null)
const messages = [
{ id: 101, title: '课程更新提醒' },
{ id: 102, title: '作业批改完成' }
]
function selectMessage(id, event) {
selectedId.value =
点击第一项后,页面显示“已选择编号:101”;点击第二项后显示 102。message.id 来自当前循环项,$event 是这次点击对应的原生 MouseEvent。
也可以写成内联箭头函数:
<button @click="(event) => selectMessage(message.id, event)">
{{ message.title }}
</button>两种写法的能力相同。$event 更短,箭头函数的参数来源更显眼。团队选一种保持一致即可。
不要写 @click="selectMessage(message.id, event)" 并期待 event 自动存在。模板里的特殊变量名是 $event。如果直接绑定函数名,事件会自动成为第一个参数;一旦自己写调用表达式,就要显式传 $event。
大多数事件名都应该直接写在模板里,因为静态名字最容易读懂。不过,封装通用组件时,事件类型有时确实来自一个变量。v-on 支持动态参数:
<!-- PressSurface.vue -->
<script setup>
import { computed, ref } from 'vue'
const usePointer = ref(true)
const count = ref(0)
const startEvent = computed(() => {
return usePointer.value ? 'pointerdown' : 'mousedown'
})
function handleStart(
勾选时监听 pointerdown,取消勾选后监听 mousedown。当 startEvent 改变,Vue 会更新元素上的监听关系,不需要我们手动先移除旧事件再添加新事件。
动态参数解决的是“事件名真的会变化”的问题,不应为了显得灵活而到处使用。@click="save" 一眼就能看懂,v-on:[config.action.event]="handlers[config.action.name]" 却要求读者跳到多个对象里才能知道按钮做什么。业务页面优先使用静态事件名,组件基础设施再考虑动态形式。
Vue 还允许一次绑定一个监听器对象:
<script setup>
function handleFocus() {
console.log('获得焦点')
}
function handleBlur() {
console.log('失去焦点')
}
const inputListeners = {
focus: handleFocus,
blur: handleBlur
}
</script>
<template>
<input v-on
这种写法适合把一组原生监听器作为整体传递,但修饰符不能直接附着在对象键上。需要 .prevent、.stop 或按键过滤时,分别写在模板上通常更清楚。
模板上的 @click 绑定到组件渲染出来的元素。元素随组件卸载时,Vue 会处理对应的监听器。可有些交互不属于某个模板元素,比如窗口尺寸变化、整个文档的 Escape、用户是否切回当前标签页。这时仍要使用原生 API,并自己处理生命周期:
<!-- GlobalEscape.vue -->
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
const open = ref(false)
function handleWindowKeydown(event) {
if (event.key === 'Escape') {
open.value = false
}
}
onMounted(() => {
window.addEventListener('keydown'
添加和移除时必须使用同一个函数引用。下面这种写法无法正确移除,因为两个箭头函数是两个不同对象:
window.addEventListener('keydown', () => close())
window.removeEventListener('keydown', () => close())如果忘记清理,全局监听器可能在组件消失后继续运行。组件多次挂载还会累积重复监听,表现为按一次键却执行多次。模板监听适合元素自身的交互;全局对象上的事件才需要这套显式挂载与卸载。把边界分清楚,既不会过度使用原生 API,也不会误以为 Vue 能自动清理所有手写监听器。
同一个控件可能连续产生多种事件。点击按钮时,常见顺序里会出现指针按下、鼠标按下、焦点变化、指针抬起、鼠标抬起,最后才是 click。键盘激活原生按钮也能产生 click。因此,“用户确认执行一个按钮动作”通常监听 click,而“手指刚按下就开始拖动”才监听 pointerdown。
如果画板工具在用户按下时就要开始记录轨迹,可以监听指针事件:
<!-- DrawingPad.vue -->
<script setup>
import { ref } from 'vue'
const drawing = ref(false)
const status = ref('等待落笔')
function startDrawing(event) {
drawing.value = true
status.value = `开始绘制,设备:${event.pointerType}`
event.currentTarget.setPointerCapture
这里不能只监听 click,因为点击要等按下和抬起完成,过程中没有连续坐标。还要处理 pointercancel:系统手势、设备切换等情况可能取消当前指针序列,如果只等 pointerup,drawing 可能一直停在 true。
普通“保存”“删除”“展开”按钮则优先使用 click。它表达控件已经被激活,也能继承原生按钮的键盘行为。若把这些动作提前放在 mousedown,用户按下后把指针移出按钮再松开,本想取消点击,逻辑却已经执行了。
文本框输入时,input 会随着值变化连续触发;change 往往在值提交或控件失去焦点后触发。下面的组件把两个时机并排显示:
<!-- InputTiming.vue -->
<script setup>
import { ref } from 'vue'
const liveValue = ref('')
const committedValue = ref('')
function handleInput(event) {
liveValue.value = event.currentTarget.value
}
function handleChange(event) {
committedValue.value =
每输入一个字符,“实时内容”都会更新;“确认后的内容”通常在离开输入框后更新。实时字数、搜索建议适合 input,离开控件后再做一次较重校验可以考虑 change 或 blur。下一章的 v-model 会替常见表单控件处理值同步,但事件时机仍然是理解 .lazy 等行为的基础。
表单可以通过点击提交按钮触发,也可以在输入框里按 Enter 触发。如果只给按钮写 @click="submit",键盘提交路径可能被漏掉。把监听放在 <form @submit.prevent="submit"> 上,才能接住表单的统一提交语义。
<script setup>
function submitOrder() {
console.log('统一处理订单提交')
}
</script>
<template>
<form @submit.prevent="submitOrder">
<input name="coupon" placeholder="优惠码" />
<button type="submit">确认订单</button>
这也解释了为什么修饰符不能弥补事件类型选错:给按钮的 click 加再多 .prevent,仍然不等于监听整个表单的 submit。
focus 和 blur 直接描述某个元素获得或失去焦点,它们不按普通方式冒泡。若每个输入框都需要自己的提示,直接绑定最清楚:
<input @focus="showHint" @blur="hideHint" />如果一个大表单希望统一知道“焦点进入了哪一项”,可以在容器监听会冒泡的 focusin 与 focusout。不要在 @focus 没有冒泡时不断尝试 .stop 或 .self;问题不在修饰符,而在事件本身的传播特性。
鼠标从父元素移动到它的子元素时,mouseover 可能再次触发,因为目标元素变化并且事件会冒泡;mouseenter 更像“指针真正进入这个元素边界”,不会因穿过内部子元素而按同样方式反复冒泡。做菜单悬停时选错事件,常见表现就是面板不停开合。
触屏没有稳定的悬停概念,所以核心功能不能只放在 mouseenter。悬停可以提供预览,点击或聚焦仍要能完成主要操作。先从用户意图选择事件,再讨论 .stop、.prevent 等传播控制,代码会简单很多。
很多“点按钮却拿到图标”“点子元素导致父级逻辑执行”的问题,都能通过事件对象解释。
event.target 是事件最初指向的元素。点击按钮里的 <span>,target 可能就是这个 <span>。event.currentTarget 是当前正在运行监听器的元素。监听器绑在 <button> 上,它就是这个按钮。event.type 是事件名,例如 click、keydown。event.defaultPrevented 表示默认行为是否已经被取消。event.eventPhase 可以帮助判断当前处在捕获、目标还是冒泡阶段。看一个带内层图标的按钮:
<!-- TargetDemo.vue -->
<script setup>
import { ref } from 'vue'
const report = ref('请点击按钮文字或星形图标')
function inspect(event) {
report.value = [
`target: ${event.target.tagName}`,
`currentTarget: ${event.currentTarget.tagName}`
].join
点文字附近时,target 可能是 BUTTON;准确点中星形 <span> 时,target 是 SPAN。无论点哪里,currentTarget 都是 BUTTON。因此,想读取监听器所在按钮的 dataset 时,通常应该用 currentTarget.dataset,不要依赖 target.parentElement 猜层级。
<script setup> 顶层声明的函数没有 Options API 中那种组件实例 this。这不是缺失,而是 Composition API 的常态:状态通过词法作用域直接可见,DOM 信息通过事件参数或模板引用取得。把 function inspect(event) 写清楚,比在回调里追踪 this 指向更稳定。
还有一个很隐蔽的调试点:currentTarget 只在事件监听器同步执行期间有意义。如果处理函数先 await,之后再读它,值可能已经变成 null。需要跨越异步边界时,先保存要用的信息:
<script setup>
async function upload(event) {
const button = event.currentTarget
const action = button.dataset.action
await Promise.resolve()
console.log('操作类型:', action)
}
</script>
<template>
<button data-action="upload-cover"
假设页面结构是“外层卡片 → 工具栏 → 删除按钮”。用户点击删除按钮时,浏览器会确定一条从页面外层走向目标元素的路径。事件传播可以分成三个阶段:
addEventListener('click', handler) 默认把监听器放在冒泡路径上;传 { capture: true } 才会在捕获路径执行。Vue 的 @click 与 @click.capture 对应这两种注册方式。
下面这个组件把执行顺序直接显示在页面上:
<!-- EventRoute.vue -->
<script setup>
import { ref } from 'vue'
const logs = ref([])
function record(label, event) {
logs.value.push(`${label}|阶段 ${event.eventPhase}`)
}
function clearLogs() {
logs.value = []
}
点击“删除”后,记录依次是:卡片捕获、工具栏捕获、按钮目标、工具栏冒泡、卡片冒泡。数字阶段通常会显示捕获为 1、目标为 2、冒泡为 3。目标元素上的捕获监听器与普通监听器都属于目标阶段,但捕获形式注册的监听器会先执行。
并非所有事件都会冒泡。比如 focus 和 blur 本身不冒泡;需要委托焦点变化时,可以考虑会冒泡的 focusin 和 focusout。调试前先看 event.bubbles,能避免把“事件根本不冒泡”误判成 Vue 没有绑定成功。
原生的 event.stopPropagation() 会阻止事件继续走向路径上的其他元素。它不会阻止链接跳转、表单提交、复选框切换等默认行为,也不会自动阻止同一元素上其他监听器执行。
Vue 的 .stop 就是在合适位置调用 stopPropagation():
<article @click="openArticle">
<h3>事件传播的三个阶段</h3>
<button type="button" @click.stop="removeBookmark">取消收藏</button>
</article>点文章空白处会执行 openArticle;点“取消收藏”只执行 removeBookmark,不会继续冒泡到 <article>。如果还要阻止同一元素上其他原生监听器,需要理解 stopImmediatePropagation(),但 Vue 没有为它提供专门修饰符,可以在处理函数中显式调用。
原生的 event.preventDefault() 取消可取消事件的默认行为。例如,点击链接通常会导航,提交表单通常会发起提交与页面跳转。它不会停止事件传播,所以父元素的监听器仍可能执行。
Vue 的 .prevent 对应这个动作:
<!-- SearchForm.vue -->
<script setup>
import { ref } from 'vue'
const keyword = ref('')
const result = ref('等待搜索')
function search() {
result.value = keyword.value.trim()
? `正在搜索:${keyword.value.trim()}`
:
按回车或点击“搜索”都会执行 search,页面不会因为原生表单提交而刷新。处理函数只关心搜索逻辑,阻止默认提交的意图留在模板上。
.stop 与 .prevent 解决的是两件事。链接上写 .stop 仍可能跳转;父容器中的表单写 .prevent 仍可能把提交事件冒泡给祖先。看到交互异常时,先问“我想停的是传播,还是默认行为”。
冒泡不只是一个需要阻止的麻烦,它也可以减少重复逻辑。事件委托的思路是:不在每个子元素上分别监听,而是在共同父元素上监听一次,再通过 event.target 找到用户实际操作的项。
原生 JavaScript 中,一个不断增加的按钮列表很适合委托:父列表早已存在,后来插入的新按钮产生的点击也会冒泡到它。Vue 使用 v-for 时直接写 @click="select(item.id)" 已经足够清楚,通常不需要为了少几个监听器提前优化。但在表格、菜单、画布工具区等动作密集的区域,理解委托仍很有价值,也能解释第三方组件为何会在容器上监听。
下面的列表把操作类型和任务编号写在 data-* 属性中,由父元素统一处理:
<!-- DelegatedTaskList.vue -->
<script setup>
import { ref } from 'vue'
const notice = ref('等待操作')
const tasks = ref([
{ id: 21, title: '理解事件冒泡' },
{ id: 22, title: '练习事件委托' }
])
function handleListClick(event) {
const button =
为什么不直接判断 event.target.tagName === 'BUTTON'?因为用户可能点中“打开”按钮里的图标 <span>,这时 target 是 SPAN。closest('button[data-action]') 会从实际目标向上找到按钮。随后再用 event.currentTarget.contains(button) 确认按钮仍属于当前列表,避免误接收不相关节点。
委托代码把模板中的重复监听器换成了处理函数里的分派逻辑。项目是否该这样写,要看哪种更清楚。只有两三个按钮时,分别绑定往往更直观:
<button @click="openTask(task.id)">打开</button>
<button @click="removeTask(task.id)">删除</button>当动作由服务端配置、按钮数量很多或节点会由外部代码插入时,委托才更可能带来实际收益。不要把它机械当成“性能更好”的固定答案。
事件委托还有两个边界。第一,子元素一旦使用 .stop,事件到不了父元素,委托自然失效。第二,只有会冒泡的事件才能直接委托;focus 与 blur 不冒泡,常用可冒泡的 focusin 与 focusout 替代。鼠标进入离开也要区分 mouseenter、mouseleave 与会冒泡的 mouseover、mouseout,因为后两者在穿过内部子元素时可能频繁触发。
有些代码会在每个按钮上习惯性加 .stop,即使父元素当前没有点击逻辑。这样短期看不出问题,后来添加统计、快捷关闭或事件委托时,父级突然收不到事件,只能继续堆补丁。
更稳妥的原则是:确实存在两个会冲突的交互时再停止传播。例如“点击卡片打开详情,点击卡片里的删除按钮只删除”,.stop 的意图很明确。若父级与子级本来可以同时响应,就让事件正常传播。传播是 DOM 提供的协作机制,不是默认应该消灭的副作用。
Vue 常用的事件修饰符包括 .stop、.prevent、.self、.capture、.once 和 .passive。其中 .stop、.prevent、.self 会在包装后的处理逻辑中操作或检查事件;.capture、.once、.passive 更接近 addEventListener() 的监听器选项。
.self 不会阻止事件冒泡。它只是给当前处理函数加了一道条件:事件必须直接从绑定元素本身开始。
弹窗遮罩是最容易理解的例子:
<!-- DialogPanel.vue -->
<script setup>
import { ref } from 'vue'
const open = ref(true)
</script>
<template>
<button type="button" @click="open = true">打开说明</button>
<div v-if="open" class="mask"
点击 .dialog 内部时,事件会冒泡到 .mask,但 target 不是 .mask,所以 .self 拦下处理函数。点击遮罩空白处时,target 与 currentTarget 都是 .mask,弹窗关闭。
<button type="button" @click.once="showGuide">只展示一次操作提示</button>第一次点击后监听器就不再响应。它很适合一次性教学提示,但不要把 .once 当成通用的防重复提交方案。请求失败后也许需要允许重试;组件被销毁再创建时,新的元素又会获得一个新的 .once 监听器。处理提交按钮更可靠的做法是根据 loading 禁用按钮,并在 try...finally 中恢复状态。
链式修饰符不是一个无序集合。Vue 会按模板里从左到右的顺序生成相应逻辑。
<a @click.prevent.stop="handleLink">先取消默认行为,再停止传播</a>
<a @click.stop.prevent="handleLink">先停止传播,再取消默认行为</a>对一个普通、可取消、会冒泡的点击来说,这两种写法最终通常都是“不跳转,也不继续传播”,所以不能仅凭结果断言它们有差异。它们的执行次序确实不同,只是两个动作彼此独立,结果恰好相同。
带条件的 .self 更能看出顺序影响:
<div @click.prevent.self="handlePanel">
<a href="/profile">个人资料</a>
</div>
<div @click.self.prevent="handlePanel">
<a href="/profile">个人资料</a>
</div>在第一段中,点击子链接后,.prevent 先取消跳转,随后 .self 发现点击来自子元素,于是不执行 handlePanel。在第二段中,.self 先发现不是容器自身,后面的 .prevent 根本不会运行,因此链接仍会执行默认导航。
这也是模板修饰符顺序最实用的判断方法:把它们从左到右翻译成普通 JavaScript。谁先调用、谁是条件守卫、守卫失败后哪些操作不会发生,结果就清楚了。

键盘交互最容易写成一串 if (event.key === ...)。Vue 的按键修饰符把常见过滤条件放回模板,让处理函数继续表达业务意图。
<!-- ShortcutInput.vue -->
<script setup>
import { ref } from 'vue'
const keyword = ref('')
const notice = ref('输入后按 Enter 搜索')
function submitSearch() {
notice.value = keyword.value
? `已搜索:${keyword.value}`
: '没有可搜索的内容'
}
function
常用别名有 .enter、.tab、.delete、.esc、.space、.up、.down、.left、.right。这里的 .delete 同时匹配 Delete 与 Backspace。
对没有别名的键,可以根据 KeyboardEvent.key 的值写成短横线形式。例如 PageDown 对应 .page-down:
<textarea @keydown.page-down="loadNextPage" />不要再写数字按键码。数字不能直观说明键位,而且旧的 keyCode 已经不适合现代代码。按键名还会受到键盘布局与 Shift 状态影响,例如字母键的 event.key 可能是小写或大写;做文字快捷键时要先明确你关心的是用户输入的字符,还是物理位置。
.ctrl、.alt、.shift、.meta 可以与键盘或鼠标事件组合。meta 在 macOS 上通常对应 Command,在 Windows 上通常对应 Windows 键。
<!-- NoteEditor.vue -->
<script setup>
import { ref } from 'vue'
const content = ref('')
const status = ref('尚未保存')
function save() {
status.value = `已保存 ${content.value.length} 个字符`
}
</script>
<
按 Ctrl+S 或 Command+S 时,浏览器原本的“保存页面”默认行为会被 .prevent 取消,组件执行自己的 save。这里选择 keydown,因为组合键按下时就应该反馈;如果使用 keyup.ctrl,只有在某个键抬起的那一刻 Ctrl 仍处于按下状态才会触发,单独松开 Ctrl 不符合这个条件。
@click.ctrl="select" 的含义是“Ctrl 必须按下”,它不排斥 Alt 或 Shift 同时按下。需要完全匹配时再加 .exact:
<button @click.ctrl="select">按住 Ctrl 点击即可</button>
<button @click.ctrl.exact="selectOnly">只能按住 Ctrl 点击</button>
<button @click.exact="selectPlain">不能按任何系统修饰键</button>.exact 只约束系统修饰键,不会把鼠标按钮、普通字符键也变成“唯一按键”。
中文输入法可能先触发一连串键盘事件,再完成文字组合。一个输入框仅写 @keyup.enter="submit" 时,某些环境中按 Enter 选定候选词也可能撞上提交逻辑。对聊天框、搜索框等敏感场景,可以在处理函数里检查组合状态:
<script setup>
function handleEnter(event) {
if (event.isComposing) return
submitMessage()
}
function submitMessage() {
console.log('发送消息')
}
</script>
<template>
<textarea @keydown.enter="handleEnter" />
</template修饰符负责筛选 Enter,处理函数再处理输入法这个与业务体验有关的边界条件。

Vue 提供 .left、.right、.middle 来筛选主按钮、次按钮和辅助按钮。名称沿用了常见鼠标布局,但表达的是逻辑按钮。用户把鼠标配置成左手模式后,主按钮可能位于物理右侧,它仍然匹配 .left。触控板的一指、两指操作也不一定对应肉眼可见的左键和右键。
下面的示例在 mousedown 时区分三个逻辑按钮:
<!-- PointerButtons.vue -->
<script setup>
import { ref } from 'vue'
const action = ref('等待操作')
</script>
<template>
<div
class="pad"
tabindex="0"
@mousedown.left="action = '主按钮按下'"
@mousedown.middle="action = '辅助按钮按下'"
@mousedown.right="action = '次按钮按下'"
浏览器的右键菜单由 contextmenu 的默认行为控制,所以示例单独用 @contextmenu.prevent 取消它。不要以为 .right 本身等于“阻止右键菜单”;按钮过滤与默认行为仍是两件事。
鼠标事件还能与系统修饰键组合:
<button @click.shift="selectRange">按住 Shift 扩展选择</button>
<button @click.meta.exact="openInPanel">Command 点击时在面板打开</button>现代交互如果同时面向鼠标、触控笔和触摸屏,可以优先考虑 pointerdown、pointerup 等指针事件。Vue 的 @pointerdown 写法与 @mousedown 一样,事件对象则会提供 pointerType、压力等额外信息。无论使用哪类事件,都不要只靠右键或悬停提供唯一功能,键盘和触屏用户也需要可达的操作入口。

在 wheel、touchmove 这类可能关联滚动的事件上,浏览器面对一个普通监听器时要留一个心眼:处理函数会不会调用 preventDefault() 阻止滚动?如果它必须等处理函数运行后才确定,耗时逻辑就可能让滚动响应变慢。
被标记为 passive 的监听器是在向浏览器承诺:“这个处理函数不会取消默认行为。”浏览器因此不必等待监听器做决定。原生写法是:
surface.addEventListener('touchmove', observeMove, { passive: true })Vue 对应的写法是:
<!-- ReadingProgress.vue -->
<script setup>
import { ref } from 'vue'
const lastY = ref(0)
function observeTouch(event) {
lastY.value = Math.round(event.touches[0]?.clientY ?? 0)
}
</script>
<template>
<div
.passive 与 .prevent 不能放在同一个监听器上。前者承诺不会阻止默认行为,后者却要求立即阻止;两者同时出现时,preventDefault() 不会按你的期待生效,控制台还可能给出警告。
<!-- 不要这样写 -->
<div @touchmove.passive.prevent="handleMove" />也不要把 .passive 当成“所有事件都能提速”的按钮。scroll 事件是在滚动发生后通知你的,而且本身不可取消;给它加 .passive 通常不会带来你以为的取消等待收益。真正需要判断的是:这个事件是否有可取消的默认动作,你是否明确不打算取消它。
事件处理函数本身如果每次都做大量同步计算,.passive 也不会让这些计算消失。滚动进度、位置测量等高频逻辑仍要控制工作量,必要时用 requestAnimationFrame 合并页面更新。
真实的点击处理往往会请求接口。函数一旦出现 await,要同时考虑三件事:浏览器不会等待 Promise 再决定事件传播;原生事件对象的某些上下文只在同步回调中可靠;用户可能在第一次请求完成前继续点击。
先看一个保存按钮:
<!-- SaveProfile.vue -->
<script setup>
import { ref } from 'vue'
const saving = ref(false)
const status = ref('有未保存的修改')
async function saveProfile() {
if (saving.value) return
saving.value = true
status.value = '正在保存…'
try {
await
if (saving.value) return 是逻辑层的第二道防线,:disabled="saving" 则在界面层阻止继续操作并向辅助技术说明按钮暂不可用。finally 保证成功或失败后都恢复按钮。相比 @click.once,这种写法允许失败后重试,也能清楚展示当前状态。
浏览器派发事件时会同步执行监听器。异步函数返回 Promise 后,浏览器不会停下来等网络请求结束,再问你是否想取消默认行为。因此,下面这种写法太晚了:
<script setup>
async function handleLink(event) {
await checkPermission()
event.preventDefault()
}
async function checkPermission() {
return true
}
</script>
<template>
<a href="/account" @click="handleLink">账户中心</a
页面可能已经开始导航,异步检查完成后再调用 preventDefault() 无法把时间倒回去。若导航是否发生取决于异步结果,应在同步阶段先取消默认行为,检查通过后再由代码主动执行后续动作:
<script setup>
async function handleLink(event) {
event.preventDefault()
const href = event.currentTarget.href
const allowed = await checkPermission()
if (allowed) {
window.location.assign(href)
}
}
</script>
<template>
<a href
这个例子还在 await 前保存了 href,避免异步结束后依赖 currentTarget。如果默认行为无条件都要取消,可以直接写 @click.prevent="handleLink";如果要看同步条件再决定,就在函数中调用 event.preventDefault()。
输入联想通常希望用户暂停一小会儿再搜索,滚动进度通常希望每一帧最多更新一次。这属于调用频率控制,不属于 .stop、.prevent 或 .passive 的职责。
一个最小的搜索防抖可以这样写:
<!-- SearchSuggest.vue -->
<script setup>
import { onBeforeUnmount, ref } from 'vue'
const keyword = ref('')
const message = ref('等待输入')
let timerId
function scheduleSearch() {
window.clearTimeout(timerId)
timerId = window.setTimeout(() => {
message.value =
每次 input 都会重置计时器,只有连续 300 毫秒没有新输入才执行查询逻辑。组件卸载时清理计时器,避免延迟回调继续修改已经离开的界面。大型项目可以使用经过测试的工具函数,但原理仍然是“频繁事件先收集,再按策略执行”,并不是给事件名加一个不存在的 .debounce。
对于 pointermove、scroll 一类高频事件,控制台里逐次打印也可能制造明显卡顿。调试时若发现“加日志后才变慢”,先减少同步输出,再观察真正的处理耗时。
到这里为止,我们讨论的都是原生 DOM 事件。Vue 组件还可以通过 defineEmits() 发出自定义事件,让子组件向直接父组件报告一个清楚的业务动作。
先写一个课程卡片:
<!-- CourseCard.vue -->
<script setup>
const props = defineProps({
course: {
type: Object,
required: true
}
})
const emit = defineEmits(['select-course'])
function selectCourse() {
emit('select-course', props.course.id)
}
</script>
<template>
按钮的 click 是 DOM 事件;select-course 是组件自己定义的业务事件。父组件这样监听:
<!-- CourseShelf.vue -->
<script setup>
import { ref } from 'vue'
import CourseCard from './CourseCard.vue'
const selectedId = ref(null)
const course = { id: 5, title: 'Vue 事件处理' }
function handleSelect(id) {
selectedId.value = id
}
</script>
组件事件的参数不是原生 DOM Event,而是子组件在 emit() 时主动传出的数据。这里父组件收到的第一个参数是课程编号 5。如果子组件执行 emit('select-course', 5, 'keyboard'),父组件处理函数就会依次收到这两个参数。
最容易混淆的一点是:Vue 组件发出的自定义事件不会像 DOM 点击那样向祖先冒泡。 父组件只能监听直接子组件发出的事件。假设结构是 App → CourseShelf → CourseCard,CourseCard 发出的 select-course 只能先被 CourseShelf 接住;App 不会因为模板层级更外就自动收到。
如果最外层也需要这个动作,中间组件可以明确转发:
<!-- CourseShelf.vue -->
<script setup>
import CourseCard from './CourseCard.vue'
const props = defineProps({
course: {
type: Object,
required: true
}
})
const emit = defineEmits(['select-course'])
function forwardSelect(id) {
emit('select-course', id)
}
这种显式传递让组件边界很清楚。层级很深、许多分支都要共享同一状态时,再考虑把状态提升、使用依赖注入或状态管理,而不是期待组件事件自动穿过整棵组件树。
还要小心事件重名。如果组件声明自己会发出 click,父组件在 <MyButton @click="handler" /> 上监听的会是组件声明的 click,不要再想当然地把它当成组件根元素的原生点击。业务事件最好使用能表达意图的名字,如 confirm、remove-item、select-course。

下面把本章的重点放进一个组件:任务可以点击选择,Enter 可以新增,Esc 可以清空输入,删除按钮不会触发任务选择,表单提交也不会刷新页面。代码不依赖额外库,可以直接保存为 TaskPanel.vue 使用。
<!-- TaskPanel.vue -->
<script setup>
import { computed, ref } from 'vue'
const draft = ref('')
const selectedId = ref(null)
const notice = ref('准备就绪')
const tasks = ref([
{ id: 1, title: '读完事件传播小节', done: true },
打开页面时有两项任务,第一项已经完成,“还剩 1 项”。在输入框中填写内容后按 Enter,表单的 submit 被触发,.prevent 取消页面刷新,任务进入列表。按 Esc 会清空输入。
点击一行空白处会把它标成选中状态;点击复选框或删除按钮时,.stop 阻止点击继续冒泡到 <li>,所以不会意外改变选中项。注意复选框的 change 事件负责完成状态,外层 <label @click.stop> 则拦住标签和输入产生的点击传播。这里没有把所有交互塞进一个处理器,每个函数都有单一、可描述的意图。
一个 <div @click="submit">提交</div> 用鼠标可以点,却没有天然的按钮语义。它默认不能通过 Tab 获得焦点,也不会自动响应 Enter 与空格,屏幕阅读器还不知道它是一个操作控件。最简单可靠的改法通常不是继续给 div 增加事件,而是换成正确的 HTML 元素:
<button type="button" @click="submit">提交</button>原生按钮已经包含焦点、键盘激活、禁用状态和可访问语义。链接负责导航,按钮负责动作,表单提交用 type="submit"。先使用正确元素,再用 Vue 处理业务事件,可以少写很多补救代码。
如果交互确实必须放在自定义容器上,就要补齐完整行为,而不只是 tabindex="0":
<!-- CustomSwitch.vue -->
<script setup>
import { ref } from 'vue'
const enabled = ref(false)
function toggle() {
enabled.value = !enabled.value
}
</script>
<template>
<div
role="switch"
tabindex="0"
:aria-checked
这里补了角色、焦点顺序、当前状态,以及 Enter 和空格。可即便如此,原生复选框或按钮通常仍更稳妥,因为自定义控件还要处理焦点样式、禁用、不同辅助设备等边界。
原生 <button> 在键盘激活时会产生 click。如果同时写 @click="submit" 和 @keyup.enter="submit",按 Enter 可能让同一个动作执行两次。对标准按钮,只监听 click 通常就已经同时覆盖鼠标、触摸和键盘:
<button type="button" @click="submit">发布</button>键盘修饰符更适合文本编辑区快捷键、全局操作或没有原生激活语义的场景。先弄清楚浏览器已经替控件做了什么,再决定是否补监听器。
打开弹窗时,键盘焦点通常应该进入弹窗;关闭后应该回到触发按钮。删除当前聚焦的列表项后,也要考虑焦点移向哪里。如果只更新响应式数组,元素确实从页面消失了,但键盘用户可能突然不知道自己在哪里。
Vue 可以通过模板引用和 nextTick() 在 DOM 更新后调整焦点:
<!-- FocusAfterOpen.vue -->
<script setup>
import { nextTick, ref } from 'vue'
const open = ref(false)
const closeButton = ref(null)
async function openDialog() {
open.value = true
await nextTick()
closeButton.value?.focus()
}
</script>
open.value = true 后,弹窗按钮还要等 Vue 把新 DOM 写入页面。await nextTick() 之后,模板引用才指向实际元素。完整弹窗还需要管理焦点循环、Esc 关闭和关闭后的焦点恢复,这里只展示事件与渲染时机的连接。
保存成功、验证失败、任务已删除等结果不能只写在控制台。页面应给出可见文本;对不会自动获得焦点的状态区域,可以适量使用 aria-live="polite",让辅助技术在不打断当前操作的情况下报告变化。
也不要用颜色作为唯一反馈。任务选中后除了背景色,可以保留明确文字或 aria-current;按钮保存中除了变灰,还可以显示“保存中…”。事件处理的终点不是函数运行完,而是用户能理解动作是否发生、现在处于什么状态、下一步还能做什么。
事件问题看起来很随机,实际通常落在“没有监听到”“监听到了但被筛掉”“执行了但默认行为或传播又造成第二个结果”这几类。可以按下面的顺序检查。
先确认元素真的存在且事件名正确。条件渲染为 false 时没有 DOM 可监听;@keyup 也不会响应一个纯鼠标点击。浏览器开发者工具的 Elements 面板能确认节点是否已渲染。
在处理函数第一行打印 event.type、event.target、event.currentTarget、event.eventPhase、event.bubbles 与 event.defaultPrevented。这几项能说明事件从哪里来、当前走到哪里、是否已经被取消默认行为。
暂时从右向左移除修饰符。.self、按键名与 .exact 都可能让函数根本不执行;.once 可能已经消费过唯一一次机会;.stop 则可能让祖先监听器收不到事件。
还有几类高频错误值得单独记住:
@click="submit()" 当成能自动接收事件对象,结果函数里的参数是 undefined。需要写 submit($event)。.prevent 后误以为子链接的处理函数也不会执行。默认行为与事件传播相互独立。.passive 监听器中调用 preventDefault(),控制台出现警告且页面照常滚动。target 判断按钮身份,点击按钮内部图标时逻辑失效。监听器所在元素应看 currentTarget。.stop,之后又想做事件委托。传播被层层截断后,父容器自然收不到事件。emit 当成 DOM 冒泡,隔了两层的祖先组件一直收不到通知。调试时也别只盯着鼠标。用 Tab 把焦点移动到按钮,再按 Enter 或空格检查键盘操作;观察焦点是否可见;为状态提示增加 aria-live="polite"。事件代码“能点”不等于交互已经完成,语义化的 <button> 往往比给 <div @click> 补一串键盘逻辑更可靠。
如果问题只在某个复杂页面出现,可以先做一个最小复现:保留一层父元素、一个目标按钮和必要的修饰符,把业务请求、动画和第三方组件暂时拿掉。先确认纯 DOM 路径上的执行顺序,再一项项加回条件。事件问题常被多个结果叠在一起,例如按钮既提交表单又把点击冒泡给卡片,卡片随后切换路由,看起来像“提交函数没有运行”。把路径拆开记录后,通常会发现函数运行了,只是页面紧接着发生了另一项默认动作。调试的重点不是盲目补 .stop,而是找到每一个可见结果分别由哪个事件、哪个阶段和哪个默认行为产生。
一个文章卡片点击后会打开详情,卡片里的链接会跳到作者主页。现在希望点击作者链接时仍然跳转,但不要打开文章详情。应该用 .stop 还是 .prevent?把修饰符写到合适位置。
<article @click="openArticle">
<h3>理解事件传播</h3>
<a href="/authors/xiaoyu">作者:小雨</a>
</article>为什么 @click.prevent.self="close" 与 @click.self.prevent="close" 在点击子链接时可能产生不同结果?先把两个修饰符从左到右翻译成普通操作,再查看答案。
给下面的编辑器补上两个监听:按 Ctrl+Enter 或 Command+Enter 发布;不能让 Enter 产生默认换行。处理中文输入法组合期间的 Enter。
<script setup>
function publish(event) {
// 在这里补充输入法判断
console.log('发布')
}
</script>
<template>
<textarea />
</template>组件结构是 App.vue → ListPanel.vue → CourseCard.vue。CourseCard 发出 select-course,App 为什么收不到?应该怎样处理?
到这里,你可以把 Vue 事件处理理解成两层:底层仍是浏览器的事件对象、默认行为和传播路径;模板提供了一套紧凑语法,把监听条件与 DOM 细节放在元素附近。
写代码时先判断意图:简单赋值可以内联,完整动作交给有名字的方法;需要原生事件时使用自动参数或 $event;要阻止路径就用 .stop,要取消默认行为就用 .prevent;键盘和鼠标修饰符负责过滤输入;组件之间则通过明确声明的自定义事件沟通。
现在,任务行里的删除点击不会再误触整行选择,表单提交也能在不刷新页面的前提下交给组件处理;单个动作从哪里来、怎样传播、是否保留默认行为,已经有了清楚的答案。
可当任务面板继续长成一张完整表单,新的不一致会出现:文本框把内容放在 value 里并通过 input 报告变化,复选框关心的是 checked,单选框和下拉框又各有自己的取值与变更方式。难道每一种控件都要手写一遍属性绑定,再挑一个事件把值塞回状态吗?于是问题自然落到 v-model 上:它究竟怎样把这些不同的“值协议”收进同一种写法?
检查是否有浏览器默认行为造成页面跳转、表单刷新或右键菜单。若 event.cancelable 是 false,调用 preventDefault() 本来就不会生效;被动监听器里调用它也不会生效。
最后区分 DOM 事件与组件事件。组件自定义事件只到直接父组件,不会沿组件树冒泡;原生 DOM 点击则要看实际元素层级和传播路径。