面试官:讲讲 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 源文件
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 标签,一起参与开源贡献~

相关推荐
小小小小宇1 分钟前
一个异步任务失败重试的方法
前端
Shun_Tianyou10 分钟前
Python Day19 时间模块 和 json模块 及例题分析
java·服务器·前端
Gazer_S15 分钟前
【React Context API 优化与性能实践指南】
前端·javascript·react.js
w4ngzhen21 分钟前
COME,轻量级自托管的站点评论系统套件
前端·javascript
掘金酱21 分钟前
🎆仲夏掘金赛:码上争锋,金石成川 | 8月金石计划
前端·人工智能·后端
wordbaby22 分钟前
CSS 环境变量 env() 与自定义变量 var() 全面解读及实用场景
前端·css
oil欧哟1 小时前
🧐 我开发的 AI 文本纠错/润色工具 Text-Well 上线了~
前端·ai编程·next.js
Mintopia1 小时前
网格布尔运算的三重奏:从像素的邂逅到模型的重生
前端·javascript·计算机图形学
Apifox1 小时前
Apifox 7 月更新|通过 AI 命名参数及检测接口规范、在线文档支持自定义 CSS 和 JavaScript、鉴权能力升级
前端·后端·测试
Mintopia1 小时前
用 Three.js 构建组件库:一场 3D 世界的 "乐高" 之旅
前端·javascript·three.js