Vue 将事件系统拆分为原生 DOM 事件与自定义组件事件两套正交实现,前者对接浏览器事件循环,后者基于发布–订阅模型。本文以 v-on(缩写 @)为线索,结合运行时源码路径,给出端到端的实现剖析。
一、架构概览
Vue 的事件绑定分为两条主线:
原生事件绑定
通过 @click 或 v-on:click 直接作用于普通 DOM 元素,最终调用浏览器的 addEventListener。
组件事件绑定
通过 @click 作用于子组件标签时,实际上是父组件监听子组件的自定义事件,由子组件通过 $emit 触发,不经过 DOM。
二、原生事件绑定:从 AST 到 addEventListener
1.编译阶段
模板中的 @click="handler" 经模板编译器解析后,生成 AST,最终转化为 VNode 的 data.on = { click: handler }。
2.运行时挂载
首次渲染时,patch 过程会调用 createElm,为真实 DOM 节点执行 invokeCreateHooks,其中 cbs.create 包含 updateDOMListeners(位于
src/platforms/web/runtime/modules/events.js)。
updateDOMListeners 的职责:
- 归一化事件名,处理 IE 兼容性差异。
- 生成包裹函数,处理 .once、.passive、.capture 等修饰符。
- 调用 updateListeners → add → target.addEventListener(type, wrappedHandler, useCapture)。
3.更新阶段
当组件更新时,patch 再次调用 updateDOMListeners,通过 sameVnode 判断事件差异,按需移除旧事件并重新绑定新事件。
三、组件事件绑定:on + events + emit
1.父组件编译
<Child @click="handleClick" /> 编译后,VNode 的
componentOptions.listeners = { click: handleClick },不会出现在 DOM 属性上。
2.子组件初始化
子组件实例化时:
- initInternalComponent 将父级 listeners 注入到 vm.$options._parentListeners。
- initEvents 创建 _events = Object.create(null) 作为事件中心。
- 若 _parentListeners 非空,执行 updateComponentListeners(vm, _parentListeners),内部通过 $on 注册事件:
3.手动触发
子组件内部调用 this.$emit('click', payload) 时,执行:
整个过程与浏览器事件体系完全隔离,因此可跨层级通信,且参数可控。
四、.native:在组件根节点强制使用原生事件
<Child @click.native="handler" /> 编译为 nativeOn 而非 on,运行时由 updateDOMListeners 读取 nativeOn,流程与原生事件一致,绑定在组件根 DOM 上。
五、事件修饰符实现细节
- .stop:包裹函数内调用 e.stopPropagation()。
- .prevent:包裹函数内调用 e.preventDefault()。
- .once:绑定后立即移除监听器,并标记 _withOnce。
- .passive:调用 addEventListener(type, fn, { passive: true })。
- .capture:第三个参数传入 useCapture: true。
六、性能与内存考量
- 原生事件由浏览器托管,Vue 仅在 VNode 销毁时执行 removeEventListener,无额外开销。
- 组件事件存储在 JS 对象,组件销毁时统一 $off,防止内存泄漏。
结论
Vue 事件系统通过“编译期转换 + 运行时调度”实现高度抽象:
- 原生事件:AST → VNode → patch → addEventListener,完全对齐浏览器。
- 组件事件:父子间通过 VNode.listeners → vm.events → emit,脱离 DOM,实现跨组件通信。
理解这一分层设计,有助于在复杂场景(服务端渲染、微前端、自定义渲染器)中精准定位事件相关问题。