
最近在维护一个老项目里面大量使用Vue2的自定义事件从$on到$emit项目跑得好好的。后来新开一个技术栈的项目果断上了Vue3结果发现自定义事件这套玩法翻了个底朝天。很多人问我Vue2和Vue3的自定义事件实现到底有什么不同为什么Vue3里$on不见了面试题里也经常被问到。这篇文章我打算把两代框架的自定义事件机制完整拆一遍从API差异、事件总线、v-model绑定到实际迁移中的改造方案和排查坑一次性说透。不管你是正在用Vue2做老项目维护还是准备迁到Vue3或者是面试前临时抱佛脚这篇都值得你花十分钟看完代码都是可直接跑的示例。1. Vue2中的自定义事件实现机制Vue2时代自定义事件主要依赖组件实例上的四个方法$emit、$on、$off、$once。子组件通过$emit触发事件父组件通过v-on监听同时任何组件实例都能通过$on监听一个事件这就是事件总线的基础。这套机制足够灵活但也因为太灵活埋了不少坑。1.1 核心API的作用与关系$emit(eventName, [...args])用于在组件实例上触发一个自定义事件父组件通过eventName监听。$on(eventName, callback)在当前组件实例上监听某个事件$off用于取消监听$once表示只监听一次。它们挂在Vue原型上任何组件实例都能调用所以才能把它当作全局广播工具来用。一个经典的例子用空的Vue实例封装事件总线。// bus.js import Vue from vue export const bus new Vue()组件A里触发// ComponentA.vue import { bus } from /utils/bus export default { methods: { notify() { bus.$emit(global-refresh, { from: A }) } } }组件B里监听// ComponentB.vue import { bus } from /utils/bus export default { mounted() { bus.$on(global-refresh, this.handleRefresh) }, beforeDestroy() { bus.$off(global-refresh, this.handleRefresh) }, methods: { handleRefresh(payload) { console.log(payload) } } }这个模式在Vue2小型项目里非常常见尤其是需要跨多个非父子组件通信的时候。不用引入Vuex也能把消息传遍整个应用。1.2 典型场景父子通信、跨级透传、事件总线最基础的应用是子组件向父组件抛数据。比如子组件里有一个按钮点击后调用this.$emit(update-data, payload)父组件模板里写update-datahandler。这几乎是所有Vue2业务系统里都会用到的能力。另一个重要场景是$listeners。Vue2中父组件传给子组件的所有v-on监听器都会收集到this.$listeners对象里可以用v-on$listeners把这个对象继续透传给孙组件实现事件跨层级转发。这个特性在封装第三方组件、高阶组件时特别有用。比如弹窗组件内部套了一层表单组件表单派发的confirm事件需要直接让页面组件捕获中间层弹窗组件不需要处理这个事件只需要透传。!-- 中间层组件 -- template el-dialog v-bind$attrs v-on$listeners !-- 这里面的内容 -- /el-dialog /template这里的v-on$listeners会把父组件绑定的所有事件原样转发到el-dialog上。如果el-dialog继续往内层传递还可以继续复制这行代码。1.3 $off漏写导致的经典故障事件总线好写但坑也不少。每次$on之后如果不在组件销毁时$off监听器会一直留在bus实例上导致内存泄漏、事件重复触发。尤其当组件被频繁创建销毁时你会发现同一个回调被执行很多次而且每次执行都伴随上一次的残留数据。我之前排查过一个报表页用户每进入一次页面刷新逻辑就多跑一遍越点越多。最后定位到原因就是组件在mounted里bus.$on注册了刷新监听却在beforeDestroy里忘了$off导致旧组件实例的回调一直存在。之后每次触发global-refresh所有历史回调全部执行一遍。所以Vue2里凡是$on必须成对$off这是一个铁律。这个问题在Vue3中被彻底解决因为Vue3直接移除了事件总线API强制你用更显式的方案。2. Vue3中的自定义事件实现机制Vue3的自定义事件是一次彻底的“去魔法化”。事件不再是组件实例上的通用方法而是变成了组件接口的一部分必须在组件中显式声明。这个变化让代码更可预测也让团队协作时的事件契约变得一目了然。2.1 emits选项从“不限事件”到“明确声明”在选项式API中Vue3提供了emits选项用法类似props。数组写法的意思是“这个组件会对外触发哪些事件”export default { emits: [update-data], setup(props, { emit }) { // 在setup中通过emit触发事件 function handleSave() { emit(update-data, { id: 1 }) } return { handleSave } } }如果组件使用选项式且没有setup也可以直接在methods里用this.$emit事件名依然能匹配到emits声明。但在迁移时我建议统一改用setup里解构出来的emit逻辑更清晰。对象写法还能做事件参数校验export default { emits: { update-data: (payload) payload payload.id 0 } }如果校验函数返回falseVue会在开发环境给出警告但不会阻止事件触发。这个设计跟props校验一脉相承让组件的输入和输出都有了“契约”感。2.2 defineEmits组合式API的标准姿势用script setup时定义事件用的是defineEmits这是Vue3组合式API的标准写法script setup const emit defineEmits([update-data, delete-item]) function handleClick() { emit(update-data, { id: 1 }) } /script template button clickhandleClick保存/button /templatedefineEmits的返回值就是emit函数可以在模板事件回调、普通方法、甚至watch回调中调用但不能在script setup的顶层同步执行中提前调用因为此时组件还没完成初始化emit时机不安全。实际使用中我们通常把组件所有对外暴露的事件集中写在defineEmits里让维护者一眼就能看出“这个组件能给外面发什么信号”。对比Vue2的任意$emit这种显式声明是极大的进步。2.3 v-model与自定义事件的联动Vue3里v-model的实现也建立在自定义事件之上。父组件写Child v-modelname /内部等价于传了一个modelValueprop同时监听update:modelValue事件。所以在子组件里需要这样实现template input :valuemodelValue inputemit(update:modelValue, $event.target.value) / /template script setup defineProps({ modelValue: String }) const emit defineEmits([update:modelValue]) /scriptVue2的v-model默认绑定的是value和input事件Vue3改成了modelValue和update:modelValue。这是一个高频考点也是项目迁移时所有二次封装组件都要同步修改的地方。2.4 自定义事件与原生事件的自动分流Vue3中组件上的事件监听器会被自动判断如果这个事件在emits里声明过就认为是自定义事件如果没声明则会被当作原生事件直接挂到组件的根元素上除非设置inheritAttrs: false。这个机制和Vue2完全不同。Vue2里所有组件上的v-on监听都会进入$listeners需要手动透传Vue3则是“默认当作原生事件透传到根节点”。举个例子!-- 父组件 -- Child clickhandleClick /即使子组件没有声明emits: [click]click也会作为原生事件绑定到根元素上。这个特性用起来很方便但也容易造成误解。如果子组件的根元素是原生buttonclick能触发如果根元素是另一个组件或者根元素被v-if动态切换导致不一致事件可能绑定不到预期的节点。这时就需要用useAttrs或v-bind$attrs手动处理。3. 两版核心差异对比两套机制最核心的区别可以概括成一句话Vue2的事件是“实例附属能力”想怎么发就怎么发Vue3的事件是“组件接口契约”必须先声明才能被正确识别。3.1 事件声明、校验与监听器管理差异Vue2不需要声明事件任意$emit事件都可以被监听。Vue3必须显式声明emits否则组件上的事件监听会被当作原生事件处理。声明后还可以像校验props一样校验事件参数开发时提供更好的提示。下面是整理好的对照表也是我面试时最喜欢用的卡片对比项Vue2Vue3事件声明不需要声明emits选项或defineEmits实例事件API$emit / $on / $off / $once仅$emitsetup中的emit事件总线使用Vue实例推荐mitt等第三方库$listeners独立对象合并入$attrsv-model事件名inputupdate:modelValue原生事件处理通过$listeners透传未声明事件自动透传事件参数校验无支持emits对象校验3.2 $on/$off/$once移除后事件总线怎么办Vue3移除了组件实例上的$on、$off、$once所以事件总线不能直接用Vue实例实现。想要全局事件通信有两个常规选择使用外部库mitt或tiny-emitter或者自己封装一个基于Map的发布订阅模块。mitt只有200多字节API和$on/$off非常像是Vue3事件总线的主流替代。示例// eventBus.js import mitt from mitt export const eventBus mitt()组件内使用import { eventBus } from /utils/eventBus import { onMounted, onUnmounted } from vue onMounted(() { eventBus.on(refresh, handler) }) onUnmounted(() { eventBus.off(refresh, handler) }) function handler() { // 处理刷新逻辑 }一定要在onUnmounted中移除监听避免泄漏。这和Vue2的beforeDestroy中$off是同一个道理。mitt的off如果不传handler会清空该事件的所有监听器如果连事件名都不传可以用all.clear()清空全部。业务中一般用精确的事件名handler配对卸载足够了。3.3 $listeners的变化与事件透传父组件模板监听自定义事件Vue2和Vue3写法相同都是eventNamehandler。差别在事件透传上Vue2里父组件监听的处理器会进入$listeners通过v-on$listeners透传Vue3移除了$listeners事件监听器被合并到$attrs中通过v-bind$attrs透传。假设组件A包含组件BB包含CC要触发A的事件。Vue2可以在B里用v-on$listeners把A的监听器透传给CVue3中可以在B上用v-bind$attrs把父组件的监听器透传给C前提是B没有消费该事件即B的emits里没有声明这个事件。实测下来这种透传在封装高阶表单组件时非常有用。3.4 事件命名策略的坑Vue2官方推荐kebab-case但不强制很多团队用camelCase也跑得正常。Vue3官方文档建议事件名始终使用kebab-case。原因是HTML模板渲染时大小写不敏感如果组件内部用camelCase声明事件在模板中监听时可能因为大小写问题出现不匹配。我在项目中统一使用kebab-case命名事件例如form-submit、update-data。这样无论从emits声明、defineEmits还是模板绑定都不需要转换减少迁移时的事件匹配问题。4. 实操案例从Vue2迁移到Vue3的事件改造理论说了一堆直接看实操。我把自己实际迁移一个后台管理系统的经验拆成几个典型case每个都有Vue2版本和Vue3版本对比。4.1 最简单的事件通知改造Vue2子组件// Child.vue export default { methods: { submit() { this.$emit(form-submit, formData) } } }Vue3组合式写法!-- Child.vue -- script setup const emit defineEmits([form-submit]) function submit() { emit(form-submit, formData) } /script父组件模板不变Child form-submithandleSubmit /如果子组件没有用script setup也可以这样export default { emits: [form-submit], setup(props, { emit }) { function submit() { emit(form-submit, formData) } return { submit } } }这种改造是所有迁移里最轻松的只改子组件内部父组件监听代码完全不用动。4.2 v-model二次封装组件改造实际项目里最难改的是二次封装。以带防抖的输入框为例Vue2版本template input :valuevalue inputonInput / /template script export default { name: BaseInput, props: { value: String }, methods: { onInput(e) { this.$emit(input, e.target.value) } } } /script父组件BaseInput v-modelkeyword /Vue3版本template input :valuemodelValue inputemit(update:modelValue, $event.target.value) / /template script setup defineProps({ modelValue: String }) const emit defineEmits([update:modelValue]) /script父组件写法完全不变。注意如果沿用Vue2的value/input事件Vue3的v-model不会生效。必须改成modelValue和update:modelValue。如果项目用的是Vue 3.4还可以用defineModel简化script setup const model defineModel({ type: String }) /script template input v-modelmodel / /template但迁移时我建议先用标准写法等熟悉语义后再用defineModel优化避免老项目边界场景踩到新API的坑。4.3 EventBus迁移到mitt实战老项目用Vue2的EventBus做全局刷新通知迁移步骤如下。先安装依赖npm install mitt然后新建事件中心// src/utils/eventBus.js import mitt from mitt const emitter mitt() export default emitter在使用文件中把原来的this.$bus.$emit改成eventBus.emit// 原来Vue2 this.$bus.$emit(user:logout) // Vue3中 import eventBus from /utils/eventBus eventBus.emit(user:logout)监听的地方改成import eventBus from /utils/eventBus import { onMounted, onUnmounted } from vue onMounted(() { eventBus.on(user:logout, handleLogout) }) onUnmounted(() { eventBus.off(user:logout, handleLogout) })这里有个细节Vue2的$bus如果是在main.js里挂载到原型上很多组件用的是this.$bus.xxx。Vue3中虽然也可以用app.config.globalProperties.$bus emitter继续用this.$bus但我更推荐在组件里显式import。原因很直接全局挂载会让依赖不透明排查时还要去找挂载代码显式导入一看就知道当前组件依赖了哪个事件总线。4.4 事件透传改造Vue2里透传事件监听器!-- Vue2 中间组件 -- Child v-on$listeners /Vue3里改成!-- Vue3 中间组件 -- Child v-bind$attrs /前提是中间组件没有在emits中声明这个事件。如果声明了该事件会从$attrs中移除因为它在当前组件被“消费”了。这个设计非常符合“组件接口契约”的思想事件在哪里声明就在哪里管理跨级透传反而变成显式的v-bind$attrs。5. 常见问题与排查技巧实录迁移和日常开发中会遇到各种奇奇怪怪的问题我整理了一些高频场景和排查思路。5.1 事件触发了但父组件没反应这是最常见的现象可能原因有三个事件名大小写不一致。子组件用camelCase父组件模板用kebab-case有些环境能匹配有些不能。统一用kebab-case最稳。emits漏声明。Vue3里如果没有在emits中声明事件不会被当作自定义事件处理而是被当作原生事件透传到根元素。如果根元素恰好是原生节点事件只是“看起来”绑定了自定义逻辑根本没跑。多级透传时事件绑定到了错误的节点。用v-bind$attrs时事件可能被重复绑定或覆盖。排查方法打开浏览器Vue DevTools看组件的事件面板确认emits是否定义、事件是否触发、监听器是否存在。这一步通常能快速定位问题。5.2 Vue3里使用$on直接报错报错内容一般是this.$on is not a function。这不是代码写错了而是Vue3根本没有这个API。替代方案看场景父子通信用emit全局通信用mitt状态共享用Pinia想监听Store变化用Pinia的subscribe。不要试图自己去Vue原型上补方法那样会破坏框架的事件响应式逻辑后续维护很难受。5.3 v-model绑定后输入框值不更新二次封装组件时最容易犯的错子组件里把modelValue直接绑定到了原生input的:value但没有监听input事件去emit(update:modelValue)导致父组件的数据永远不更新。还有可能是使用了value/input老事件名在Vue3里不生效。正确姿势是在input中取到新值后立刻emit。如果需要防抖记得把emit放在防抖回调里而不是单独在input里直接emit。5.4 emits参数校验过于严格导致警告Vue3的emits对象校验返回false时开发控制台会有警告。这个功能适合给组件使用者提示参数问题但不要写得太严格。我见过有人这样写emits: { update-data: (payload) payload.id 0 }结果上游某次接口返回payload提前被改了变成undefined每次触发都报警告页面控制台一堆红色。后来改成默认值兜底才消停emits: { update-data: (payload) { if (!payload) return true return payload.id 0 } }校验的目的是给开发者提示不是限制业务逻辑别让校验反而成了业务bug。5.5 事件总线在大型项目里的失控问题事件总线用多了项目会变成一团乱麻。全局事件满天飞组件之间隐式耦合后期改一个事件名要全局搜索还有可能漏改。我的原则是如果只是简单的兄弟通信或跨层通信就用props/emits逐层传如果通信频率高、范围广优先考虑Pinia只有临时性、低频率的全局通知比如登录后刷新用户信息才用mitt事件总线。如果你决定用mitt务必建立前缀命名规范比如user:update、cart:clear避免事件重名互相干扰。6. 迁移中的几个额外细节最后补几个边角细节虽然不算显眼但实际项目中总会遇到。6.1 多个v-model绑定Vue3支持多个v-model参数Child v-model:titletitle v-model:contentcontent /子组件对应声明defineProps({ title: String, content: String }) const emit defineEmits([update:title, update:content])内部触发事件时分别传update:title和update:content。这个能力在封装复杂表单控件时非常实用比Vue2的.sync修饰符更统一。6.2 自定义事件在播放器封装中的实际意义很多人问“vue播放m3u8”到底怎么玩其实自定义事件在播放器组件里非常重要。拿一个基于hls.js封装的播放器组件举例Vue3版本往往会这样定义事件const emit defineEmits([loaded, play, error])父组件使用video-player :srcurl loadedonLoaded playonPlay erroronError /播放器加载成功、播放状态变化、网络异常都需要通过自定义事件通知外部。事件命名是否清晰直接决定这个组件好不好用。这个场景也说明自定义事件不只是面试题它是组件通信的“门面”值得认真对待。6.3 面试答题框架Vue面试题里碰到“Vue2和Vue3自定义事件有何区别”可以按“声明—触发—监听—透传—总线”五个维度展开Vue2不声明、$emit/$on、$listeners透传、用Vue实例做总线Vue3用emits/defineEmits声明、emit触发、$attrs透传、推荐mitt替代。再把v-model的事件名变化讲出来这个答案就足够完整了。最后说点切身体会。在维护Vue2老项目时遇到自定义事件出问题第一反应就是全局搜$emit/$on但在Vue3新项目里我更倾向于先看组件的emits定义像看接口文档一样。习惯了这种显式声明之后你会发现代码的可维护性提升不是一点点。如果你们团队正准备把Vue2项目迁到Vue3我的建议是先别急着改业务逻辑先把公共组件的事件定义统一梳理一遍列一个事件清单再动工改造能省掉大量“事件不触发”的排查时间。