likes
comments
collection
share

JavaScript 中的事件委托

作者站长头像
站长
· 阅读数 22

JavaScript 中一个重要的方法就是事件委托(又叫事件代理)。事件委托将事件侦听器添加到一个父级元素上,这样就只用添加一次事件侦听器,可以避免向 (父级元素内)很多特定的 DOM 节点添加多个事件侦听器,减少了内存消耗,从而优化程序性能。

事件委托的适用场景

举个例子来看看事件委托的应用场景,看看它是如何减少了内存消耗,从而优化程序性能的?举例,假设有一个 UL 带有多个子元素的父元素,其 HTML 代码如下:

<button id="add" class="button-add">新增</button>
<ul id="list" class="list">
  <li class="item">
    <span class="book">JavaScript DOM 编程艺术</span>
    <span class="delete">删除</span>
  </li>
  <li class="item">
    <span class="book">JavaScript 高级程序设计</span>
    <span class="delete">删除</span>
  </li>
  <li class="item">
    <span class="book">高性能 JavaScript</span>
    <span class="delete">删除</span>
  </li>
  <li class="item">
    <span class="book">JavaScript 设计模式</span>
    <span class="delete">删除</span> 
  </li> 
  <li id="item-5" class="item">
    <span class="book">JavaScript 数据结构与算法</span>
    <span class="delete">删除</span> 
  </li>
</ul>

我们可以看到,这个是一个书单列表,在书单的最上方可以点击添加按钮新增书籍,然后每个书单都支持删除操作。

通常的做法,可以为每个书单的删除按钮添加一个单独的事件侦听器,其 JavaScript 代码如下:

const $add = document.querySelector('#add')
const $list = document.querySelector('#list')
const $buttons = $list.querySelectorAll('.delete')

// 删除书单
const remove = function(evt) {
  // 获取点击的删除按钮
  const $target = evt.target
  // 获取到书单菜单项
  const $li = $target.parentNode
  
  // 删除菜单
  $list.removeChild($li)
}

// 遍历所有按钮
$buttons.forEach($button => {
  // 为每个删除按钮添加 click 事件处理器 remove 以删除表单
  $button.addEventListener('click', remove)
})

目前来看一切都没有问题,但是如果添加上动态添加的书单的功能:

const add = function() {
  const $li = document.createElement('li')

  $li.innerHTML = `
    <li class="item">
      <span class="book">JavaScript DOM 编程艺术</span>
      <span class="delete">删除</span>
    </li>
  ` 
  
  $list.appendChild($li)
}

$add.addEventListener('click', add)

这时会发现,点击新增按钮确实可以在书单中添加新的图书,但是点击删除按钮就没有任何反应了。因为我们的之前获取的**$buttons**中是不包含动态添加的图书信息的。当然我们也可以这么处理一下:

const add = function() {
  const $li = document.createElement('li')
  let $button = null

  $li.innerHTML = `
    <li class="item">
      <span class="book">JavaScript DOM 编程艺术</span>
      <span class="delete">删除</span>
    </li>
  ` 
  
  $list.appendChild($li)
  
  // 手动为每个新添加的 delete 按钮绑定事件处理器
  $button = $li.querySelector('.delete')
  $button.addEventListener('click', remove)
}

$add.addEventListener('click', add)

但是,当添加和删除代码位于应用程序中的不同位置时,这时候添加和删除事件侦听器将是一场噩梦。并且随着书单数量的不断增加,绑定的事件处理器也会越来越多,不占用很多的系统资源。更严重的问题是,如果同时还频繁进行删除的操作,按照示例代码的处理方式,没有在删除 DOM 元素前销毁 DOM 元素绑定的事件处理器,在一些浏览器中会产生内存溢出的问题。

所以像这种场景:父元素是固定的,而其中的子元素会动态增加或者删除。这个时候就适合使用事件委托,为父元素(UL)添加事件侦听器,通过事件冒泡事件流机制,父元素可以通过 event.target 监测分析出子元素的匹配项。实现只绑定一次事件处理器,可以处理其下所有子元素的事件监听。减少了内存消耗,从而优化程序性能。

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

事件冒泡(Event Bubbling)

事件委托技术得以实现,主要是借助了事件冒泡事件流机制。因此,要完全理解事件委托的工作原理,必须要先了解事件冒泡。

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

JavaScript 中的事件委托

另外,与事件冒泡事件流相对的还有一个事件捕获事件流,它的事件触发过程与事件冒泡正好相反,如上图所示。在起初阶段,除了 IE 浏览器默认的事件流是使用的事件冒泡,其它的浏览器(Netscape)采用的是捕获,后又改为了先捕获,后冒泡。

不过经过开发人员30多年不断的实践,事件冒泡事件流得到开发者的青睐。目前主要的浏览器厂商已经把默认的事件流改为了事件冒泡了。来看看下面这个例子:

<div class="container" id="container">
  <span id="text">Text</span> 
</div>
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)

示例中就使用了事件捕,用户点击 target is text 文本,事件捕获会先触发在 body 获得 div 节点上的事件侦听器。控制台会先输出:target is container,然后才触发 text 节点的事件处理器,显示:target is text。

很明显,比起事件捕获,事件冒泡的事件触发机制更符合普通用户的实践操作的预期。例如我鼠标点击的是上图中的 text 的文本节点,通常我们更希望最先触发的是绑定在 text 节点的事件侦听器,

addEventListener() 方法的第三个参数

前文的示例代码中,使用了 addEventListener() 方法为 DOM 元素绑定事件处理器,指定是使用事件冒泡还是事件捕获机制。现在简单介绍一下 addEventListener() 方法,其基础语法如下:

addEventListener(event, function, useCapture)

参数说明:

  • event - 绑定的事件名称;
  • function - 绑定的事件处理器函数;
  • useCapture - 是否使用捕获(默认值:false);

如果你还想体验一把事件捕获,可以将 addEventListener 的第三个参数设置为 true,就像前文的示例代码那样。

事件委托的实现

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

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

<ul id="list" class="list">
  <li class="item">
    <span class="book">JavaScript DOM 编程艺术</span>
    <span class="delete">删除</span>
  </li>
  <li class="item">
    <span class="book">JavaScript 高级程序设计</span>
    <span class="delete">删除</span>
  </li>
</ul>  
const $list = document.querySelector('#list')

// 获取元素,添加点击监听器... 
$list.addEventListener('click', function (e) {
    // e.target 是被点击的元素!
    const $delete = e.target
    
    // 如果它是一个删除按钮
    if ($delete && $delete.className === 'delete') {
        console.log(`点击.delete删除按钮`);
    }
})

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

参考 jQuery 的实现

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

const handler = function(evt){
  console.log(`list ${$li.id} 被点击了`);
}
// 绑定事件委托处理器
$('#list').on('click', '.item', handler)

// 取消事件委托处理器绑定
$('#list').off('click', '.item', handler)

jQuery 的中提供了 on() 和 off() 两个方法,分别是绑定事件委托和取消事件委托绑定。jQuery 的实现方式比前文介绍的实现方式更加灵活,它的 on() 方法可以通过选择器(例如:.item)来分析查找子元素的匹配项。

如果想要实现和 jQuery 类似的事件委托接口,关键是需要找一种方法判断 event.target 是否包含或者说匹配使用的选择器。如果选择器单纯的只是使用类选择器,我们可以通过 event.target.classList 属性,判断 classList 中是否包使用含使用的选择器。但是 jQuery 的 on() 方法的接口中使用的选择器是很灵活的,可以是类选择器,也可以是元素选择器,也可以是其它的选择器。为每个可能的选择器做不同的判断逻辑,那将是一个无比痛苦的事情。

Element.matches() 方法

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

const isMatched = 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() 除了调用十分方便外,各大浏览器的支持情况也很不错。

JavaScript 中的事件委托

如果想兼容更多浏览器,也可以使用 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
    }
}

closest() 方法获得与选择器匹配的元素

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

这里就有一种可能,鼠标点击的直接目标可能是我们期望元素的子元素,这时是从点击的子元素开始向上冒泡,直到达到选择器匹配的元素。那么这时要获取的目标元素就是点击的目标元素的父元素。为此还需要封装一个 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 {Object} [context] - callback 回调函数的 this 上下文(默认值:el)
 * @param {Boolean} [useCapture] - 是否采用事件捕获(默认值:false - 事件冒泡)
 * @returns {Function}
 */
export const on = (el, selector, type, callback, context, useCapture) => {
  const listener = function (e) {
    // 通过 Element.matches 方法获得点击的目标元素
    const delegateTarget = closest(el, 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 移除事件侦听器的绑定。

实现 stop() 方法

除了 on() 和 off() 方法外,在日常的开发中我们还需要一个 stop() 方法来阻止事件冒泡。还是以前文的例子为例:

<nav class="navigation">
    <ul id="list" class="list">
      <li class="item">
        <span class="book">JavaScript DOM 编程艺术</span>
        <span class="delete">删除</span>
      </li>
      <li class="item">
        <span class="book">JavaScript 高级程序设计</span>
        <span class="delete">删除</span>
      </li>
    </ul>  
</nav>
const $nav = document.querySelector('.navigation')
const $list = document.querySelector('#list')

$nav.addEventListener('click', function(evt) {
  console.log('nav to list page')
})
on($list, 'click', '.delete', remove)

通过代码可以知道,由于 list 是 nav 元素的子节点,所以点击 list 中的删除按钮时,由于事件冒泡的机制,也会触发 nav 的事件处理器。如果不希望触发 nav 的事件处理器,我们就需要一个 stop() 方法来阻止事件冒泡,代码如下:

const stop = function (evt) {
  // 阻止冒泡
  evt.stopPropagation()
  // 阻止元素的事件的默认行为 
  evt.preventDefault()
}

export default stopEvent

来看看如何使用 stop() 方法来阻止冒泡:

const $nav = document.querySelector('.navigation')
const $list = document.querySelector('#list')

// 删除书单
const remove = function(evt) {
  // 获取点击的删除按钮
  const $target = evt.target
  // 获取到书单菜单项
  const $li = $target.parentNode
  
  // 删除菜单
  $list.removeChild($li)

  // 阻止事件冒泡
  stop(evt)
}

$nav.addEventListener('click', function(evt) {
  console.log('nav to list page')
})
on($list, 'click', '.delete', remove)

实现于 jQuery 一致的 API 接口

至此,我们已经完成实现事件委托机制的功能函数了。不过我们可以稍微调整一下已经封装好的功能函数,实现一个与 jQuery 一致的 API 接口。代码封装如下:

/**
 * 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
    }
}

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
  }
}

/**
 * 绑定代理事件
 * ========================================================================
 * @param {HTMLElement} el - 绑定代理事件的 DOM 节点
 * @param {String} selector - 触发 el 代理事件的 DOM 节点的选择器
 * @param {String} type - 事件类型
 * @param {Function} callback - 绑定事件的回调函数
 * @param {Object} [context] - callback 回调函数的 this 上下文(默认值:el)
 * @param {Boolean} [useCapture] - 是否采用事件捕获(默认值:false - 事件冒泡)
 * @returns {Function}
 */
const on = (el, selector, type, callback, context, useCapture) => {
  const listener = function (e) {
    // 通过 Element.matches 方法获得点击的目标元素
    const delegateTarget = closest(el, 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
}

/**
 * 取消事件绑定
 * ========================================================================
 * @param {HTMLElement} el - 取消绑定(代理)事件的 DOM 节点
 * @param {String} type - 事件类型
 * @param {Function} callback - 绑定事件的回调函数
 * @param {Boolean} [useCapture] - 是否采用事件捕获(默认值:false - 事件冒泡)
 */
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)
}

const stop = (evt) => {
  // 阻止冒泡
  evt.stopPropagation()
  // 阻止元素的事件的默认行为 
  evt.preventDefault()
}

const $emit = (el) => {
  const $el = document.querySelector(el)
  
  return {
    on(type, selector, handler, context, useCapture) {
      on($el, selector, type, handler, context, useCapture)
      return this
    },
    off(type, selector, handler) {
      off($el, type, handler)
      return this
    }
  }
}

// 静态方法
$emit.stop = (evt) => {
  stop(evt)
}

我们封装的 $emit() 模块的调用方式如下:

const $ = $emit('#list')

const handler = function(evt){
  console.log(`list ${$li.id} 被点击了`);
  $emit.stop(evt)
}

// 绑定事件委托处理器
$.on('click', '.item', handler)

// 取消事件委托处理器绑定
$.off('click', '.item', handler)

怎么样?是不是有点 jQuery 的味道了。

总结

本文介绍了关于事件委托需要掌握的主要的知识点,并且还开发出了 on()、off() 和 stop() 3个常用方法。但如果你需要有一个功能更加完善的事件委托的 JavaScript 工具库, 大家可以去看看我的 delegate.js 项目,它有更加完善的事件处理的工具方法。

在 delegate.js 库中,还有更多关于事件处理的一些知识点,例如事件的默认行为的处理方式,如何阻止监听同一事件的其他事件监听器被调用,以及如何 销毁 DOM 元素所有的事件绑定。感兴趣的朋友可以阅读一下源代码,看看这些处理方式都是如何实现的。

转载自:https://juejin.cn/post/7387801635328983049
评论
请登录