这两年带着团队把组件库从 Vue2 迁到 Vue3,日常被问得最多的不是响应式原理,也不是虚拟 DOM diff,反而是最不起眼的“自定义事件”。Vue2 里我们习惯this.$emit+this.$on一把梭,事件总线new Vue()到处挂;到了 Vue3 这些全变了,$on没了,$listeners也没了,.native修饰符一样被移除,一时间很多人连“在组件上监听一个原生 click”都要重新学一遍。这篇文章我想把这套东西的前因后果、两代实现差异,以及迁移时真正会踩的坑完整梳理一遍。不管你是还在维护 Vue2 老项目,还是已经切到 Vue3,应该都能在里头找到自己需要的那几行代码。
1. 先搞清楚一件事:Vue3 为什么要把自定义事件“重做一遍”
1.1 Vue2 的自定义事件是“实例方法大礼包”
在 Vue2 里,自定义事件本质上就是组件实例上的一组方法:$emit触发事件,$on监听事件,$once只监听一次,$off解除监听。事件名就是一个字符串,所有监听器都存在实例的_events对象上。你打开 Vue DevTools 看一个 Vue2 实例,能在_events里看到当前组件到底挂了多少个自定义事件监听,这种“所见即所得”在调试时其实挺直观。
这种设计最方便的地方是灵活:任何组件都可以制造一个事件总线(EventBus),const bus = new Vue(),然后全局广播。但也因为太灵活,带来了一个致命的隐患——组件之间没有显式契约。谁都能监听任何事件,谁都能往外抛字符串,项目一旦大了,根本没有人知道某个事件到底是谁在发、又有多少组件在听。我曾经在一个老项目里搜$emit,搜出来 300 多处,光是梳理事件关系就花了一个下午。
1.2 Vue3 的新规:声明式事件协议
Vue3 决定把“自定义事件”从灵活的方法调用改成“声明式协议”。组件要对外发送哪些事件,先在emits选项里声明清楚:
export default { emits: ['success', 'error'], methods: { handleClick() { this.$emit('success', { time: Date.now() }) } } }在<script setup>里则用defineEmits这个编译宏:
<script setup> const emit = defineEmits(['success', 'error']) function handleClick() { emit('success', { time: Date.now() }) } </script>声明的好处有三点。第一,事件变成了组件对外接口的一部分,调用方看emits数组就知道这个组件能发出什么消息;第二,Vue 内部可以根据声明区分“自定义事件”和“透传属性”,避免把监听器错误地挂到根元素上;第三,v-model的多绑定语法依赖事件名做命名空间,声明后解析更可靠。
1.3 从 EventBus 换到 mitt,我损失了什么
说实话,最开始迁移时我是有怨气的。Vue3 移除了$on、$off、$once,只保留了$emit,这意味着老项目里所有bus.$on()的代码全部作废。官方推荐用第三方库 mitt 代替事件总线,或者干脆用 provide/inject、Pinia 这类状态管理方案。
但真改完之后我发现,这其实是个“劝退式”的好设计。强迫你停下来思考:这个事件是真的需要一个全局总线吗?还是可以用props+emit父子通信?或者放进 Pinia 里当状态管理?我们项目里大概一半的事件总线都属于“状态共享”被误用成了“全局广播”,迁移过程中顺手就改造成了 Pinia,剩下的低频通知类事件才用 mitt 保留。这是冷静下来之后才意识到 Vue3 这步棋的合理性。
2. 底层逻辑:自定义事件到底是怎么工作的
2.1 组件事件不冒泡,只有监听者能收到
很多从原生 JavaScript 转过来的同学会有一个误解:以为组件上的自定义事件和 DOM 事件一样会冒泡。实际上 Vue 的自定义事件不会冒泡。子组件$emit('change'),只有绑定在这个子组件实例上的@change监听器能收到;祖先组件如果没在中间层转发,是根本不可能直接监听到孙子组件的事件的。
举个具体场景:
<!-- 祖父组件 --> <template> <Parent @change="handleChange" /> </template><!-- 父组件 --> <template> <Child /> </template>如果Child内部$emit('change'),祖父组件里的@change是收不到的,因为Parent组件自己没有$emit('change')。这个设计在 Vue2 和 Vue3 里完全一致,属于“组件事件第一性原则”。想跨层级通信,要么一层层手动转发,要么用事件总线,要么用 provide/inject。
理解这一点很重要,因为我在迁移时见过不少把事件总线当“冒泡通道”用的代码,最后排查问题时绕了一大圈,才发现是事件根本没有沿着组件树向上传播。
2.2 事件到底算哪边的:$listeners 到 $attrs 的变化
Vue2 处理组件上的事件监听有一个非常容易混淆的地方:组件标签上的@click,默认是自定义事件,不会自动绑定到组件根元素上。如果你想让它落到根元素的原生 click 上,必须用.native修饰符:
<MyButton @click.native="handleClick" />而在封装高阶组件时,为了让父组件的所有监听器都能穿透到内部 DOM 上,Vue2 专门提供了$listeners:
<!-- Vue2 透传写法 --> <template> <div class="wrapper"> <input v-bind="$attrs" v-on="$listeners" /> </div> </template>Vue3 把这个设计合并了。$listeners被移除,事件监听器全部并入了$attrs,在$attrs里以onXxx的形式存在。同时,只要组件没有在emits里声明该事件,组件标签上的@click会自动作为透传属性绑定到组件的根元素上。所以在 Vue3 里,大多数情况下你不需要做任何透传处理:
<!-- Vue3 默认透传 --> <MyButton @click="handleClick" />只要MyButton内部没有声明click事件,handleClick就会自动绑定到MyButton的根元素上,点击按钮直接触发。这个变化一开始有点反直觉,但它确实大大简化了基础组件的封装。
2.3 v-model 的本质就是自定义事件,只是偷偷封装了
v-model 在 Vue 里是最容易被忽略的“自定义事件应用”。Vue2 中v-model="value"本质上是:
<Child :value="value" @input="value = $event" />子组件要配合这个语法,必须接收一个名为value的 prop,然后在需要更新时触发input事件。为了更灵活,Vue2.2 之后支持用model选项自定义 prop 和事件名:
export default { model: { prop: 'checked', event: 'change' } }Vue3 统一改成了modelValue+update:modelValue:
<Child :modelValue="value" @update:modelValue="value = $event" />这个改动的核心原因是:事件名必须能携带绑定的字段名,才能支持一个组件上写多个 v-model。Vue3 中你可以这样:
<Child v-model:title="title" v-model:content="content" />等价于:
<Child :title="title" @update:title="title = $event" :content="content" @update:content="content = $event" />在子组件里声明这两组事件和 props 即可。这套机制也解释了为什么 Vue3 移除.sync修饰符——Vue2 的:
<Child :title.sync="title" />在 Vue3 里直接写成v-model:title就行了,本质是同一件事。理解 v-model 是自定义事件的语法糖,对排查双向绑定问题特别有用,后面我会专门讲一个坑。
3. 实操:两代框架里的事件绑定、触发与透传
3.1 Vue2:$emit + $on 全家桶的典型用法
Vue2 子组件发送自定义事件:
<!-- Child.vue --> <template> <button @click="handleClick">点击我</button> </template> <script> export default { name: 'Child', methods: { handleClick() { this.$emit('success', { time: Date.now() }) } } } </script>父组件监听:
<!-- Parent.vue --> <template> <Child @success="handleSuccess" /> </template> <script> import Child from './Child.vue' export default { components: { Child }, methods: { handleSuccess(payload) { console.log('success event:', payload) } } } </script>Vue2 里组件上的事件默认是自定义事件,所以@success能正常收到子组件的$emit('success')。但如果你想监听组件根元素上的原生事件(比如根元素是 button),就得写.native:
<Child @click.native="handleClick" />这个.native修饰符在 Vue3 中已经不存在了,迁移时如果还留着它,Vue 会直接忽略并报一个控制台警告,所以看到.native就要条件反射地把它删掉。
3.2 Vue3:defineEmits 与 emit 的三种写法
Vue3 里发送自定义事件有三种常见姿势。第一姿势是组合式 API 的<script setup>:
<!-- Child.vue --> <script setup> const emit = defineEmits(['success', 'change']) function handleClick() { emit('success', { time: Date.now() }) } </script> <template> <button @click="handleClick">点击我</button> </template>第二种是选项式 API:
export default { emits: ['success', 'change'], methods: { handleClick() { this.$emit('success', { time: Date.now() }) } } }第三种是 TypeScript 泛型写法,适合新项目和组件库:
const emit = defineEmits<{ (e: 'change', id: number): void (e: 'update:modelValue', value: string): void }>()这里有个细节:defineEmits是编译宏,不需要 import,而且只能在<script setup>顶层调用。如果漏掉了emits声明,子组件里的emit('success')依然能触发监听器,但 Vue 会把它当成透传属性来处理,在某些场景下事件会“落”到根元素上,产生行为差异。所以别偷懒,声明一定要写。
3.3 全局事件总线:从 new Vue() 到 mitt
Vue2 里搞一个全局事件总线非常简单:
// event-bus.js import Vue from 'vue' export const bus = new Vue()组件 A 发消息:
import { bus } from '@/utils/event-bus' bus.$emit('refresh', userId)组件 B 收消息,记得在销毁前解绑:
import { bus } from '@/utils/event-bus' export default { created() { bus.$on('refresh', this.handleRefresh) }, beforeDestroy() { bus.$off('refresh', this.handleRefresh) }, methods: { handleRefresh(userId) { // ... } } }Vue3 里最顺手的替代品就是 mitt,体积只有 200 字节左右,API 也很接近:
npm install mitt// event-bus.js import mitt from 'mitt' export const bus = mitt()发消息:
import { bus } from '@/utils/event-bus' bus.emit('refresh', userId)收消息:
import { bus } from '@/utils/event-bus' import { onBeforeUnmount } from 'vue' const handler = (userId) => { // ... } bus.on('refresh', handler) onBeforeUnmount(() => { bus.off('refresh', handler) })mitt 没有$once这种只监听一次的方法,需要自己封装一个,原理和 Vue2 内部实现几乎一样:
function once(bus, event, handler) { const wrapper = (payload) => { handler(payload) bus.off(event, wrapper) } bus.on(event, wrapper) }另外 mitt 支持通配符*,可以用bus.on('*', handler)监听所有事件。这个在找问题时特别好用,生产环境记得去掉,否则会有性能开销。
3.4 事件穿透到原生元素:.native 和 $attrs 的迁移
封装基础组件时,事件穿透是最容易写错的地方。举一个视频播放器组件的例子(这段代码同样适用于 m3u8 播放器封装一类的场景):
Vue2 里你想让父组件能监听到播放器的timeupdate、loadedmetadata,但组件本身又不想暴露太多内部状态,只能靠手动透传:
<!-- Vue2 封装 --> <template> <div class="player-wrapper"> <video ref="video" v-bind="$attrs" v-on="nativeListeners" @timeupdate="onTimeupdate" @loadedmetadata="onLoadedmetadata" ></video> </div> </template> <script> export default { inheritAttrs: false, computed: { nativeListeners() { // 过滤掉自定义事件,剩下的透传给原生 video const listeners = { ...this.$listeners } delete listeners.timeupdate delete listeners.loadedmetadata return listeners } }, methods: { onTimeupdate(e) { this.$emit('timeupdate', e.target.currentTime) }, onLoadedmetadata(e) { this.$emit('loadedmetadata', e.target.duration) } } } </script>Vue3 里这部分简洁得多,事件统一在$attrs里,而且自动透传到根元素:
<!-- Vue3 封装 --> <script setup> const emit = defineEmits(['timeupdate', 'loadedmetadata']) function onTimeupdate(e) { emit('timeupdate', e.target.currentTime) } function onLoadedmetadata(e) { emit('loadedmetadata', e.target.duration) } </script> <template> <div class="player-wrapper"> <video ref="video" v-bind="$attrs" @timeupdate="onTimeupdate" @loadedmetadata="onLoadedmetadata" ></video> </div> </template>这里有个容易踩的细节:组件标签上的@timeupdate,如果你在emits里声明了,它就走自定义事件通道;如果没声明,它会自动落到根div上。而根div不是 video,原生timeupdate事件永远不会触发,监听就白写了。所以封装这类组件时,凡是内部emit的事件,一定要在emits里写清楚。
4. 迁移常见问题与排查实录
4.1 事件不触发的第一排查点
遇到“自定义事件不触发”,我现在的排查顺序基本是固定的。第一步,看子组件emits里有没有声明这个事件。如果没声明,父组件的监听器可能被当成透传属性挂到了根元素上,而不是等待子组件$emit,两个通道会互相干扰。第二步,看事件名大小写。Vue2 中我之前踩过$emit('myEvent')用@my-event监听失效的坑,因为模板里的事件名会被当作字符串原样匹配。虽然 Vue 文档推荐在模板中统一使用 kebab-case,但老项目里总有漏网之鱼。第三步,看触发时机。
我印象最深的一个问题,是组件在created钩子里$emit一个事件,父组件收到时引用的数据还没初始化就绪,导致拿到undefined。自定义事件本质上是同步调用,别指望它有延迟,所以数据没准备好时抛出来的事件,接收方很容易踩空。现在看到有人把emit写在created里,我都会多问一句“这个事件必须这么早发吗”,多数情况下挪到交互事件里更安全。
4.2 子组件同时需要自定义事件和原生事件的冲突
Vue3 里最容易被忽略的坑,是你既想在组件里emit('click'),又想让它自动透传原生 click。这两个需求是冲突的。如果你在emits里声明了click,父组件的@click会走自定义事件,不再自动绑定到根元素的原生事件上;如果你不声明,@click只落到根元素,子组件内部emit('click')的行为又不稳定。
我的建议是:别让一个事件名同时承担两种语义。组件内部要对外通知“被点击”这个业务行为,就起个更具体的名字,比如buttonClick、select、confirm,把click留给原生事件去透传。这个原则在封装第三方组件时尤其重要,否则外部使用者根本搞不清@click到底是在监听组件行为还是原生点击。
4.3 事件总线监听泄漏
这个 Vue2 和 mitt 都会遇到。原因很简单:事件总线是全局单例,组件销毁时如果没解绑,总线里的 handler 会一直持有这个组件实例的引用,垃圾回收根本没法把它回收掉。页面跳来跳去,内存就一点一点涨上去了。
排查方法也不难。Vue2 里可以在beforeDestroy里打个日志,看有没有组件销毁后总线事件仍然被触发的现象;或者直接搜项目里所有只$on不$off的地方。mitt 同理,凡是bus.on都要对应一个bus.off。我在迁移时还写过一个临时的“事件监听器计数”工具,在开发环境把bus.all的所有事件打印出来,一眼就能看出哪些事件注册了几百次都没释放。
4.4 v-model 迁移过程中的怪现象
迁移时最经典的怪现象是:Vue2 里用model: { prop: 'visible', event: 'change' }自定义了 v-model,组件内部this.$emit('change');到了 Vue3,v-model 默认是modelValue和update:modelValue,如果不改子组件,父组件写v-model="visible"时 prop 名对不上,界面怎么点都没反应。
正确做法是子组件改成:
<script setup> const props = defineProps({ modelValue: Boolean }) const emit = defineEmits(['update:modelValue']) function toggle() { emit('update:modelValue', !props.modelValue) } </script>同时把所有 Vue2 写的:value+@input自定义双向绑定,也统一成modelValue+update:modelValue,或者直接写多个v-model:title、v-model:content。这个改动很小,但漏掉的人特别多,几乎每轮迁移评审都能抓到几个。
5. 选型与建议
5.1 用事件还是用 ref 调用
我在实际项目中会遵循一个简单的原则:数据流向下,事件向上。子组件有状态变化需要告诉父组件,用emit发事件;父组件需要命令子组件做某件事,用模板 ref 调用子组件暴露的方法。比如表单组件,父组件点击“提交”按钮,需要让子表单做校验,这就不是事件该干的活,应该用ref拿到子组件实例后调用validate()。
Vue3 里子组件用defineExpose显式暴露方法:
<script setup> import { ref } from 'vue' const inputRef = ref(null) function focusInput() { inputRef.value?.focus() } defineExpose({ focusInput }) </script>父组件:
<script setup> import { ref } from 'vue' import Child from './Child.vue' const childRef = ref(null) childRef.value?.focusInput() </script>用事件还是用 ref,本质上是“通知”和“命令”的区别。事件是子组件主动广播“发生了一件事”,父组件自行决定要不要响应;ref 是父组件主动驱动子组件去执行某个动作。用反了,代码很快就会变得绕来绕去。
5.2 跨组件通信场景拆解
除了父子通信,日常开发里还有几种跨层级通信场景,我的选择逻辑是这样的:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 父子组件之间 | props+emit | 最简单、显式、可追踪 |
| 深层组件需要读取祖先数据 | provide+inject | 跳过中间层,不污染全局 |
| 多个页面共享一份状态 | Pinia | 状态集中管理,带 DevTools、时间旅行 |
| 全局广播低频通知 | mitt 事件总线 | 轻量,但使用前必须登记事件表 |
| 父组件命令子组件动作 | ref+defineExpose | 命令式调用,语义清晰 |
特别提醒一句:事件总线只适合“通知”类场景,比如登录状态过期、布局切换、菜单折叠。如果你需要把一份数据从 A 传到 B,然后 B 还要根据这份数据二次计算,就别用事件总线,直接上 Pinia。事件比状态更难调试,因为你看不到当前谁持有这个数据、谁改过这个数据。
5.3 团队迁移检查清单
最后放一个我们团队在 Vue2 迁移 Vue3 时整理的检查清单,照着走基本不会漏:
- 全局搜索
new Vue()生成的事件总线,改为 mitt 或 Pinia。 - 全局搜索
$once,替换为自行封装的once封装函数。 - 全局搜索
$listeners,Vue3 中使用$attrs替代,注意onXxx格式。 - 全局搜索
.native,直接删除,同时确认子组件根元素是否正确。 - 检查所有
emits:子组件每个$emit的事件都得在emits里声明。 - 检查所有
v-model:Vue2 的value+input改为modelValue+update:modelValue;.sync改为v-model:xxx。 - 检查事件名大小写是否统一,尽量全部使用 kebab-case。
- 给所有事件总线监听补上
onBeforeUnmount解绑逻辑。 - 对高频事件抽样做内存泄漏检查,确保组件销毁后监听器不残留。
最后再分享一个实用心得:如果你们的项目代码量特别大,不要指望一次迁移搞定事件系统。先把高频、涉及状态共享的事件改成 Pinia,把低频、全局通知类事件改成 mitt,最后再处理组件内部的emits声明。整个过程中可以顺手打印一份“事件地图”,把老代码里所有$emit、$on的调用点列出来,按接收方分组。做完这一步,你会发现 Vue3 的声明式事件协议虽然刚开始有点不适应,但它逼你把组件之间的交流方式重新梳理清楚了。这个代价,换项目长期的可维护性,是值得的。