编程 Vue Vapor 事件机制实测:@click.stop 在混用传统组件时拦不住事件

2026-09-23 20:01:47

Vue Vapor 事件机制实测:@click.stop 在混用传统组件时拦不住事件

测试基于 vue@3.6.0-alpha.2,对 Vapor 模式下的事件绑定原理做了完整验证。结论之一是:Vapor 组件里的 stop 修饰符只在委托链内部有效,一旦和传统 Vue 组件、或者直接 addEventListener 的第三方库混用,就会失效。

一、全局事件委托

传统 Vue 的事件模式

每个 DOM 节点有一个统一的事件挂载点 el._vei = {},键是事件名,值是一个 invoker 函数。节点通过 addEventListener 把 invoker 绑到对应事件上。本质上还是浏览器原生的事件模型,每个事件都要在监听的 DOM 元素上直接绑一次。

Vapor 的委托事件模式

React 早就引入了「合成事件」的概念,把事件处理委托给 document 或挂载的 #app。Vapor 采用了同样的做法。

Demo 源文件:


const add1 = () => {};
const add2 = () => {};
const add3 = () => {};

add1 按钮

add2 按钮

add3 按钮

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 上绑定一次「全局统一处理函数」。利用事件冒泡,在根节点统一处理所有原本需要在节点上触发的事件。这样能大幅减少页面的事件监听器数量,表格这类场景尤其明显。

全局统一的处理函数:

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 这里是否遗漏了异常处理和上报?

多个事件处理函数

handlers 可能是数组。@click="[add1, ()=> add2()]" 这种数组写法在早期可行,最新演练场已经无法触发事件。读源码可以看到,同一个事件绑定多个函数时会编译成数组形式的 handlers


const addWithAlt = () => msg.value.push("with alt clicked");
const addWithCtrl = () => msg.value.push("with ctrl clicked");

add 按钮

Vapor 渲染结果:

_delegateEvents("click");

function _sfc_render(_ctx, $props, $emit, $attrs, $slots) {
const n0 = t0();
_delegate(n0, "click", _withModifiers(_ctx.addWithAlt, ["alt"]));
_delegate(n0, "click", _withModifiers(_ctx.addWithCtrl, ["ctrl"]));
return n0;
}

delegate 函数在多次调用时生成 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;
}
}

二、事件修饰符的实现

模板 @click.stop="add1" 编译后:

const n0 = t0();
const n1 = t1();
const n2 = t2();
n0.$evtclick = _withModifiers(_ctx.add1, ["stop"]);
n1.$evtclick = _ctx.add2;
n2.$evtclick = _ctx.add3;

withModifiers 的实现:

var systemModifiers = ["ctrl", "shift", "alt", "meta"];

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)),
};

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
import { onMounted, useTemplateRef } from 'vue';

const add1 = () => console.log("add1 clicked");
const elRef = useTemplateRef("elRef");

onMounted(() => {
elRef.value?.addEventListener('click', function () {
console.log("listen by addEventListener");
});
});

add1 按钮

上面这个例子里,直接 addEventListener 的事件仍然会触发,@click.stop 的那层事件不会触发。混用 Vue 传统模式与 Vapor 模式同样有该 Bug,vue@3.6.0-alpha.2 演练场可复现。

结论:Vapor 组件中的 stop 不能阻止传统 Vue 组件中的监听;而传统 Vue 组件的 stop,会彻底破坏 Vapor 的委托链。

三、非冒泡事件

并非所有事件都会冒泡,比如 blurmouseenter,根节点监听不到。


const inputBlur = (ev: InputEvent) => console.log("inputBlur");
const divBlur = (ev: InputEvent) => console.log("divBlur");

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);
if (options.effect) {
onEffectCleanup(() => {
el.removeEventListener(event, handler, options);
});
}
}

生成委托事件的条件(packages/compiler-vapor/src/transforms/vOn.ts):

const delegatedEvents = 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)

四、组件的事件绑定


import DemoVue from "./Demo.vue";

const handleClick = (data: string) => console.log(data);
const handleCustomClick = (data: string) => console.log(data);

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 中,事件只能向父级传播一层,要通知祖先级元素必须逐层传递。可以创建自定义冒泡事件来解决:

Demo.vue


import { useTemplateRef } from "vue";

const catFound = new CustomEvent("animalfound", {
detail: { name: "猫" },
bubbles: true,
});
const elRef = useTemplateRef("elRef");
setTimeout(() => elRef.value?.dispatchEvent(catFound), 3000);

I am demo.vue

App.vue


import DemoVue from "./Demo.vue";

const demoAnimalFound = (ev: CustomEvent) => console.log(ev);
const divAnimalFound = (ev: CustomEvent) => console.log(ev);

监听自定义事件会渲染成普通事件绑定模式,直接绑在目标 DOM 元素上。是否可以为 Vapor 添加一个 delegate 事件修饰符,强制生成委托事件模式,例如 @animalfound.delegate,是一个可以提的需求。

六、总结

Vapor 模式下,元素上绑定可冒泡事件时会进入委托事件模式,通过减少事件监听器数量带来性能提升。使用 stop 修饰符时,可能和传统事件绑定模式产生致命冲突,需要格外小心。非冒泡事件和组件事件与传统 Vue 没有变化。此外,Vapor 模式下直接调用用户函数而没有捕获异常,希望在正式版本中解决。

关于 OpenTiny

  • OpenTiny 官网:https://opentiny.design
  • OpenTiny 代码仓库:https://github.com/opentiny
  • TinyVue 源码:https://github.com/opentiny/tiny-vue
  • TinyEngine 源码:https://github.com/opentiny/tiny-engine

本文由体验技术团队申君健原创(OpenTiny / TinyVue)。

复制全文 生成海报 Vue Vapor 事件委托 前端

推荐文章

程序员茄子在线接单