JavaScript 中的事件委托

更新日期: 2021-07-22阅读: 2.1k标签: 事件

JavaScript 中一个重要的方法就是事件委托(又叫事件代理)。事件委托将事件侦听器添加到一个父级元素上,这样就只用添加一次事件侦听器,可以避免向 (父级元素内)很多特定的 dom 节点添加多个事件侦听器,减少了内存消耗,从而优化程序性能。而这个添加在父元素上的事件侦听器通过事件冒泡的事件流机制以分析查找子元素的匹配项。事件委托的概念解释并不复杂,但很多人不明事件委托到底是如何的工作,本文就来解释一下事件委托的工作原理。

事件冒泡(Event Bubbling)

要完全理解事件委托的工作原理,必须要先了解事件冒泡。事件冒泡的事件流最先是由微软在其开发的 IE 浏览器中实现的。事件冒泡的事件流,事件的触发会从最底层的 DOM 元素开始发生,一直向上传播,直到 document 对象。就像把一颗石头投入水中,泡泡会一直从水底冒出水面。事件冒泡也正因此而得名。


与事件冒泡事件流相对的还有一个事件捕获事件流,它的事件触发过程与事件冒泡正好相反,如上图所示。在起初阶段,除了 IE 浏览器默认的事件流是使用的事件冒泡,其它的浏览器(Netscape)采用的是捕获,后由改为了先捕获,后冒泡。不过经过30多年不断的实践,目前主要的浏览器厂商已经把默认的事件流改为了事件冒泡了,而我们开发者也都更倾向于使用事件冒泡事件流,所以在这里也就不多介绍事件捕获了。
那样先触发在 body 获得 div 节点上的事件侦听器。

addEventListener 的第三个参数

addEventListener() 方法的语法如下:

addEventListener(event, function, useCapture)

如果你还想体验一把事件捕获,可以将 addEventListener 的第三个参数设置为 true。
事件冒泡事件流之所以得到开发者的青睐,是因为它的事件触发机制更符合实践操作的预期。例如我鼠标点击的是上图中的 text 的文本节点,通常我们更希望最先触发的是绑定在 text 节点的事件侦听器,而不是像事件捕获

<!DOCTYPE html>
<html>
    <head>
        <title>Event Bubbling</title>
    </head>
    <body>
      <div class="container" id="container">
          <span id="text">Text</span>
      </div>
    </body>
    <script>
        const $container = document.querySelector('#container')
        const $text = document.querySelector('#text')
        const containerHandler = function(evt) {
            console.log('target is container')
        }
        const textHandler = function(){
            console.log('target is text')
        }
        // 目前所有的主流浏览器都将 addEventListener 方法的第三个参数设置了 fale
        // 也就是使用事件冒泡了,所以必须手动设置为 true 才会执行事件捕获,包括
        // onclick 这样的事件绑定方法,默认也是采用事件冒泡了
        $container.addEventListener('click', containerHandler, true)
        $text.addEventListener('click', textHandler, true)
    </script>
</html>

事件委托的适用场景

在了解完事件冒泡后,接下来我们要了解一下事件委托的适用场景。举例,假设有一个 UL 带有多个子元素的父元素:

<ul id="list" class="list">
  <li id="item-1" class="item">Item 1</li>
  <li id="item-2" class="item">Item 2</li>
  <li id="item-3" class="item">Item 3</li>
  <li id="item-4" class="item">Item 4</li>
  <li id="item-5" class="item">Item 5</li>
  <li id="item-6" class="item">Item 6</li>
</ul>

假设单击每个子元素时需要发生一些事情。通常的做法,可以为每个单独的 LI 元素添加一个单独的事件侦听器,但是如果 LI 元素频繁地从列表中添加和删除怎么办?尤其是当添加和删除代码位于应用程序中的不同位置时,这时候添加和删除事件侦听器将是一场噩梦。

像这种场景:父元素是固定的,而其中的字元素会动态增加或者删除。这个时候就适合使用事件委托,为父元素(UL)添加事件侦听器,通过事件冒泡事件流机制,父元素可以通过 event.target 监测分析出子元素的匹配项。

当然,事件委托也是有一定局限性的。比如 focus、blur 之类的事件本身没有事件冒泡机制,所以无法委托。而 mousemove、mouseout 这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的。

事件委托的实现

由于事件委托是将事件侦听器添加到父级,如何知道单击了哪个子元素成为了要解决的最大问题。

处理方式其实很简单,其实面前我已经提及过了,当点击 UL 元素下的任何子元素,当事件冒泡到 UL 元素时,通过检查事件对象的 target 属性就可以获得对实际单击子节点的引用。简单的 JavaScript 实现如下:

const $list = document.querySelector('#list')
// 获取元素,添加点击监听器... 
$list.addEventListener('click', function (e) {
    // e.target 是被点击的元素!
    const $li = e.target
    // 如果它是一个列表项
    if ($li && $li.tagName.toLowerCase() === 'li') {
        // List项目找到了!输出的ID!
        console.log(`list ${$li.id} 被点击了`);
    }
})

可以看到,事件委托之所以能够正常工作,最重要的原因就是事件冒泡事件流机制。点击 UL 下的子元素,由于事件冒泡,在 UL 元素上的 click 事件侦听器也会被触发。而这时,我们几可以通过 event.target 获取到点击的目标元素。再通过对这个元素的一系列的判断检测是否为我们期望的元素,如果是就执行相关的操作。

使用事件委托的好处不仅在于将多个事件处理函数减为一个,而且对于不同的元素可以有不同的处理方法。假如上述列表元素当中添加了其他的元素节点(如:a、span等),就不必再一次循环给每一个元素绑定事件,直接修改事件委托的事件处理函数即可。

封装 delegate

事件委托的接口实现的比较好的 JavaScript 框架应该是 jquery 了。我们就先看看 jQuery 的事件委托的接口是如何实现的:

$('#list').delegate('.item', 'click', function(evt){
    console.log(`list ${$li.id} 被点击了`);
})

可以看到,jQuery 的实现方式比前文介绍的实现方式更加灵活,它的 delegate() 方法可以通过选择器(例如:.item)来分析查找子元素的匹配项。如果想要实现和 jQuery 类的事件委托接口,关键是需要找一种方法判断 event.target 是否包含或者说匹配使用的选择器。

如果选择器单纯的只是使用类选择器,我们可以通过 event.target.classList 属性,判断 classList 中是否包使用含使用的选择器。但是 jQuery 的 delegate 方法的接口中使用的选择器是很灵活的,可以是类选择器,也可以是元素选择器,也可以是其它的选择器。如果为每个可能的选择器做不同的判断逻辑,那将是一个无比痛苦的事情。

Element.matches()

Element.matches() 这个新的方法为我们判断 DOM 元素是否与给定的选择器匹配提供了非常便捷的方式。Element.matches() 的调用方式如下:

let result = element.matches(selectorString);

如果元素被指定的选择器字符串选择,Element.matches() 方法返回 true,否则返回 false。回归到前面的示例,如果想判断 event.target 是否与 .item 选择器匹配,就可以这么调用:

const $li = event.target
  if($li && $li.matches('.item')) {
    console.log(`list ${$li.id} 被点击了`);
  }

Element.matches() 除了调用十分方便外,各大浏览器的支持情况也很不错。


如果你想兼容更多浏览器,也可以使用 MDN 给出的 polyfill,代码如下:

/**
 * A polyfill for Element.matches()
 * ========================================================================
 * @see https://developer.mozilla.org/en-US/docs/Web/api/Element/matches
 */
if (!Element.prototype.matches) {
  Element.prototype.matches =
    Element.prototype.matchesSelector ||
    Element.prototype.mozMatchesSelector ||
    Element.prototype.msMatchesSelector ||
    Element.prototype.oMatchesSelector ||
    Element.prototype.webkitMatchesSelector ||
    function (selector) {
      let matches = (this.document || this.ownerDocument).querySelectorAll(selector)
      let i = matches.length
      while (--i >= 0 && matches.item(i) !== this) {
      }
      return i > -1
    }
}

获得与选择器匹配的元素

使用 Element.matches() 判断点击的元素于选择器是否匹配不是最终的目的,使用它是为了获得与选择器匹配的元素。前面提到过了,事件委托是利用事件冒泡事件流,在事件流逐层向上冒泡的过程中,在绑定事件侦听器的父元素上来做判断分析点击的目标是否于使用的选择器匹配。

这里就有一种可能,鼠标点击的直接目标可能是我们期望元素的子元素,这时是从点击的子元素开始向上冒泡,直到达到选择器匹配的元素。那么这时要获取的目标元素就是点击的目标元素的父元素。为此还需要封装一个 closest() 方法,获得与选择器匹配的元素。

/**
    * 获得与选择器匹配的元素
    * ========================================================================
    * @param {Element} el
    * @param {String} selector
    * @return {Function}
    */
   export const closest = (el, selector) => {
     // Node.ELEMENT_NODE	1	An Element node like <p> or <div>.
     // Node.ATTRIBUTE_NODE	2	An Attribute of an Element.
     // Node.TEXT_NODE	3	The actual Text inside an Element or Attr.
     // Node.CDATA_SECTION_NODE	4	A CDATASection, such as <!CDATA[[ … ]]>.
     // Node.PROCESSING_INSTRUCTION_NODE	7	A ProcessingInstruction of an XML document, such as <?xml-stylesheet … ?>.
     // Node.COMMENT_NODE	8	A Comment node, such as <!-- … -->.
     // Node.DOCUMENT_NODE	9	A Document node.
     // Node.DOCUMENT_TYPE_NODE	10	A DocumentType node, such as <!DOCTYPE html>.
     // Node.DOCUMENT_FRAGMENT_NODE	11	A DocumentFragment node.
     const DOCUMENT_NODE_TYPE = 9
   
     // 忽略 document,因为事件冒泡最终都到了 document
     while (el && el.nodeType !== DOCUMENT_NODE_TYPE) {
       if (typeof el.matches === 'function' && el.matches(selector)) {
         return el
       }
       el = el.parentNode || el.parentElement
     }
   }

可以看到 closest() 方法首先会比对元素的 nodeType,直到 nodeType 变为 document 类型。然后判断元素是否于选择器匹配,如果匹配,那么就返回匹配的元素,如果不匹配,则“向上冒泡”到元素的父元素,直到找到匹配的元素或者冒泡到 document。

实现 on() 方法

在完成前面的准备工作后,现在可以正式实现类似 jQuery 的 on() 方法了。

/**
    * 绑定代理事件
    * ========================================================================
    * @param {HTMLElement} el - 绑定代理事件的 DOM 节点
    * @param {String} selector - 触发 el 代理事件的 DOM 节点的选择器
    * @param {String} type - 事件类型
    * @param {Function} callback - 绑定事件的回调函数
    * @param {Boolean} [useCapture] - 是否采用事件捕获(默认值:false - 事件冒泡)
    * @param {Object} [context] - callback 回调函数的 this 上下文(默认值:el)
    * @returns {Function}
    */
   export const on = (el, selector, type, callback, useCapture, context) => {
     const listener = function (e) {
       const target = e.target || event.srcElement
       // 通过 Element.matches 方法获得点击的目标元素
       const delegateTarget = closest(target, selector)
   
       e.delegateTarget = delegateTarget
   
       if (delegateTarget) {
         callback.call(context || el, e)
       }
     }
   
     // mouseenter 和 mouseleave 不适合使用冒泡
     if (type === 'mouseenter' || type === 'mouseleave') {
       useCapture = true
     }
   
     callback._delegateListener = callback
     el.addEventListener(type, listener, useCapture || false)
   
     return callback
   }

仔细查看代码我们会发现 on() 的关键是使用了一个私有的 listener() 方法将 callback 回调函数包装了一下,将获取掉的目标元素赋值给 event.delegateTarget 属性。并且指定了 callback 回调函数的执行上下文。

另外,一个关键措施就是给 callback 函数添加了自定义的 _delegateListener (私有)属性,这是为 off() 销毁事件侦听方法做的准备。理论上 callback 是一个事件侦听的回调函数,但由于 JavaScipt 语言的特性,函数也是对象,而 JavaScript 中的对象是可以添加任意属性的。

最后就是对于 mouseenter 和 mouseleave 事件,我们的 on() 方法直接使用了事件捕获事件流。原因在前文提到过 mouseenter 和 mouseleave 事件是不适合使用事件冒泡事件流的。

实现 off() 方法

最后我们再实现一个 off() 方法,用来实现取消事件委托的事件侦听的绑定。

/**
    * 取消事件绑定
    * ========================================================================
    * @param {HTMLElement} el - 取消绑定(代理)事件的 DOM 节点
    * @param {String} type - 事件类型
    * @param {Function} callback - 绑定事件的回调函数
    * @param {Boolean} [useCapture] - 是否采用事件捕获(默认值:false - 事件冒泡)
    */
   export const off = (el, type, callback, useCapture) => {
     if (callback._delegateListener) {
       callback = callback._delegateListener
       delete callback._delegateListener
     }
   
     if (type === 'mouseenter' || type === 'mouseleave') {
       useCapture = true
     }
   
     el.removeEventListener(type, callback, useCapture || false)
   }

off() 方法的实现相比 on() 方法就简单多了,将 on() 方法中的 callback._delegateListener 属性移除掉,然后再调用 removeEventListener 移除事件侦听器的绑定。

好了,到此为止关于 JavaScript 中事件代理的相关介绍就结速了。希望本文可以帮助大家直观地了解事件委托背后的概念和事件委托的力量!并且鼓励大家尝试在适用的场景下使用事件代理来绑定事件侦听器。

链接: https://fly63.com/article/detial/10553

js中addEventListener事件监听器参数详解

我们都知道addEventListener() 的参数约定是:useCapture是可选参数,默认值为false,目前DOM 规范做了修订:addEventListener() 的第三个参数可以是个对象值了。passive就是告诉浏览器我可不可以用stopPropagation...

js监听浏览器返回,pushState,popstate 事件,window.history对象

在WebApp或浏览器中,会有点击返回、后退、上一页等按钮实现自己的关闭页面、调整到指定页面、确认离开页面或执行一些其它操作的需求。可以使用 popstate 事件进行监听返回、后退、上一页操作。

CSS中的pointer-events属性实现点穿效果

具有层级关系的结构中,使用了pointer-events:none 属性将会使当前元素中的事件不会被捕获,从而实现了点穿的效果。而当代码示例中假如top元素具有子元素且显示指定pointer-events属性不为none的时候,top元素注册的事件将会被捕获/冒泡触发

js鼠标事件参数,获取鼠标在网页中的坐标

事件对象 event,JavaScript 将事件event作为参数传递,IE中把 event 事件对象作为全局对象 window 的一个属性,获取鼠标在网页中的坐标 = 鼠标在视窗中的坐标 + 浏览器滚动条坐标

js事件冒泡和默认事件处理(原生js、vue)

何为默认事件?比如 a 会跳转页面,submit 会提交表单等。普通js方法:e.preventDefault()函数。Vue.js方法: .prevent 是vue 的内置修饰符,调用了 event.preventDefault()阻止默认事件

js keyup、keypress和keydown事件 详解

js keyup、keypress和keydown事件都是有关于键盘的事件,当一个按键被pressed 或released在每一个现代浏览器中,都可能有三种客户端事件。

深入nodejs-核心模块Events详解(事件驱动)

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。Node.js 使用了一个事件驱动、非阻塞式 I/O的模型,使其轻量又高效。比如,文件操作中的fs事件流,网络编程所用到的tcp,http模块等,当你回想自己写的程序后,会发现很多操作都基于事件驱动,Events类。

纯CSS实现点击事件展现隐藏div菜单列表/元素切换

在写移动端导航的时候经常用到点击按钮出现/隐藏导航条的情况,最常见的方法当然还是前端框架直接调用,省心省力,不易出错;当然还有使用纯JS实现的小代码段。我这里整理了纯CSS实现方式,给需要的人和给自己做个笔记:实现原理利用CSS伪类:target

关于鼠标移动太快导致moseleave事件不触发的问题

我做的是一个table的编辑功能,当移入某行的时候展示编辑状态,在移出某行的时候显示的是原始状态,此时遇到一种情况,就是.当mousenter事件触发之后,由于鼠标移动得太快,同一个tr上绑定的mouseleave事件压根儿就没有执行。

Js事件传播流程

js事件传播流程主要分三个阶段:事件捕获阶段、处于目标阶段、事件冒泡阶段。在我们平常用的addEventListener方法中,一般只会用到两个参数,一个是需要绑定的事件,另一个是触发事件后要执行的函数

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!