尧图精选

Vue 2 $listeners完全指南:原理、透传与组件封装实战

🕒 发布时间:2026/9/15 4:56:49 📁 来源:尧图网络
做 Vue 2 开发的老哥们估计都遇到过这种烦心事子组件里 $emit 了一个事件我这个中间层组件不仅没做任何处理还得像个传话筒一样手动转发给父组件要是事件一多代码里全是 myEvent(payload) $emit(myEvent, payload) 这种转发代码。更复杂的情况是子组件发生事情后想让父组件先处理一把处理完的结果再传回子组件接着处理这时候 $listeners 就是那个能把整个链路理顺的关键。我第一次真正被 $listeners 惊艳到是在封装一个通用筛选栏组件的时候。筛选栏里有一堆表单项输入框、下拉框、日期选择器每个都有一堆 change、blur、focus、clear 之类的事件需要抛到最外层的页面组件。如果每个事件都在中间层手动 $emit 一遍组件一多根本维护不过来。后来翻了好几天源码才发现 $listeners 这个官方后门可以直接把父组件绑定的监听器原封不动往下传几行代码解决所有转发问题。这篇文章我就把这个东西讲透包括它是什么、怎么用、怎么配合 props 和 .sync 形成完整通信闭环以及我踩过的那些坑。1. 先搞明白$listeners在这出戏里到底扮演什么角色1.1 从一次偷懒开始我真的不想一层层转发事件写 Vue 2 项目两三年的老哥估计都有过这种经历——某个页面里套了三层组件页面组件 A - 列表包装组件 B - 列表项组件 C。C 组件是真正的业务当事人B 组件只是一个中转站但在没有搞懂 $listeners 之前我们只能在中转站一层一层转发事件。当一个列表项需要抛给页面十几种事件时B 组件里就是一场灾难满屏的 $emit看得人头大。实际上我当年第一次遇到这个痛点就是在封装通用筛选栏的时候。筛选栏里的每个表单项各自会触发 blur、change、select、clear 等事件如果每个事件都在中间层手动 emit 一遍组件一多根本维护不过来。后来翻源码才发现$listeners 这个官方后门可以直接把这些监听器原封不动往下传。它最核心的价值就是解决多层组件嵌套时事件转发的代码冗余问题让你不用当一个纯粹的传话筒。1.2 $listeners的本体父组件绑定的监听器快照严格来说$listeners 是 Vue 2 实例上的一个属性它的值是一个对象里面存放的是父组件在当前组件上绑定的事件监听器。所谓绑定的事件监听器指的就是那些在父组件模板里通过 clickxxx、update:valxxx 等方式写的回调函数。这个对象的结构大致是这样的{ // 键名是事件名键值是父组件传下来的回调函数 change: function () { /* 父组件里的处理逻辑 */ }, update:value: function (val) { /* ... */ } }这里有个容易忽略的点$listeners 里的事件不包括用 .native 修饰符绑定的监听器。原因很简单.native 表示监听这个组件根元素的原生事件它绑定的是根元素本身不会进入到子组件的事件监听器名单里。这一点后面我会专门展开讲。另外一个值得了解的点是$listeners 的值会随着父组件的重渲染而更新。因为父组件重新渲染时新的监听器函数会被解析出来最终同步到子组件实例上。这也就意味着如果你在某处缓存了 $listeners 的引用并且长时间不释放可能拿到的是一份过期数据。我在业务代码里一般不会刻意缓存它但在写自定义指令或者脱离响应式系统操作组件实例的时候会格外注意这一点。1.3 标题里那句话翻译成专业术语是啥意思用大白话说$listeners 能实现子组件发生事情先让父组件处理处理完再给子组件接着处理这种绕来绕去的需求。但用组件通信的专业术语翻译一下实际上是三层意思叠加第一层子组件通过 $emit 抛事件。第二层父组件通过 语法监听这个事件并且执行自己的业务处理——比如校验数据、请求接口、修改状态。第三层父组件处理完之后把结果作为新的 props 回传给子组件子组件通过 watch 或者其他机制感知到 props 变化继续执行后面的事情。$listeners 在这个链路里负责的是第二层的传输效率。因为如果中间隔着一个甚至多个包装组件普通手写转发会非常繁琐而 v-on$listeners 一行就能把事件监听器贯穿到底。所以如果你也在写 Vue 2 的封装组件、通用业务组件或者被多层事件转发搞得头大$listeners 基本就是你绕不开的东西。接下来我会用一个完整的例子把这套抛上去、处理完、传下来、接着干的通路整个还原一遍。2. 核心场景实操子组件抛事父组件处理再交给子组件接着干2.1 场景定义与整体思路先定义一下我们要实现的场景。假设我在做一个订单列表页面列表项组件 OrderItem 里有一个点击申请退款的按钮。业务要求很明确OrderItem 不希望自己直接控制退款流程它只负责抛事件和展示结果。整个流程是OrderItem 点击按钮后向外部抛出一个事件父组件订单列表页面监听到这个事件去请求退款接口做合法性和余额校验父组件拿到接口返回结果后把退款状态、提示文案等作为 props 回传给 OrderItemOrderItem 监听到 props 变化后弹窗提示、更新按钮状态。在这个场景中加入一个中间层组件 OrderList用于统一管理所有订单项的布局和公共属性。如果不使用 $listeners那么 OrderList 里得这样写!-- OrderList.vue 中间层的手动转发写法不建议用在复杂场景 -- template div classorder-list OrderItem v-foritem in orders :keyitem.id :orderitem apply-refund(...args) $emit(apply-refund, ...args) / /div /template一个事件还行如果业务里有十几个甚至几十个事件都要从孙组件透传到顶层这种写法会让中间层组件越来越臃肿越来越难维护。而使用 $listeners 后中间层只需要写一句 v-on$listeners就能把所有父组件绑定在 OrderList 上的事件监听器一次性原封不动地传给 OrderItem。整体结构立刻清爽很多后续就算父组件新增一个事件中间层也完全不用改代码。2.2 代码还原孙组件触发、中间层透传、父组件处理下面把三层组件的具体实现写出来。先看最内部的 OrderItem它只管抛事件和响应 props!-- OrderItem.vue 真正的业务组件 -- template div classorder-item div{{ order.name }} - {{ order.price }}元/div button :disabledrefundStatus ! idle clickhandleRefund {{ refundStatus refunding ? 退款中... : 申请退款 }} /button div v-ifrefundMessage{{ refundMessage }}/div /div /template script export default { name: OrderItem, props: { order: { type: Object, required: true }, // 父组件处理完退款流程后回传过来的状态 refundStatus: { type: String, default: idle // idle | refunding | success | fail }, refundMessage: { type: String, default: } }, methods: { handleRefund() { // 仅抛事件具体如何处理交给父组件 this.$emit(apply-refund, this.order) } }, watch: { // 父组件处理完之后props 变化子组件接着自己的逻辑做 refundStatus(newVal) { if (newVal success) { // 比如上报日志、滚动定位等自己这边的后续处理 console.log(退款成功本地补做一些清理) } } } } /script这个组件不会直接处理退款事务它只负责抛出 apply-refund 事件并且观察父组件回传的 refundStatus。注意这里我特意加了一个 watch对应标题里说的子组件接着处理——父组件把结果传回来之后子组件可以立刻响应并执行后续动作。接下来是中间层 OrderList。在没有 $listeners 的情况下它需要手动转发事件有了 $listeners代码极其简单!-- OrderList.vue 中间层组件 -- template div classorder-list OrderItem v-foritem in orders :keyitem.id :orderitem :refund-statusitem.refundStatus :refund-messageitem.refundMessage v-on$listeners / /div /template script import OrderItem from ./OrderItem.vue export default { name: OrderList, components: { OrderItem }, props: { orders: { type: Array, required: true } } } /script注意关键的一行v-on$listeners。这行代码会把父组件订单页面绑定在 OrderList 上的所有事件监听器全部传给 OrderItem。这样 OrderItem 内部 $emit(apply-refund) 时实际上是直接触发了最顶层父组件里绑定的回调函数。这也就解释了为什么叫透传事件监听器并不在中间层停留它就像一条直通管道从父组件的模板直接通到最底层的业务组件。你不需要在 OrderList 的 methods 里写任何转发函数只需要关心父组件最终要谁来处理这个事件。最后是父组件 OrderPage它负责真正处理退款!-- OrderPage.vue 父组件 -- template div OrderList :ordersorders apply-refundhandleApplyRefund / /div /template script import OrderList from ./OrderList.vue export default { name: OrderPage, components: { OrderList }, data() { return { orders: [ { id: 1, name: 机械键盘, price: 399, refundStatus: idle, refundMessage: } ] } }, methods: { handleApplyRefund(order) { // 父组件先处理做校验、模拟请求 order.refundStatus refunding order.refundMessage 正在为你退款... // 模拟异步接口 setTimeout(() { if (order.price 100) { order.refundStatus success order.refundMessage 退款成功原路返回 } else { order.refundStatus fail order.refundMessage 退款失败请联系客服 } }, 1000) } } } /script到这里完整的链路已经跑通了OrderItem 内部点击按钮 - $emit(apply-refund) - 监听器被 $listeners 透传到父组件 - 父组件 handleApplyRefund 处理 - 更新 orders 里的 refundStatus - 通过 props 传回 OrderItem - OrderItem 的 watch 感知到 refundStatus 变化继续执行自己的逻辑。整个链路中中间层只是通路不掺和业务。2.3 处理结果如何回到子组件并继续处理有人可能会问父组件把处理结果传回来这不就是普通的 props 传递吗跟 $listeners 有什么关系其实关键点是从哪儿传回来。如果没有 $listeners中间层 OrderList 就得手动转发事件而且由于父子和层级的 props 绑定中间层还得替孙组件维护一份中转 props处理起来非常烦。$listeners 解决的是上游通路的效率问题让事件可以在多层组件中无损直达父组件下游通路props 回传是 Vue 本来就支持的能力两个搭配起来才形成完整的闭环。这里有几个容易踩的坑我先提醒一下父组件更新 order 对象时要保证 order 是响应式对象。上面例子中我在 data 里直接定义好结构后续通过赋值修改属性Vue 2 能正常响应。但如果父组件是拿接口数据动态塞进数组的要小心新增属性不响应的问题常见做法是先预设好字段或者用 this.$set 处理。OrderItem 中 watch refundStatus 触发时如果同时还有别的 props 变化建议在 watch 里只做响应展示类逻辑不要把复杂的业务请求也塞进去否则很容易造成监听循环或者隐式耦合。更稳妥的做法是把父组件处理和子组件接着处理的职责边界划清楚父组件管数据子组件管界面表现。如果你中间层有多个子组件都要监听同一个事件v-on$listeners 会把同一个监听器同时绑定给多个子组件这可能导致事件被触发多次。实际开发时我一般会给 $listeners 加一层筛选或者明确每个子组件只接收属于自己的事件子集。后面我会专门讲怎么筛选。3. 从原理到写法为什么你以前总觉得组件事件传递别扭3.1 常规方案手动转发事件的问题在没有 $listeners 或不会用它的 Vue 2 项目里跨层级事件通信通常有这么几种方案第一种是逐层手动转发。子组件 $emit中间层组件监听后再 $emit 一次再到父组件。这种方式逻辑直观但是代码冗余而且事件越来越多时中间层会变成一团乱麻一个组件里塞满了不属于自己的业务回调。第二种是引入 Event Bus也就是把 Vue 实例当作一个全局事件中心。这种方式跨层省事但事件是全局广播的过一段时间你就分不清谁发的、谁收的调试起来非常痛苦。而且一旦忘记 off 掉监听器内存泄漏也是实打实的问题。第三种是使用 Vuex 这类集中式状态管理。组件不再通过事件通信而是所有组件都从 store 里取数据和触发 mutation/action。这种方式适合大型应用但对于局部领域、低频交互来说往往显得杀鸡用牛刀而且样板代码很多一个简单的 ajax 请求结果回传也要写一堆 state、mutation、action。$listeners 的存在恰好给逐层手动转发这个场景提供了更优雅的答案。它不是一个需要引入的第三方方案而是 Vue 2 内置的能力。它的价值在于当你的组件层级很深、事件很多时你可以不用写一堆转发的 $emit直接把监听器对象转发到底层组件。对比上面三种方案$listeners 是最贴合 Vue 2 原生设计哲学的那一个。3.2 $listeners的透传写法与手写转发对比先放一个直观对比。假设有 5 个事件需要从最底层传递到最顶层手写转发版template ChildOne change(...args) $emit(change, ...args) select(...args) $emit(select, ...args) blur(...args) $emit(blur, ...args) focus(...args) $emit(focus, ...args) clear(...args) $emit(clear, ...args) / /template$listeners 版template ChildOne v-on$listeners / /template如果中间层还嵌套了一层手写转发会指数级痛苦$listeners 却只需要在每一层写一行相同的代码即可。有人担心 v-on$listeners 会不会把父组件绑定给中间层自身的所有事件都传下去导致事件乱飞答案是它确实是全量透传的这也是它简单高效的代价。如果你需要筛选可以用计算属性处理后再传。这个技巧我放在第 4 部分详细讲。从代码维护角度来说手写转发还有一个隐形问题你每新增一个事件就得在中间层和底层组件两层各改一次代码漏一个就出现事件走到半路断了的诡异 bug。而 $listeners 是动态的父组件新增了监听器它就能传下去中间层不需要跟着改。这一点对多人协作的团队特别重要因为中间层的维护者根本不需要关心上游到底挂了哪些事件。3.3 与.sync、$emit、props一起用才是完整的通信姿势$listeners 不是孤立的工具它有一个非常经典的搭档.sync 修饰符。熟悉 Vue 2 的童鞋都知道.sync 本质上是父子组件之间数据双向绑定的语法糖它在子组件上绑定的事件名是 update:xxx。这个 update:xxx 监听器也会出现在 $listeners 对象里。举个例子。假如我给一个通用配置组件 ConfigPanel 加了 .sync 绑定 visibletemplate ConfigPanel :visible.syncpanelVisible / /template子组件 ConfigPanel 内部不管是自己用还是继续传给更底层的组件都可以这样处理template BaseModal :visiblevisible v-on$listeners / /template这样 BaseModal 里执行 this.$emit(update:visible, false) 时事件会顺着 $listeners 一路向上最终改变父组件的 panelVisible。这种写法在封装半成品组件时非常常见外层组件负责样式和排版把状态变化的事件原封不动交给调用方。props 这边也一样v-bind 可以和 v-on 同时使用。$listeners 负责事件通路$attrs 负责非 props 属性通路两者合起来写就是template BaseComponent v-bind$attrs v-on$listeners / /template script export default { name: WrapperComponent, inheritAttrs: false } /script这算是 Vue 2 里透传封装组件的经典姿势。我再强调一次inheritAttrs: false 是为了阻止外部传入的属性自动挂到当前组件根元素上否则在用 $attrs 手动指定位置时外部属性会被默认地挂在根元素上冲突和意外就来了。4. 高阶玩法$listeners在组件封装里的实战价值4.1 封装第三方UI组件时事件透传不漏的标配写法我自己实际项目里用到 $listeners 最多的地方就是对第三方 UI 库进行二次封装。以 Element UI 为例el-input 有 change、blur、focus、input、clear 等十来个事件如果我在公司内部封装一个 HkInput 组件希望调用方依然能像使用原生 el-input 一样绑定这些事件最省力的方式就是 v-on$listeners 透传。封装后的用法template HkInput v-modelkeyword placeholder输入关键词搜索 focushandleFocus clearhandleClear keyup.enterhandleSearch / /templateHkInput 内部不需要逐个声明这些事件是干嘛的只需要写好布局和业务逻辑然后把监听器透传给底层的 el-input!-- HkInput.vue -- template div classhk-input-wrapper el-input v-bind$attrs v-on$listeners / /div /template script export default { name: HkInput, inheritAttrs: false } /script这个组件里我没有显式声明 props所有外部传入的非事件属性都会收集到 $attrs 中由 el-input 去消化所有事件监听器都会收集到 $listeners 中也由 el-input 去消化。这样 HkInput 就像一个透明的外壳既能扩展自己的样式和结构又不会挡住原组件的能力。这里有一个细节要注意$listeners 里的事件监听器最终绑定到了 el-input 上而不是绑定到 HkInput 的根元素 div.wrapper 上。所以调用方在用 HkInput 时如果写了 click它指的是 el-input 触发的 click 事件而不是包裹层 div 的 click 事件。如果你希望点击包裹层也能触发需要自己在 HkInput 内部把 div 的 click 事件再转发一次不要指望 $listeners 替你处理。4.2 在computed里对listeners做增删实现事件拦截$listeners 是一个普通对象这意味着你可以在计算属性里把它取出来加工后再透传。最常见的需求是大部分事件透传但某一个事件要先在中间层处理一下再决定是否往上抛。比如我在封装一个图片上传组件时底层组件在失败时抛出 fail 事件父组件需要提示错误。正常情况下我可以直接透传 fail但业务要求中间层要先记录一条日志再放行给父组件。这时候可以这样写template BaseUploader v-bind$attrs v-onfilteredListeners / /template script export default { name: LogUploader, inheritAttrs: false, computed: { filteredListeners() { const listeners { ...this.$listeners } const rawFail listeners.fail if (rawFail) { delete listeners.fail listeners.fail (...args) { // 中间层先处理自己的逻辑 console.log(记录失败日志, ...args) // 再调用父组件的监听器 rawFail(...args) } } return listeners } } } /script这个技巧的核心是先展开再加工后透传。注意不要直接在 $listeners 对象上做 delete因为 $listeners 是响应式对象直接在它上面改动可能会影响其他逻辑也不安全。先展开成新对象再在新对象上操作是最稳妥的。我自己还遇到过一种情况需要把同一个监听器细化后再分发到不同的内部组件。比如外层一个复合组件内部有多个分区父组件只关心整体值变化中间层则拆解成 aChange 和 bChange 两个事件。这时也完全可以通过 filteredListeners 对原始事件做降级、合并、拆分。所以 $listeners 表面上是一行透传但真要用好它的可塑性非常强。4.3 render函数里手动绑定事件监听器除了模板语法在 render 函数中也经常能看到 $listeners 的身影。尤其是写高阶组件或者抽象组件时render 函数的控制力比模板更强可以更精细地决定事件绑定在哪里。// 一个极简的高阶组件工厂 export function createWrapper(Component, extraProps {}) { return { name: WrapperComponent, inheritAttrs: false, render(h) { const children this.$slots.default return h( Component, { attrs: this.$attrs, props: extraProps, on: this.$listeners, scopedSlots: this.$scopedSlots }, children ) } } }这里的 on: this.$listeners 就是把父组件绑定在当前组件上的所有事件全部绑定到目标组件上。在 render 函数里事件监听器的键值对直接作为 VNode 的 data.on 传入Vue 2 的虚拟 DOM 渲染过程会帮你完成绑定。对比模板写法render 函数的优势是可以在 JavaScript 的逻辑里对监听器进行更灵活的条件判断、循环追加适合封装一些需要动态生成组件的场景。不过要注意在 render 函数中直接传 on 有一个隐藏坑events 对象会被整体替换而不是增量合并。如果你既想透传 $listeners又想自己额外绑定一个 click 事件正确写法是像上面 4.2 一样先展开、合并再传入。直接写 on: { click: this.handleClick, ...this.$listeners } 也没问题但要记住顺序确保自己绑定的监听器不被 $listeners 里同名的覆盖。否则你会遇到我明明在组件里写了一个点击处理怎么就是不执行的诡异问题调试半天发现是顺序反了。5. 常见问题与排查技巧实录5.1 为什么我写了v-on$listeners事件还是没反应这是最常见的问题。我遇到的情况主要分三种你可以挨个排查第一种中间层组件根本没有把 $listeners 绑定到正确的孙组件上。比如你写在了模板的某个普通 div 上而不是孙组件上。$listeners 绑在哪个元素上监听器就落在哪个元素上如果你的孙组件是嵌套在 div 里面的事件根本不会到达孙组件。第二种孙组件里 $emit 的事件名跟父组件绑定的事件名对不上。$listeners 传的是父组件绑定的监听器孙组件 $emit(xxx) 时只会触发同名的监听器拼写多了个空格都会没反应。我见过一个案例父组件绑定 to-upper子组件 $emit(toUpper)看起来差不多实际上事件名完全不一致监听器永远触发不了。第三种中间层虽然写了 v-on$listeners但是父组件绑定事件时用了 .native 修饰符。前面提过.native 修饰符不会进 $listeners它只负责在组件根元素上添加原生事件监听。所以如果你在中间层想要透传一个 click.native那是透传不了的因为 click.native 本来就绑定在当前组件根元素上它的执行路径跟 $listeners 是两条线。我自己的排查顺序一般是这样先在中间层组件里 console.log(this.$listeners)看看对象里到底有没有父组件绑定的监听器再在孙组件里 console.log 一下确认事件名是否一致如果都正常再看有没有被 .native 修饰符截胡。一般这三步走完问题就定位了。5.2 $listeners和.native修饰符的相爱相杀这里专门展开说一下 5.1 里提到的 .native。Vue 2 中如果父组件这样写HkInput focus.nativehandleFocus /那么 handleFocus 不会出现在 HkInput 实例的 $listeners 中而是作为原生事件监听器直接绑定在 HkInput 的根元素上。也就是说$listeners 是非 native 事件的集合native 事件走的是另一条通道。这个设计在使用上要注意如果你封装了一个组件根元素是一个 div而调用方用 click.native 监听点击那点击事件会落在 div 上。如果你想把这个点击转发给内部的 el-input.native 那条通道是做不到的你只能在内部手动 $emit(click) 再透传或者让调用方改用非 native 的 click 并配合 $listeners 透传。从组件封装角度说我倾向建议团队内部约定对外暴露的自定义事件一律用非 native 写法原生事件需要监听时尽量借助 $listeners 透传到真正的 DOM 元素上。这样组件的边界更清晰不会出现监听器到底挂在哪个根元素上这种玄学问题。5.3 $attrs和$listeners是不是必须成对出现严格来说不是必须成对但它们确实经常一起出现。这里面的逻辑并不复杂一个完备的透传包装组件既要让外部传入的非 props 属性比如 el-input 的 placeholder、maxlength、type顺利落到目标组件上也要让外部绑定的事件监听器比如 input、change顺利挂到目标组件的事件通道上。属性走 $attrs事件走 $listeners两套通道各管各的缺一个封装组件的能力就不完整。如果你只透传事件父组件设置的 placeholder 就会落在包装组件的根元素上底层 el-input 拿不到这个属性最终展示出来的输入框没有占位提示甚至可能因为多余属性挂到根元素上而出现样式异常。反过来只透传属性但忽略事件父组件虽然能看到 UI 状态却接不到组件内部的各种交互通知等于一个聋子。所以我在实际开发中很少把 $listeners 单独拆出来用基本都和 $attrs 成对出现同时配合 inheritAttrs: false 使用。5.4 升级Vue 3后$listeners去哪儿了这是一段技术演进说明很多老项目在迁移 Vue 3 时会惊讶地发现this.$listeners 怎么变成 undefined 了这是 Vue 3 有意设计的变化。在 Vue 3 中$listeners 被合并进了 $attrs所有属性包括事件监听器都被当作普通的 attrs 来传递。例如父组件绑定 changehandler在子组件中可以通过 $attrs.change 取到这个监听器如果不做特殊处理组件根节点会自动继承这些 attrs。这意味着在 Vue 3 里写透传组件只需要 v-bind$attrs不需要 v-on$listeners。同时事件声明也从 Vue 2 的不需要显式声明变成了建议在 emits 选项中显式声明以便框架在未声明的事件发到根元素时给出警告。这是一个更加统一、更加声明式的设计但它确实改变了不少老 Vue 2 开发者的习惯。如果你当前还在维护 Vue 2 项目$listeners 依然是必须掌握的核心特性如果打算迁移建议在迁移初期就统一把透传组件的双写改成 Vue 3 的单写模式这类改动通常不会影响业务逻辑风险很低。这部分内容主要是帮大家做技术认知上的衔接方便后续重构时心里有底。最后再分享一个小技巧如果你和我一样经常封装表单类组件建议在公司内部沉淀一个透传组件模板里面写好 inheritAttrs: false、v-bind$attrs、v-on$listeners 这三件套然后把常见事件列成一个清单放到注释里。后续每个开发遇到类似需求直接拿模板改业务逻辑就行效率会高很多。这个习惯我用了两三年确实是省心又不会踩漏事件。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →