写点什么

全面解读 Vue Vapor 的事件机制

作者:OpenTiny社区
  • 2025-07-30
    广东
  • 本文字数:6930 字

    阅读完需:约 23 分钟

全面解读 Vue Vapor的事件机制

本文由体验技术团队申君健原创。


在开发 TinyVue 组件库的过程中,Vue 框架的演进一直都是项目持续研究和关注的重点。近期 Vue 发布了 v3.6 版本,因此对 Vue Vapor 的事件机制也进行了一些实测。本文在 vue@3.6.0-alpha.2 版本中,对事件绑定原理进行了全面测试,以深入了解 Vapor 模式下的事件机制。

一、 全局事件委托

  • 传统的 Vue 事件模式

原理:在传统的 Vue3 中,每个 DOM 节点都有一个统一的事件挂载点:el._vei = {},其中键为事件名称,值为一个 invoker 函数。DOM 节点通过 addEventListener 方法将这个 invoker 函数绑定到相应的事件上。本质上,这仍然是浏览器的事件模式,每个事件都需要在监听的 DOM 元素上直接绑定一次。


  • Vue vapor 的委托事件模式

在 React 中,早就引入了“合成事件”的概念,通过将事件处理委托给 document 或挂载的 #app,并同时解决不同浏览器之间事件对象的差异问题。如今,Vapor 也采用了这一方法,让我们来了解一下它是如何实现的。


  • Demo 源文件

<script setup lang="ts" vapor>const add1 = () => {};const add2 = () => {};const add3 = () => {};</script>
<template> <div class="div1" @click="add1">add1 按钮</div> <div class="div2" @click="add2">add2 按钮</div> <div class="div3" @click="add3" @dblclick="add3">add3 按钮</div></template>
复制代码


  • Vapor 渲染结果:

_delegateEvents("click", "dblclick");   // 1、委托事件function _sfc_render(_ctx, $props, $emit, $attrs, $slots) {  const n0 = t0();  const n1 = t1();  const n2 = t2();  n0.$evtclick = _ctx.add1;          // 2、节点上藏一个私有属性记录处理函数  n1.$evtclick = _ctx.add2;  n2.$evtclick = _ctx.add3;  n2.$evtdblclick = _ctx.add3;  return [n0, n1, n2];}
复制代码


  • 委托的原理:

var delegatedEvents = /* @__PURE__ */ Object.create(null);// 1、记录所有委托事件。第一次遇到,才绑定事件名到全局处理函数,且永不解绑。var delegateEvents = (...names) => {  for (const name of names) {    if (!delegatedEvents[name]) {      delegatedEvents[name] = true;      document.addEventListener(name, delegatedEventHandler);    }  }};
复制代码


原理:在事件委托模式中,不再将 DOM 与事件处理函数直接绑定,而是将每种事件仅在 document 上绑定一次”全局统一的处理函数“。通过利用事件冒泡的特性,可以在根节点统一处理所有原本需要在节点上触发的事件。

这种方法大大减少了页面的事件监听器数量,特别是在表格等场景中。


  • 全局统一的处理函数:

// 2、全局处理函数var delegatedEventHandler = (e) => { // 2.1、 取出事件触发元素  let node = e.composedPath && e.composedPath()[0] || e.target;  if (e.target !== node) {    Object.defineProperty(e, "target", {      configurable: true,      value: node    });  }  Object.defineProperty(e, "currentTarget", {    configurable: true,    get() {      return node || document;    }  });
// 2.2、 while 模拟冒泡,如果当前节点有$evtXXX事件处理,则调用。 while (node !== null) { const handlers = node[`$evt${e.type}`]; if (handlers) { if (isArray(handlers)) { for (const handler of handlers) { if (!node.disabled) { handler(e); if (e.cancelBubble) return; } } } else { handlers(e); if (e.cancelBubble) return; } } // 2.3、 向上找父节点,继续上面的判断,直至根结点为止。 node = node.host && node.host !== node && node.host instanceof Node ? node.host : node.parentNode; }};
复制代码


原理: 该函数负责处理页面上的所有冒泡事件。当页面上发生事件时,由于点击元素上没有绑定处理函数,事件最终会冒泡到根节点的统一处理函数。在处理函数中,首先获取点击元素,然后向上遍历所有节点。依次判断每个元素是否绑定了与事件类型(e.type)相对应的事件,如果绑定了,则立即触发该事件。


在这里,我有一个疑问:

handlers 的执行没有使用 try{} catch() 语句包裹。如果事件中出现异常,会导致事件冒泡链路崩溃,整个应用异常。在传统 Vue 的事件调用中,是通过 callWithErrorHandling 来调用处理函数,并通过 handleError 来上报异常事件,这样 app.config.errorHandler 才能捕获错误。


那么 vapor 是否遗漏了异常处理和异常上报?


  • 冒泡路径


通过打印冒泡路径,可以清楚理解统一处理函数的冒泡原理。


  • 多个事件处理函数

在 const handlers = node[{e.type}]; 这一行下面,提到了 handlers 可能是数组形式。然而,我尝试了多种事件写法,都无法使其绑定一个数组。在 2022 年时,验证过:@click = "[ add1, ()=> add2() ]" 这种数组形式绑定是可以成功的,但在最新的演练场上,这种写法已经无法触发事件了。


后来阅读 Vue 源码,发现同一个事件绑定多个函数的情况,此时会编译成上述的数组形式的 handlers:


  • Demo 源文件

<script setup lang="ts" vapor>const addWithAlt = () => msg.value.push("with alt clicked");const addWithCtrl = () => msg.value.push("with ctrl clicked");</script>
<template> <div class="div1" @click.alt="addWithAlt" @click.ctrl="addWithCtrl">add 按钮</div></template>
复制代码


  • 其 Vapor 渲染结果为

// 1、仍然委托模式_delegateEvents("click");function _sfc_render(_ctx, $props, $emit, $attrs, $slots) {  const n0 = t0();  // 2、不再是 n0.$evtclick =..., 而是 delegate 多次  _delegate(n0, "click", _withModifiers(_ctx.addWithAlt, ["alt"]));  _delegate(n0, "click", _withModifiers(_ctx.addWithCtrl, ["ctrl"]));  return n0;}
复制代码


  • delegate 函数

// 3、多次调用时,生成 n0.$evtclick =[handler1,handler2...]function delegate(el, event, handler) {  const key = `$evt${event}`;  const existing = el[key];  if (existing) {    if (isArray(existing)) {      existing.push(handler);    } else {      el[key] = [existing, handler];    }  } else {    el[key] = handler;  }}
复制代码


至此,我们就明白全局的处理函数为什么要判断 handlers 是否为数组形式了。大家可以打开下面链接尝试:事件handlers为数组的示例

二、事件修饰符的实现

Vapor 的事件修饰符的实现简单了很多,在上面例子如果添加 @click.stop 的话,编译后:

  const n0 = t0();  const n1 = t1();  const n2 = t2(); //  模板代码   @click.stop="add1"  n0.$evtclick = _withModifiers(_ctx.add1, ["stop"]);  n1.$evtclick = _ctx.add2;  n2.$evtclick = _ctx.add3;
复制代码


  • withModifiers 的实现:

原理:withModifiers 函数返回一个经过包装的函数,该函数会依次执行修饰函数。修饰函数实际上是一种守卫函数,当条件不满足时,它会提前终止执行,从而避免调用与之关联的处理函数。

var systemModifiers = ["ctrl", "shift", "alt", "meta"];// 1、所有的修饰符函数var modifierGuards = {  stop: (e) => e.stopPropagation(),  prevent: (e) => e.preventDefault(),  self: (e) => e.target !== e.currentTarget,  ctrl: (e) => !e.ctrlKey,  shift: (e) => !e.shiftKey,  alt: (e) => !e.altKey,  meta: (e) => !e.metaKey,  left: (e) => "button" in e && e.button !== 0,  middle: (e) => "button" in e && e.button !== 1,  right: (e) => "button" in e && e.button !== 2,  exact: (e, modifiers) => systemModifiers.some((m) => e[`${m}Key`] && !modifiers.includes(m))};//2、 返回包装(fn)后的函数var withModifiers = (fn, modifiers) => {  const cache = fn._withMods || (fn._withMods = {});  const cacheKey = modifiers.join(".");  return cache[cacheKey] || (cache[cacheKey] = (event, ...args) => {    for (let i = 0; i < modifiers.length; i++) {      const guard = modifierGuards[modifiers[i]];      // 3、修饰符本质就是守卫函数,不满足条件就不执行!      if (guard && guard(event, modifiers)) return;    }    return fn(event, ...args);  });};
复制代码


  • stop 修饰符的陷阱

看到上面源码中有  stop: (e) => e.stopPropagation() 一句,不禁在想:委托事件已经冒泡到根结节了,才开始 stopPropagation 事件,此时只能欺骗一下 delegatedEventHandler 函数而已,stop 到底还有用吗?


如果整个应用统一是 vapor 模式去绑定冒泡事件,整个机制是正常的。但如果是混用了 Vue 传统组件,或用其它第 3 方库给 dom 直接绑定了事件,那么这个 stop 修饰符岂不是”掩耳盗铃“了,来看下面的例子:

<script setup lang="ts" vapor>import { onMounted, useTemplateRef } from 'vue';
const add1 = () => console.log("add1 clicked");const elRef=useTemplateRef("elRef")onMounted(()=>{ // 1、模拟直接绑定事件 elRef.value?.addEventListener('click',function(){ console.log("listen by addEventListener"); })})</script>
<template> <!-- 2、正常的监听事件 --> <div @click="add1" ref="elRef"> <div class="div1" @click.stop="add1">add1 按钮</div> </div></template>
复制代码


上面例子,注释 1 处的事件仍然会触发到的,注释 2 处的事件不会触发。


我还尝试了 Vue 传统模式和 Vapor 模式混用的场景,仍然有该 Bug,点击下面链接,切换 Vue 版本 到 vue3.6 alpha.2 尝试:stop事件不生效的示例


总之这个例子说明:Vapor 组件中的 stop 不能阻止传统 Vue 组件中的监听; 而传统 Vue 组件的 stop,会彻底破坏 Vapor 的委托链。


在面对新技术时,我们需要深入了解其原理,慎之又慎。

三、非冒泡事件

众所周知,并非所有的事件都会冒泡。对于那些不冒泡的事件,如“blur”和“mouseenter”等,在根节点是无法监听到的。现在,让我们来看看这些事件在 vapor 环境下的实现。


  • Demo 源代码

<script setup lang="ts" vapor>const inputBlur = (ev: InputEvent) => console.log("inputBlur");const divBlur = (ev: InputEvent) => console.log("divBlur");</script>
<template> <div @blur="divBlur"> <input type="text" @blur="inputBlur" /> </div></template>
复制代码


  • Vapor 渲染结果:

  const n1 = t0();  const n0 = _child(n1);  _on(n0, "blur", _ctx.inputBlur);  _on(n1, "blur", _ctx.divBlur);  return n1;
复制代码


容易看到,对于非冒泡的事件,它们是直接绑定到元素上的。

function addEventListener2(el, event, handler, options) {  el.addEventListener(event, handler, options);  return () => el.removeEventListener(event, handler, options);}function on(el, event, handler, options = {}) {  addEventListener2(el, event, handler, options);  // options.effect 是vue的私有用法  if (options.effect) {    onEffectCleanup(() => {      el.removeEventListener(event, handler, options);    });  }}
复制代码


  • 生成委托事件的条件

为什么冒泡事件会自动编译为委托事件,而非冒泡事件则直接绑定在 DOM 上呢?通过查阅源码(packages\compiler-vapor\src\transforms\vOn.ts),可以了解到了事件渲染的原理。

// 所有冒泡事件const delegatedEvents = /*#__PURE__*/ makeMap(  'beforeinput,click,dblclick,contextmenu,focusin,focusout,input,keydown,' +    'keyup,mousedown,mousemove,mouseout,mouseover,mouseup,pointerdown,' +    'pointermove,pointerout,pointerover,pointerup,touchend,touchmove,' +    'touchstart',)
// Only delegate if: // - no dynamic event name 非动态事件名 // - no event option modifiers (passive, capture, once) 不能有某些修饰函数 // - is a delegatable event 必须是可委托的事件名 const delegate = arg.isStatic && !eventOptionModifiers.length && delegatedEvents(arg.content)
复制代码

四、组件的事件绑定

下面,我们继续探索一下组件上的事件绑定原理:


  • 为组件绑定 2 个事件

<script setup lang="ts" vapor>import DemoVue from "./Demo.vue";   // Demo组件内部会触发 click, custom-click 事件
const handleClick = (data: string) => console.log(data);const handleCustomClick = (data: string) => console.log(data);</script>
<template> <div> <demo-vue @click="handleClick" @custom-click="handleCustomClick"></demo-vue> </div></template>
复制代码


  • Vapor 渲染结果:

  const n1 = t0();  _setInsertionState(n1);  const n0 = _createComponent(_ctx.DemoVue, {    onClick: () => _ctx.handleClick,    "onCustom-click": () => _ctx.handleCustomClick  });  return n1;
复制代码


我们注意到,与 dom 事件不同,绑定到组件上的事件已经基本回归到传统 Vue 的策略。在这种策略下,事件被视为一种属性,通过属性传递给子组件的实例。当子组件内部触发事件时,只需调用自身实例上的相应属性函数即可。


假设在 Demo.vue 文件中未声明'click'事件,而在 App.vue 中仍然为组件绑定 @click 事件,那么这个 click 事件会直接透传(inheritAttrs) 到 Demo 的根节点上。尽管这是一个原生冒泡的'click'事件,但它不会通过全局委托,而是通过 setDynamicProp 直接为 Demo 的根节点绑定 click 事件。


如果 Demo.vue 包含多个根节点,那么未声明的事件绑定将会被丢弃。除非在 Demo.vue 中,为某个节点主动绑定:v-bind="$attrs"。这种行为与传统 Vue 保持一致。


通过以上分析,我们可以得出结论:只有直接绑定到 DOM 元素的事件才可能是全局委托,而组件上的所有事件是和传统的 Vue 组件行为则完全一致。

五、自定义原生事件

Vue 内置的事件模式已经非常简洁且高效,通常情况下无需使用自定义事件。这个概念是原生浏览器特性的一部分,但在 Vue 生态中却较少提及。因此,我们在此提供一个示例来演示如何在 Vue 中使用它。


在 Vue 中,事件只能向父级传播一层。若要通知祖先级元素,必须逐层传递事件,这类似于令人头痛的 "Prop Drilling"。为了解决这个问题,创建一个自定义冒泡事件是最佳方案。


  • Demo.vue

<script setup lang="ts" vapor>import { useTemplateRef } from "vue";// 创建自定义事件const catFound = new CustomEvent("animalfound", {  detail: { name: "猫" },  bubbles: true  // 可冒泡});
const elRef = useTemplateRef("elRef");// 触发自定义事件setTimeout(() => elRef.value?.dispatchEvent(catFound), 3000);</script>
<template> <div ref="elRef">I am demo.vue</div></template>
复制代码


  • App.vue

<script setup lang="ts" vapor>import DemoVue from "./Demo.vue";
const demoAnimalFound = (ev: CustomEvent<{ name: string }>) => console.log(ev);const divAnimalFound = (ev: CustomEvent<{ name: string }>) => console.log(ev);</script>
<template> <div @animalfound="divAnimalFound"> <demo-vue @animalfound="demoAnimalFound"></demo-vue> </div></template>
复制代码


在这个例子中,我们创建了一个名为"animalfound"的冒泡事件,使得这两个位置都能够监听到该事件。而监听事件会渲染成普通事件绑定的模式,直接绑定在目标 dom 元素上


这让我产生了一个想法:是否可以为 Vapor 添加一个事件修饰符,例如"delegate",以便我们可以强制生成委托事件模式的代码,让用户自行确保该事件能够冒泡。

<template>  <div @animalfound.delegate="divAnimalFound">    <demo-vue></demo-vue>  </div></template>
复制代码

六、总结

经过一系列实验,我们全面测试了在 Vapor 模式下元素和组件事件绑定的诸多细节。最大的变化在于,当在元素上绑定可冒泡事件时,会进入委托事件模式。这种模式带来了显著的性能提升,主要是通过减少页面上的事件监听器数量实现的。然而,当使用 stop 修饰符时,它可能会与传统事件绑定模式产生致命的冲突,因此在使用时需要格外小心。对于非冒泡事件和组件事件,和传统的 Vue 没有任何的变化。此外,在 Vapor 模式下,竟然直接调用用户函数,而没有捕获异常,希望在正式版本中能够解决这个问题。


我一直喜欢浏览器原生的技术,因此在最后一节,我分享了如何在 Vue 中使用自定义的原生事件。既然 Vapor 已经采用了事件委托模式,为什么不增加一个.delegate 修饰符,让任意自定义的冒泡事件也能享受到 Vapor 带来的性能优势呢?

关于 OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~


OpenTiny 官网https://opentiny.design

OpenTiny 代码仓库https://github.com/opentiny

TinyVue 源码https://github.com/opentiny/tiny-vue

TinyEngine 源码: https://github.com/opentiny/tiny-engine

欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor ~如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献 ~

发布于: 19 小时前阅读数: 2
用户头像

OpenTiny 企业级web前端开发解决方案 2023-06-06 加入

官网:opentiny.design 我们是华为云的 OpenTiny 开源社区,会定期为大家分享一些团队内部成员的技术文章或华为云社区优质博文,涉及领域主要涵盖了前端、后台的技术等。

评论

发布
暂无评论
全面解读 Vue Vapor的事件机制_开源_OpenTiny社区_InfoQ写作社区