本文由体验技术团队申君健原创。
在开发 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 源文件
js
<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 渲染结果:
js
_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];
}
- 委托的原理:
js
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上绑定一次"全局统一的处理函数"。通过利用事件冒泡的特性,可以在根节点统一处理所有原本需要在节点上触发的事件。
这种方法大大减少了页面的事件监听器数量,特别是在表格等场景中。
- 全局统一的处理函数:
js
// 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[ <math xmlns="http://www.w3.org/1998/Math/MathML"> e v t evt </math>evt{e.type}]; 这一行下面,提到了 handlers 可能是数组形式。然而,我尝试了多种事件写法,都无法使其绑定一个数组。在2022年时,验证过:@click = "[ add1, ()=> add2() ]" 这种数组形式绑定是可以成功的,但在最新的演练场上,这种写法已经无法触发事件了。
后来阅读Vue源码,发现同一个事件绑定多个函数的情况,此时会编译成上述的数组形式的handlers:
- Demo 源文件
js
<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渲染结果为
js
// 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 函数
js
// 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的话,编译后:
js
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 函数返回一个经过包装的函数,该函数会依次执行修饰函数。修饰函数实际上是一种守卫函数,当条件不满足时,它会提前终止执行,从而避免调用与之关联的处理函数。
js
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修饰符岂不是"掩耳盗铃"了,来看下面的例子:
js
<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源代码
js
<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 渲染结果:
js
const n1 = t0();
const n0 = _child(n1);
_on(n0, "blur", _ctx.inputBlur);
_on(n1, "blur", _ctx.divBlur);
return n1;
容易看到,对于非冒泡的事件,它们是直接绑定到元素上的。
js
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),可以了解到了事件渲染的原理。
js
// 所有冒泡事件
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个事件
js
<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 渲染结果:
js
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
js
<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
js
<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",以便我们可以强制生成委托事件模式的代码,让用户自行确保该事件能够冒泡。
js
<template>
<div @animalfound.delegate="divAnimalFound">
<demo-vue></demo-vue>
</div>
</template>
六、总结
经过一系列实验,我们全面测试了在Vapor模式下元素和组件事件绑定的诸多细节。最大的变化在于,当在元素上绑定可冒泡事件时,会进入委托事件模式。这种模式带来了显著的性能提升,主要是通过减少页面上的事件监听器数量实现的。然而,当使用stop修饰符时,它可能会与传统事件绑定模式产生致命的冲突,因此在使用时需要格外小心。对于非冒泡事件和组件事件,和传统的Vue没有任何的变化。此外,在Vapor模式下,竟然直接调用用户函数,而没有捕获异常,希望在正式版本中能够解决这个问题。
我一直喜欢浏览器原生的技术,因此在最后一节,我分享了如何在Vue中使用自定义的原生事件。既然Vapor已经采用了事件委托模式,为什么不增加一个.delegate修饰符,让任意自定义的冒泡事件也能享受到Vapor带来的性能优势呢?
关于 OpenTiny
欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti...
TinyEngine 源码: github.com/opentiny/ti...
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~