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 事件机制的核心脉络串起来了:

Vue 事件机制核心结构 v-on 指令 原生 DOM 事件 自定义事件 ($emit) 事件修饰符 click / input keydown / resize 子 → 父 通信 .stop / .prevent 注意:Vue 3 移除 $on,改用第三方库 原生事件需手动清理,避免内存泄漏

核心要点:

  • v-on 是 Vue 事件绑定的统一入口,支持原生事件和自定义事件
  • $emit 是子组件向父组件通信的标准方式,事件名推荐 kebab-case
  • 事件修饰符能大幅减少模板中的原生事件处理代码
  • 原生事件在 Vue 中仍有使用场景,但要注意生命周期内手动清理

Vue 的事件机制设计得相当精巧——它把 DOM 事件的复杂性封装在指令背后,同时保留了自定义事件的扩展能力。你不需要每次都手写 addEventListener 和 removeEventListener,Vue 帮你管好了生命周期。但理解它背后的原理,能让你在遇到边界问题时快速定位。