v-for 给每项绑定事件时,需要使用「事件代理」吗❓

Vue 开发者在日常码业务中,或多或少都需要对列表项绑定事件完成需求,那你有没有思考过,用 v-for 时,给每个列表项绑定事件时,是否需要使用「事件代理/(委托)」呢?还是说你觉得 Vue 内部帮我们实现了事件代理呢?接下来,让我们用事实说话,探讨一下这个问题吧❗️

纵观掘金里对「Vue事件代理」的讨论几乎没有~大家不如跟这笔者一起,探寻 Vue 中事件绑定的奥秘吧。(有些叫法是:事件委托,名词都不重要!大家知道就行,本文统一称之为「事件代理」)

阅读本文你可以知道:

  1. 我们在 v-for 列表每项中使用 @click 最终到 DOM 的结果;
  2. 编译后列表项目中的 @clickrender函数 长什么样;
  3. 整个 new Vue 到挂载,是怎么给真实 DOM 元素绑定事件
  4. 一起探究组件更新时,Vue 是否会重新 绑定事件

注:本文调试的 Vue源码 版本为 v2.6.14

一、事件绑定时的思考

先看几个问题👀:

  • 日常开发中,在 Vue 长列表绑定事件时,都是怎写的?
  • 如果在 v-for 每项中直接绑定事件,会不会存在性能问题呢?
  • 如果这样会有性能问题,那 Vue 内部有没有处理?

先看一段代码:在每个 li 中绑定了点击事件 handleClick

// 模版代码
<ul>
  <li 
      v-for="(item, i) in listArr" 
      :key="item + i"
      @click="handleClick(item + (i + 1))"
  >
    {{item + (i + 1)}}
  </li>
</ul>

// js代码
methods: {
  handleClick (params) {
    console.log('触发绑定事件' + params)
  }
} 

相信这是绝大部分 Vueer 完成业务功能的写法(包括我🌚)。此时此景,不知道在座的各位会不会想到经常碰到的一道面试题:「事件代理」❓ 以前我们不用 Vue 之前,直接给列表绑定事件时,是否都会用一个叫事件代理的写法呢。利用事件冒泡,把点击事件绑定在外层的元素,以解决这样的问题:

  1. 执行多个事件绑定引起性能的损耗

  2. 实现动态绑定。不必对每次插入的 DOM 重新做事件绑定


好了,现在我们不妨先从上面的问题回过来,一起看看我们案例代码 v-for 中绑定的事件最终渲染到浏览器时是怎么样的❓ Vue 是否有对其做一层事件代理呢 ❓ 下面我们对比以下这两张图有什么不同:

  1. 从第一张图能看到,ul 标签上并没有绑定到 click 事件。再往上 div #appbody 、 直至 html 标签,都没有绑定 click 事件。就不一一贴图了,都跟图一是一样的。得到结论就是:li 的父级元素都没有绑定 click 事件。 image.png

  2. 接着我们来看图2,可以看出 li 标签上绑定了 click 事件。每个 li 都都能在 Event Listeners 中找到 click 的绑定。这也不一一贴图了,都是一样的。 image.png

结果导向 来看,v-for 中绑定的 click 事件是直接绑定到对应的列表项中,Vue 内部并没有将其代理到上层元素。这是为什么呢?接下来,让我们带着疑问,从源码层面开始对 Vue 的事件实现一探究竟!🧑‍💻


二、从 @click 到真实 DOM

1. 探究 Render 函数

直接对上述的 App组件 打包后的代码如下:

image.png

分析:

  • 生成的 lirender 函数中,第二个参数中有一个 on 的属性,里面有一个 click 属性指向我们代码中的 handleClick

  • n._l 是对 v-for 的实现,在源码中 core/instance/render-helpers/render-list.js

  • 运行时执行 render 函数,就会根据 on 的这个属性,对里面的事件进行处理

2. 回顾 Vue 组件化路程

组件化时候的流程图如下 :(如果想深入了解,可以看笔者的另外一篇:响应式原理)👀

组件化简化.png

简单分析:

  • 如图可知,整个组件化的周期:new Vueinit$mountrender 得到 VNode

  • 最后对 VNode 进行 patch ,就到真实的 DOM 上了,完成整个组件化流程,而事件绑定就是在这个时候绑定到真实的 DOM 上的

3. 进行源码调试

  1. 直接从 createELm 开始看。(这是创建 真实DOM 的方法,大🔥不用深究,知道就行了)。如下图所示,可以看到此时的 liDOM 已经创建了,我们就从这里开始! image.png

  2. 创建所有完子元素之后会执行到 invokeCreateHooks 、再到 updateDOMListeners。(递归创建 li 子元素过程就不展开了,熟悉 Vue Patch的童鞋可以自己脑补一下哈)

  3. updateDOMListeners 实现。先判断没有 on 属性,直接返回;而后会调用到 updateListeners 这个关键的函数。(我们只需要关注核心点即可,像 normalizeEvents 是处理 v-model 的,还有其他的我们都可以不用关注) image.png

  4. updateListeners 可以看到 通过 createFnInvoker 赋值给 on.clickcreateFnInvoker 其实就是包装了我们的 handleClick 再返回去的一个新函数(高阶函数),目的是做一层函数包装。为什么这么做?这里先埋个伏笔

    image.png

  5. 看上图的 ⭐️ 处,这里执行 add 函数中对真实的 DOM 进行事件绑定。直接看下图,看看 add 函数做了什么。(当前的执行堆栈显示正在执行 add 函数)

    image.png

    • 列表项有多少,这里就会执行多少次 add 函数进行事件绑定。我们案例中有8个 li ,这里会执行 8 次,给 8 个 li 绑定 click 事件

OK,这就是组件初始化阶段时,DOM 的绑定事件的过程。根据上述调试结果,可以肯定的是: Vue 没有对 v-for 列表项的绑定事件进行 事件代理

这里我把列表长度调整为3,去掉其他 debugger,且只保留 addEventListener 的,再录个屏给大家看。注意看,会执行3次 addEventListener 👇

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rWKrk3bj-1655437479768)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/59a8bd5ad6284142a9b18f42537e20e2~tplv-k3u1fbpfcp-zoom-in-crop-mark:1956:0:0:0.image?)]

结论:Vue 内部没有 对列表项绑定的 click 事件进行 事件代理


三、Vue 真的没做一点处理吗?

上文埋下伏笔的 createFnInvoker 函数,在这里就有用处了!👇看过 Vue错误处理 实现的童鞋知道这个方法会用 try-catch 包装我们的 handleClick 以实现错误捕获,那还有其他的用途吗?(想详细了解错误捕获的可以 戳这里,看Vue的错误处理机制

1. invoker 函数

这里关注 createFnInvoker 的核心处理:

  • 核心点一: 接收一个 fns 函数 或 函数数组, 返回一个 Function

  • 核心点二: 返回的 invoker 方法的 静态属性 fns,然后每次 invoker 执行的时候,会重新拿这个 invoker.fns

  • 核心点三: 我们开发者写的 handleClick 会被包裹在 invokeWithErrorHandling 中,点击事件的回调会在其内部执行。作用开头有讲过,就是 Vue 做了一层 错误捕获 的处理

export function createFnInvoker (fns: Function | Array<Function>, vm: ?Component): Function {
  function invoker () {
    // 获取 invoker 的静态属性 fns
    const fns = invoker.fns
    if (Array.isArray(fns)) {
      const cloned = fns.slice()
      for (let i = 0; i < cloned.length; i++) {
        // 1. cloned[i] 其实就是对一个的执行函数,如 handleClick,传入函数中
        // 2. 最后会在 invokeWithErrorHandling 内部执行(被 try-catch 包裹)
        invokeWithErrorHandling(cloned[i], null, arguments, vm, `v-on handler`)
      }
    } else {
      // 只有一个函数走这里,逻辑跟上述一模一样
      return invokeWithErrorHandling(fns, null, arguments, vm, `v-on handler`)
    }
  }
  invoker.fns = fns
  return invoker
} 

2. 为什么要用invoker包装?

  • 先着看 updateListeners 中的 else if 中的逻辑:old.fns = cur
    • 这就是 Vue 对我们列表绑定事件的处理了!组件更新时:仅仅是替换绑定函数!
    • 这里注意一点:组件初始化组件更新都是使用 updateListeners 这个方法的
function updateListeners () {
  ...
  for (name in on) {
    ...
    if (isUndef(cur)) {
      ...
    } else if (isUndef(old)) {
      if (isUndef(cur.fns)) {
        // 初始化的时候执行到这里,cur = invoker
        cur = on[name] = createFnInvoker(cur, vm);
      }
      if (isTrue(event.once)) {
        cur = on[name] = createOnceHandler(event.name, cur, event.capture);
      }
      // 回顾步骤 4. 画五角星⭐️的部分,首次会在这里添加函数
      add(event.name, cur, event.capture, event.passive, event.params);
    } else if (cur !== old) {
      // 组件更新时,走到这里,将新的 invoker 赋值到之前的 invoker 静态属性 fns 中
      old.fns = cur;
      on[name] = old;
    }
  }
  ...
} 
  • 再回到绑定事件的源码看看。这里的 handler ,其实就是 invoker 函数。现在,为什么不直接绑定我们的 handleClick 函数,大🔥get到了吗?笔者在这先大胆下个结论:就是为了避免组件更新的时候,每一项 li 需要重新绑定事件
target$1.addEventListener(
  name, // click
  handler, // 其实绑定到 DOM 的是 invoker 函数,并不直接是我们的 handleClick
  supportsPassive
    ? { capture: capture, passive: passive }
    : capture
); 

接下来,我们就验证一下到底是不是这样,看看 old.fns = cur; 逻辑的执行,Let’s go!


四、组件更新 & 事件绑定

我们都知道,只要触发响应式数据改变,就会触发当前组件的更新。而组件更新,无非就是重新执行 renderpatch 的过程(中间还有个diff)。重新执行render,意味着我们 li 上绑定的 handleClick 会是一个全新的函数。

这里再次贴出 App 组件的 Render 函数图,加深大家的理解 image.png

接下来,通过两个案例的调试,一起验证一下,Vue 组件更新,到底有没有重新绑定事件

案例代码如下:

/* template */
<div id="app">
  // 多了个flag
  <p>{{flag}}</p>
  <ul>
    <li
        v-for="(item, i) in listArr"
        :key="item + i"
        @click="handleClick(item + (i + 1))"
    >
      {{item + (i + 1)}}
    </li>
  </ul>
  // 按钮点击改变 flag
  <button @click="flag = !flag">change</button>
</div>

/* script */
data () {
  return {
    flag: true,
    listArr: new Array(3).fill('list-item')
  }
},
methods: {
  handleClick (params) {
    console.log('触发绑定事件' + params)
  }
} 

1. 普通组件更新会怎么样?

上述代码的页面长这样。现在我们就点击 change 按钮,验证下是不是直接替换函数而已! image.png

直接看结果:

  • 如图所示,组件更新存在 old ,所以不会走到初始化的逻辑(截图打❌处)
  • 3个 li 包括 change按钮 在内的总共4个 click 事件全部都是走如下逻辑,将静态属性 fns 切换成新的点击函数 old.fns = cur,并不会走原生的 addEventListener 重新绑定事件!

image.png

2. 添加一个新 li 会怎么样?

大家可以根据前面的案例自己想一下!其实很简单哈~

我们在上述案例代码稍作修改,将 buttonclick 事件改一下:

<button @click="listArr.push('list-item')">pushItem</button> 

页面长这样👇

image.png

我们点击 pushItem 按钮!如无意外,前三次应该跟上述一样!

image.png

没有意外,我们接着单步进行!直到进入了 addEventListener 环节,果然是我们新 pushli 元素!如下图,特地找了他的 innerHTML 给大🔥看

image.png

结论:Vue 组件更新时,不会重新对元素进行事件绑定! 展开说说,其实就是用 invoker 包装(高阶函数思想),将我们的事件回调放在 invoker.fns 静态属性。DOM事件绑定回调时,绑定了 invoker ;事件回调执行时,实际是找到 invoker.fns 中的函数去执行,也就是我们的 handleClick

So,其实 Vue 内部是有对事件这一块做一定的优化的,但是内部没有做 事件代理优化!!!


五、v-for是否需要事件代理?

首先抛出我的观点:

  1. 所有的优化处理,一般都是出现性能问题再考虑。
  2. 不需要在所有开发中都考虑优化,毕竟完整业务、早点下班才是首要。
  3. 不是说有优化思维和执行不好,而是要兼顾时间成本,和优化必要性。如果不需要优化也流畅得鸭批,那我感觉是没必要优化的。

从实际出发:

  1. 性能导向。对长列表进行事件代理处理,当然是有一定的性能提升,这点毋庸置疑,但是大家看场景决定是否需要写成事件代理。如果不会引起性能问题,直接 @click 在每一项也是可以的

  2. 注意绑定的函数的写法。不要给一些面试题的写法蒙蔽,有些面试题出题的写法确实性能开销更大,但其实是可以避免的。不妨看看下面的伪代码

    for (let i = 0; i < xxx; i++) {
      // 注意看写法的区别,每次赋值一个全新函数 
      li.onclick = function () { ... }
      // 每次指向同一个函数,不会造成函数n次生成
      li.onclick = myCick
    }
    
    function myClick () { ... } 
    

    所以,在 v-for 中绑定事件避免直接写成 @click="function () {}" 这种,加大函数创建的性能开销

  3. Vue 内部有一定的优化手段。由本文可知,Vue 内部其实是有做一定的优化的,特别是组件更新时减少了很多事件重新绑定、解绑的开销

结论:大家放心在列表项中直接绑定事件吧,一般都没问题!出现性能问题再解决就好了,不是吗?(毕竟长列表不都流行用虚拟列表优化了嘛 狗头.png


写在最后,其实写这个话题出于几点吧:

  • 其一:主要是因为掘金里确实很少对 Vue长列表绑定事件、Vue有没有内部帮我们实现事件代理 的讨论,但是这个对日常开发也算是有指引性作用的吧
  • 其二:笔者之前面试时就被问到这个问题~当时也是想当然,觉得这么基础的点,尤大不可能没考虑到吧?所以就被问垮了。其实对于源码的实现,如果没看到那一块,确实不知道,也没办法啦~
  • 其三:熟悉 React 的同学肯能就会很自然而然的想到合成事件,感觉 Vue 也是有这样处理事件的,做一层总代理

最后,想写一点「创作内容」的心里话。目前已经是坚持 写文章 的一个多月了吧,初衷只是想把自己的知识做一个沉淀,没想到发布的文章会有一定的阅读量、点赞收藏,也会有掘友想要转载,这也让我感受到了 知识分享、知识交流 带来的充实感。所以,笔者接下来会在选题、内容质量上更下功夫,做对大家日常工作、面试中有用的干货文章!❤️

  • 6
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值