JavaScript 中的事件委托

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

事件冒泡(Event Bubbling)

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

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

addEventListener 的第三个参数

addEventListener() 方法的语法如下:

addEventListener(event, function, useCapture)

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

<!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 中事件代理的相关介绍就结速了。希望本文可以帮助大家直观地了解事件委托背后的概念和事件委托的力量!并且鼓励大家尝试在适用的场景下使用事件代理来绑定事件侦听器。