您现在的位置是:首页 > 文章详情

90%人会踩的这几个Vue Vapor 事件大坑,我已经替你踩完了

日期:2025-07-30点击:39

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

在开发 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[$evt${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 标签,一起参与开源贡献 ~

原文链接:https://my.oschina.net/u/6769809/blog/18686412
关注公众号

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。

持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。

转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。

文章评论

共有0条评论来说两句吧...

文章二维码

扫描即可查看该文章

点击排行

推荐阅读

最新文章