JavaScript 事件代理深入

178 阅读5分钟

事件代理的基本思想是利用事件冒泡,只使用一个事件处理程序来管理一种类型的事件,从而减少网页对内存的占用和先期指定事件造成页面交互的延迟。本文从我个人学习事件代理的角度来循序渐进地讨论事件代理的正确实现。本文假设你已经知晓浏览器事件冒泡和捕获,因此不再赘述。

“错误”的写法

对于下面的 HTML 代码,

<ul class="items">
  <li id="item1">item 1</li>
  <li id="item2">item 2</li>
  <li id="item3">item 3</li>
</ul>

我最开始在网上学习到的事件代理的版本,也是大部分文章使用的一般人使用的方法,如下,

let items = document.querySelector('.items');
items.addEventListener('click', (event) => {
  let target = event.target;
  switch(target.id) {
    case "item1":
      console.log('click item1');
      break;
    case "item2":
      console.log('click item2');
      break;
    case "item3":
      console.log('click item3');
      break;
  }
})

这个写法是可行的,那为什么是 “错误” 的呢?来看下面的例子

<ul class="items">
  <li id="item1"><span>item 1</span></li>
  <li id="item2"><span>item 2</span></li>
  <li id="item3"><span>item 3</span></li>
</ul>

仅仅是将 <li> 标签的内容添加了一层嵌套,上面的 JS 代码就无效了,只有在点击 <li> 前面的圆点的时候才可以响应点击事件。

this? target? currentTarget?

在搞清楚为什么会出现上面的现象之前,我们先要清楚在事件处理函数中 target, currentTargetthis 的值分别指代什么东西(可直接看结论)。对于下面代码

<!-- 相关样式已省略 -->
<div class="a">
  <div class="b">
    <div class="c"></div>
  </div>
</div>

添加如下的点击事件

let a = document.querySelector('.a');
a.addEventListener('click', function (event) {
  console.log('this', this);
  console.log('target', event.target);
  console.log('currentTarget', event.currentTarget);
});

在点击 class 为 a 的 div 时,控制台输出

this <div class="a">…</div>
target <div class="a"></div>
currentTarget <div class="a">…</div>

在点击 class 为 b 的 div 时,控制台输出

this <div class="a">…</div>
target <div class="b"></div>
currentTarget <div class="a">…</div>

在点击 class 为 c 的 div 时,控制台输出

this <div class="a">…</div>
target <div class="c"></div>
currentTarget <div class="a">…</div>

我们可以得出以下结论:

在事件处理函数内部,this 对象始终等于 currentTarget 的值,表示被事件处理函数挂载的对象(在这个例子中是 a),target 只包含事件的实际目标,也即是相应事件的触发者。如果事件处理程序直接添加在了意图指定的目标,则 this, currentTargettarget 的值是一样的。另外需要注意的是,如果事件处理逻辑包含在箭头「=>」函数内,this 作用域被影响,则不等于 currentTarget

“修正”

搞清楚 target 是什么之后,我们回过头看原来的事件代理函数。

items.addEventListener('click', (event) => {
  let target = event.target;
  switch(target.id) {
    // ......
  }
})

现在就比较清楚了,在原来 <li> 标签的内容添加了一层 <span> 嵌套后,(点击文字)触发点击事件的对象为 <span>,也即代码中 target 指向 <li> 中的 <span>,所以后面根据 id 执行相应输出的代码无效了。

那么该如何解决这个问题呢,经历短时间的搜寻后,发现一个偏方。将要响应的元素下面一层子元素的响应的 CSS 属性 pointer-events 设置为 none。在这个例子中,也即

.items span {
  pointer-events: none;
}

pointer-events 设置为 none 时,元素永远不会成为鼠标事件的 target,并且在子元素 pointer-events 属性未设置时,鼠标事件将在捕获或冒泡阶段触发父元素的事件侦听器。使用这种方法相当使点击事件穿透了 <span>,代码中的 target 依旧为 <li>

通用写法

上一小节讨论的只是某些情况下能用的事件代理写法,正是因为我一开始用的上面的写法,导致我在实际开发中发现不能适用的场景,也才有了上面算是 「歪门邪道」的修正。从这里开始讨论如何正确地实现事件代理。

首先来捋清思路,其实也很简单:因为事件冒泡,触发事件的元素不一定是需要被代理的元素,所以从该元素开始,一层层向上寻找,直到与需要被代理的元素相匹配。代码如下所示:

function delegate(parentSelector, targetSelector, eventType, fn) {
  let parent = document.querySelector(parentSelector);
  parent.addEventListener(eventType, (ev) => {
    let ele = ev.target;
    while (!ele.matches(targetSelector)) {
      if (ele === parent) {
        ele = null;
        break;
      }
      ele = ele.parentNode;
    }
    ele && fn.call(ele, ev)
  })
}

方法传入四个参数,分别是 代理元素选择器,被代理元素选择器,事件类型以及事件函数。主要代码从第 4 行开始,首先拿到触发点击事件的元素,第 5 行从该元素开始进行匹配,第 6 行表示向上匹配的过程中,如果到达了代理元素还没有找到匹配,停止循环。第 12 行表示如果 ele 不为空,即匹配到目标元素,就调用开始传入的事件函数。该函数使用方法如下:

delegate('ul.items', 'span', 'click', function (e) {
  console.log('click item' + this.innerText);
});

有一点值得注意的是,在事件函数中使用 this 来指代目标元素,不要用 e.target 表示目标元素也不要用箭头函数

添加兼容性

为上面的代码添加兼容性后,最终代码如下:

function delegate(parentSelector, targetSelector, eventType, fn) {

  // 兼容 IE 添加处理函数
  function addHandler(element, type, handler) {
    if (element.addEventListener) {
      element.addEventListener(type, handler, false);
    } else if (element.attachEvent) {
      element.attachEvent("on" + type, handler);
    } else {
      element["on" + type] = handlder;
    }
  }

  // 兼容 matches
  if (!Element.prototype.matches) {
    Element.prototype.matches =
      Element.prototype.matchesSelector ||
      Element.prototype.mozMatchesSelector ||
      Element.prototype.msMatchesSelector ||
      Element.prototype.oMatchesSelector ||
      Element.prototype.webkitMatchesSelector ||
      function (s) {
      var matches = (this.document || this.ownerDocument).querySelectorAll(s),
          i = matches.length;
      while (--i >= 0 && matches.item(i) !== this) { }
      return i > -1;
    };
  }

  var parent = document.querySelector(parentSelector);

  addHandler(parent, eventType, function (ev) {
    // 兼容 IE
    var e = e || window.event;
    var t = e.target || e.srcElement;

    while (!t.matches(targetSelector)) {
      if (t === parent) {
        t = null;
        break;
      }
      t = t.parentNode;
    }
    t && fn.call(t, ev)
  });
}

小结

回过头来看,这篇文章描述了我从一开始接触事件代理到发现问题,再到探讨出通用写法。我没有一开始直接介绍后面通用的写法,因为互联网上充斥着大量我最开始遇到的写法,也正因为碰巧碰到了这个写法的问题,才花了一小段时间去研究它,也给我留下了深刻的印象,虽然是个小问题,也让我想把他记录下来,感谢阅读。