4. Vue 事件机制:v-on、$emit、$on 与原生 DOM 的协作
Vue 的事件系统,说白了就是一套「声明式监听 + 自定义通信」的组合拳。我最早从 jQuery 转 Vue 时,最不适应的就是事件绑定方式——不再是 $('.btn').on('click'),而是模板里写 @click。但用久了你会发现,这套设计其实更符合「数据驱动视图」的直觉。
这一章我们拆开来看:v-on 指令怎么用、组件内部怎么发事件、事件修饰符解决了哪些痛点,以及最关键的一点——Vue 事件和原生 DOM 事件到底怎么共存。
4.1 v-on 指令:模板里的事件绑定
v-on 是 Vue 事件系统的入口。你可以在模板里直接绑定事件,语法很简单:
<!-- 完整写法 -->
<button v-on:click="handleClick">点击</button>
<!-- 缩写(我几乎只用这种) -->
<button @click="handleClick">点击</button>
<!-- 动态事件名(Vue 2.6+) -->
<button @[eventName]="handleClick">动态事件</button>
我个人习惯统一用 @ 缩写,团队协作时也更容易扫读。但要注意一点:动态事件名虽然灵活,调试时却不太直观——你没法一眼看出绑定了什么事件。
@click="count++",省去一个方法定义。但超过两行逻辑,还是抽成方法吧,不然模板会变得很难维护。
4.2 $emit 与 $on:组件间的「自定义事件」
Vue 组件通信里,$emit 是子组件向父组件发消息的标准方式。父组件通过 v-on 监听子组件抛出的自定义事件:
// 子组件 Child.vue
this.$emit('update', { id: 1, name: '张三' })
// 父组件
<Child @update="handleUpdate" />
这里有个细节我踩过坑:$emit 的事件名最好用 kebab-case(短横线命名)。虽然 Vue 2 也支持 camelCase,但 HTML 属性不区分大小写,容易出问题。Vue 3 官方推荐统一用 kebab-case,我建议你从一开始就养成这个习惯。
至于 $on,它在 Vue 2 里用于实例间监听自定义事件,但在 Vue 3 中已经被移除了。如果你还在维护 Vue 2 项目,可能会看到这样的代码:
// Vue 2 写法(Vue 3 已废弃)
eventBus.$on('some-event', callback)
eventBus.$emit('some-event', data)
嗯,这里要提醒一下:$on 在 Vue 3 里不能用了,替代方案是 mitt 或 tiny-emitter 这类第三方库。如果你在升级项目,记得把 eventBus 替换掉。
4.3 事件修饰符:少写很多「防冒泡」代码
Vue 提供了一组事件修饰符,说白了就是帮你处理 DOM 事件的常见场景——阻止冒泡、阻止默认行为、按键识别等。我最常用的是这几个:
| 修饰符 | 作用 | 等价原生写法 |
|---|---|---|
.stop |
阻止事件冒泡 | event.stopPropagation() |
.prevent |
阻止默认行为 | event.preventDefault() |
.capture |
在捕获阶段触发 | addEventListener(..., true) |
.self |
只有 event.target 是自身时才触发 | if (event.target === this) |
.once |
事件只触发一次 | { once: true } |
.passive |
告诉浏览器不调用 preventDefault | { passive: true } |
举个例子,你有一个嵌套结构:
<div @click="outer">
<button @click.stop="inner">点我</button>
</div>
不加 .stop,点击按钮会触发 inner 和 outer 两个方法。加了之后,冒泡被阻断,只执行 inner。我在项目中遇到过好几次因为忘记加 .stop 导致父级事件意外触发的问题——尤其是弹窗嵌套时,点关闭按钮却触发了背景层的点击事件。
@click.stop.prevent。但顺序很重要——@click.prevent.stop 会先阻止默认行为再阻止冒泡,虽然大多数场景下结果一样,但建议保持一致的书写顺序。
4.4 与原生 DOM 事件的交互
Vue 的事件绑定最终还是会落到原生 DOM 事件上。但两者之间有一些关键差异,我整理了一张对比表:
| 对比维度 | Vue 事件(v-on) | 原生 DOM 事件 |
|---|---|---|
| 绑定方式 | 模板声明式 | JS 命令式 |
| 事件对象 | 自动传入,可通过 $event 访问 |
回调第一个参数 |
| 移除事件 | Vue 自动清理 | 需手动 removeEventListener |
| 自定义事件 | 原生支持($emit) | 需用 CustomEvent |
| 性能 | 有虚拟 DOM diff 开销 | 直接绑定,无额外开销 |
你可能会问:什么时候必须用原生事件?我遇到过几种场景:
- 第三方库集成: 比如地图 SDK、富文本编辑器,它们的事件不在 Vue 的虚拟 DOM 里,只能用原生
addEventListener。 - 窗口/文档事件:
window.resize、document.keydown这类全局事件,没法用 v-on 绑定。 - 性能敏感场景: 比如列表中有上千个元素需要监听点击,用原生事件代理比每个元素都绑 v-on 更高效。
在 Vue 组件里混用原生事件时,记得在 beforeDestroy 或 onUnmounted 里移除监听,否则会造成内存泄漏。我曾经在一个长驻页面里忘了移除 window.resize 监听,结果页面切出去再切回来,事件绑了两次,性能直接崩了。
4.5 知识体系总览
下面这张图把 Vue 事件机制的核心脉络串起来了:
核心要点:
- v-on 是 Vue 事件绑定的统一入口,支持原生事件和自定义事件
- $emit 是子组件向父组件通信的标准方式,事件名推荐 kebab-case
- 事件修饰符能大幅减少模板中的原生事件处理代码
- 原生事件在 Vue 中仍有使用场景,但要注意生命周期内手动清理
Vue 的事件机制设计得相当精巧——它把 DOM 事件的复杂性封装在指令背后,同时保留了自定义事件的扩展能力。你不需要每次都手写 addEventListener 和 removeEventListener,Vue 帮你管好了生命周期。但理解它背后的原理,能让你在遇到边界问题时快速定位。