原文链接:http://javascript.info/event-delegation,translate with ❤️ by zhangbao.

利用捕获和冒泡可以实现一种很强大的事件处理模式,称为事件代理

情况是这样的,当大量元素通过同一种相似方式绑定事件的时候,我们无需给每一个元素都绑定相同的一个处理器,而是将这一个事件处理器放在众多元素的共同祖先元素上。

在处理器内部,我们使用 event.target,操作实际的目标元素。

我们看个例子:

事件委托 - 图1

HTML 结构如下:

  1. <table>
  2. <tr>
  3. <th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th>
  4. </tr>
  5. <tr>
  6. <td>...<strong>Northwest</strong>...</td>
  7. <td>...</td>
  8. <td>...</td>
  9. </tr>
  10. <tr>...2 more lines of this kind...</tr>
  11. <tr>...2 more lines of this kind...</tr>
  12. </table>

表格里有 9 个单元格,当然也可以是 99 或 9999 个,这都不重要。

我们的任务是高亮被点击 <td> 元素。

无需给每个 <td>(可以为空)都绑定 onclick 事件处理器,相反我们可以为 <table> 元素绑定一个通用的、可用来处理所有点击了内部 <td> 元素的委托事件处理器。

这里会用到 event.target 获得被点击的目标元素,然后高亮它。

代码如下:

  1. let prevSelectedTd;
  2. table.onclick = function (event) {
  3. let target = event.target;
  4. if (target.tagName !== 'TD') { return ; } // 不关心除 <td> 之外的元素
  5. highlight(target); // 高亮被点击 <td>
  6. };
  7. function highlight(td) {
  8. if (prevSelectedTd) {
  9. prevSelectedTd.classList.remove('highlight'); // 去掉旧元素高亮效果
  10. }
  11. td.classList.add('highlight'); // 为新元素添加高亮效果
  12. prevSelectedTd = td;
  13. }

上面的代码不关心表格里到底有多少个单元格(即 <td>)。我们可以在任何时间动态地为 <td> 添加/移除高亮效果。

但这个方案仍有一个缺点。

就是当点击的是 <td> 中的元素时。

如果我们进一步查看 HTML 代码,会发现 <td> 中还包含 <strong> 这样的标签:

  1. <td>
  2. <strong>Northwest</strong>
  3. ...
  4. </td>

很自然的,如果点击发生在 <strong> 上,event.target 自然也就表示它了。

事件委托 - 图2

table.onclick 处理程序内部,我们要考虑 event.target 不是 <td> 的情况,即是否点击发生于 <td> 内部。

这是改进后的代码:

  1. table.onclick = function (event) {
  2. let td = event.target.closest('td'); // (1)
  3. if (td === null) { return ; } // (2)
  4. if (!table.contains(td)) { return ; } // (3)
  5. highlihght(td); // (4)
  6. };

下面就代码内容来解释下:

  1. elem.closest(selector) 返回距离目标元素 elem 最近的匹配祖先元素。在我们的例子中,就是指 <td>

  2. 如果点击的 event.target 不在 <td> 中,就会返回 null,什么都不做。

  3. 如果 <td> 元素内部包含 <table>,我们就有要判断这个 td 是不是属于最外层表格的,保证高亮的是正确的 <td>

  4. 如果找到了,就高亮。

委托例子:标签的 data-action 特性

事件委托可以用来优化事件处理。我们使用一个处理器,可以同时处理多个具有相似事件行为的元素。就像之前高亮 <td> 元素那样。

我们也可以将单个处理器,作为不同行为的入口来使用。

例如:我们有一个包含“保存”、“加载”和“搜索”等按钮的菜单。那么,就可以使用包含 saveloadsearch 方法的对象来处理这些按钮的事件逻辑。

我们首先想到的,可能是为每个按钮绑定一个单独的事件处理器,但现在有更加优雅的方式——使用标签的 data-action 特性,作为事件处理器处理的依据,点击不同的按钮调用不同的处理方法。

  1. <button data-action="save">点击保存</button>

在处理程序中读取特性值,然后执行对应方法。来看下面的代码:

  1. <div id="menu">
  2. <button data-action="save">保存</button>
  3. <button data-action="load">加载</button>
  4. <button data-action="search">搜索</button>
  5. </div>
  6. <script>
  7. class Menu {
  8. constructor (elem) {
  9. this._elem = elem;
  10. elem.onclick = this.onClick.bind(this); // (*)
  11. }
  12. save () {
  13. alret('保存中');
  14. }
  15. load () {
  16. alert('加载中');
  17. }
  18. search () {
  19. alert('搜索中');
  20. }
  21. onClick () {
  22. let action = event.target.dataset.action;
  23. if (action) {
  24. this[action]();
  25. }
  26. }
  27. }
  28. new Menu(menu)
  29. </script>

请注意,在 (*) , this.onClick 方法的上下文环境绑定到了 this(也就是 Menu 实例对象),这很重要,否则 onClick 方法里使用的 this 指代的就是 DOM 元素 elem,这样的话,执行 this[action] 就是不对的。

因此,事件委托给我们带来的好处是:

  • 我们没必要为每个 button 都单独绑定事件处理器,只需给祖先元素绑定下就行了。

  • HTML 结构变得灵活,我们可以在任何时候添加/删除按钮。

当然,我们也可以使用 .action-save.action-load 这些类名来实现同样功能,但使用 data-action 的方式更加语义化,而且也支持用 CSS 样式修饰。

“行为”模式

我们也可以使用事件委托,为元素声明式添加“行为”,使用一些特殊的特性或类名。

该模式分为两个部分:

  1. 为元素添加一个特殊特性。

  2. 文档级别的事件处理器,如果事件在拥有这个特殊特性的元素上触发了,就执行操作。

计数器

例如,我们为拥有 data-conuter 这个特性的元素添加行为:按钮在“点击时增加计数值”。

  1. 一个计数器: <input type="button" value="1" data-counter>
  2. 再来一个计数器: <input type="button" value="2" data-counter>
  3. <script>
  4. document.addEventListener('click', function (event) {
  5. if (event.target.dataset.counter !== undefined) {
  6. event.target.value++;
  7. }
  8. });
  9. </script>

执行上面的代码,点击按钮,按钮显示数值就会相应增加。

网页里我们可以添加尽可能多的具有 data-counter 特性的元素,我们可以随时向 HTML 插入新的。使用事件委托,我们“扩展”了 HTML,添加一个特性就增加了一个新的行为描述。

事件委托 - 图3文档级别的处理器,总应该用 **addEventListener`` 绑定**

在为 document 对象绑定事件的时候,应该总是使用 addEventListener,而非 document.onclick 这种绑定方式——因为后者会导致覆盖:新的处理器会覆盖旧的。

真实项目中,document 通常会在很多地方使用,被绑定许多不同的事件处理器。

Toggler

再来个例子。一个拥有 data-toggle-id 特性的元素,该特性值表示网页中具有此 id 值的元素,点击后会显示/隐藏这个元素:

  1. <button data-toggle-id="subscribe-mail">显示注册表单</button>
  2. <form id="subscribe-mail" hidden>
  3. Email:<input type="mail">
  4. </form>
  5. <script>
  6. document.addEventListener('click', function (event)) {
  7. let id = event.target.dataset.toggleId;
  8. if (!id) { return ; }
  9. let elem = document.getElementById(id);
  10. elem.hidden = !elem.hiddden;
  11. }
  12. </script>

现在实现的点击显示/隐藏某个元素的,不需要书写额外 JavaScript 代码,直接为元素指定 data-toggle-id 特性即可实现。

这非常方便——无需为具有相同行为的元素编写相似的 JavaScript 代码,我们可以提炼出行为。文档级别的处理程序,能捕获发生在页面中的任何元素行为。

我们也可以在同一个元素上使用多个行为模式代码。

“行为”模式可以替代 JavaScript 的迷你代码片段。

总结

事件委托非常酷,这是 DOM 事件中最有用的模式之一。

这种模式通常用来处理具有相同事件行为的多个元素,但又不限于此。

下列是它的算法:

  1. 为容器绑定一个事件处理器。

  2. 在处理器中,使用 event.target 检查元素。

  3. 如果目标元素就是我们需要处理的,就开始执行处理逻辑。

使用事件委托的好处:

  • 简化初始化过程,节省内存:无需同时添加多个事件处理器了。

  • 更少的代码:添加/删除元素,无需手动添加/删除事件处理器。

  • DOM 修改:对使用 innerHTML 或类似方式添加/删除的元素同样生效。

当然,也存在一些限制:

  • 首先,事件必须会冒泡的,一些事件是不冒泡的。而且不能再低阶的事件处理器中使用 event.stopPropagation()

  • 第二,委托可能会增加 CPU 负载,因为容器级别的处理器会捕获容器中发生的所有行为,不论我们是否需要。但这类负载通常是微不足道的,所以我们不用担心。

练习题

问题

一、使用事件委托隐藏信息

这里有一列携带删除按钮的消息框。写代码让关闭按钮能够正常使用。

页面样式如下图:

事件委托 - 图4

页面结构是这样的:

  1. <div class="container">
  2. <div class="pane">
  3. <h3>Horse</h3>
  4. <p>The horse is one of ...</p>
  5. <button class="remove-button">[x]</button>
  6. </div>
  7. <div class="pane">...</div>
  8. </div>

PS. 使用事件委托,我们只需要在容器上绑定一个事件处理器就可以了。

二、树目录

创建一个树目录,在点击这个树中的孩子节点时能够显示/隐藏子菜单。

事件委托 - 图5

HTML 结构如此:

  1. <style>
  2. .tree span:hover {
  3. font-weight: bold;
  4. }
  5. .tree span {
  6. cursor: pointer;
  7. }
  8. </style>
  9. <ul class="tree" id="tree">
  10. <li>Animals
  11. <ul>
  12. <li>Mammals
  13. <ul>
  14. <li>Cows</li>
  15. <li>Donkeys</li>
  16. <li>Dogs</li>
  17. <li>Tigers</li>
  18. </ul>
  19. </li>
  20. ...
  21. </ul>
  22. </li>
  23. ...
  24. </ul>

要求:

  • 仅使用一个事件处理器(用到事件委托)

  • 点击发生在节点标题之外(或者空白区域)的不会产生任何效果。

三、可排序表格

制作一个可排序的表格:在 <th> 元素上点击的时候,会排序对应列的数据。

每个 <th> 都可以使用 data-type 特性来标记要排序的数据类型,像这样:

  1. <style>
  2. table {
  3. border-collapse: collapse;
  4. }
  5. th, td {
  6. border: 1px solid black;
  7. padding: 4px;
  8. }
  9. th {
  10. cursor: pointer;
  11. }
  12. th:hover {
  13. background: yellow;
  14. }
  15. </style>
  16. <table id="grid">
  17. <thead>
  18. <tr>
  19. <th data-type="number">年龄</th>
  20. <th data-type="string">姓名</th>
  21. </tr>
  22. </thead>
  23. <tbody>
  24. <tr>
  25. <td>5</td>
  26. <td>John</td>
  27. </tr>
  28. <tr>
  29. <td>10</td>
  30. <td>Ann</td>
  31. </tr>
  32. ...
  33. </tbody>
  34. </table>

上例中,表格的第一列数据是数值类型的,第二列数据是字符串类型的。

仅支持 "string""number" 数据类型。

排序之前:事件委托 - 图6按照姓名排序:事件委托 - 图7

PS. 表格可以使任意尺寸的,拥有任意数量的行或者列数据。

四、提示框行为

创建使用 JS 驱动的提示框行为。

当鼠标落在具有 data-tooltip 特性的元素上时,就会出现提示框;鼠标离开元素时,提示框消失。

HTML 结构如下:

  1. <button data-tooltip="the tooltip is longer than the element">Short button</button>
  2. <button data-tooltip="HTML<br>tooltip">One more button</button>

这是效果图

事件委托 - 图8

我们假设所有具有 data-tooltip 特性的元素仅包含文本,没有内嵌标签。

详细:

  • 提示框不能超出窗口边缘。正常来说,出现在元素之上,如果元素位于页面顶部没有足够空间显示提示框,就将提示框置于元素之下显示。

  • 使用 data-tooltip 特性标记需要显示提示语的元素,支持任意 HTML 元素。

这里我们会用到两中事件类型:

  • mouseover 会在鼠标落在元素上时触发。

  • mouseout 会在鼠标离开元素时触发。

请使用事件委托:在 document 上绑定鼠标“落在”和“离开”data-tooltip 元素时的提示框显示逻辑。

功能实现之后,即使是不熟悉 JavaScript 的人也可以添加带提示语的元素。

PS. 为了让事情简单,一个元素上只支持显示一条提示语。

答案

一、使用事件委托隐藏信息

  1. container.onclick = function(event) {
  2. if (event.target.className === 'remove-button') {
  3. let pane = event.target.closest('.pane');
  4. pane.remove();
  5. }
  6. };

二、树目录

  1. // 移动所有文本至 <span>
  2. for (let li of tree.querySelectorAll('li')) {
  3. let span = document.createElement('span');
  4. li.prepend(span);
  5. span.append(span.nextSibling); // 将文本移动至 span
  6. }
  7. // 捕获在真个树上发生的所有点击事件
  8. tree.onclick = function(event) {
  9. if (event.target.tagName === 'SPAN') {
  10. let childrenContainer = event.target.parentNode.querySelector('ul');
  11. if (childrenContainer) {
  12. childrenContainer.hidden = !childrenContainer.hidden;
  13. }
  14. }
  15. }

三、可排序表格

  1. grid.onclick = function(e) {
  2. if (e.target.tagName === 'TH') {
  3. let th = e.target;
  4. // 如果是 TH 的说, 就执行排序逻辑
  5. // cellIndex 就是指当前 th 元素的索引值:
  6. // 0 表示第一列
  7. // 1 表示第二列,以此类推
  8. sortGrid(th.cellIndex, th.dataset.type);
  9. }
  10. };
  11. function sortGrid(colNum, type) {
  12. let tbody = grid.querySelector('tbody');
  13. let rowsArray = Array.from(tbody.rows);
  14. // compare(a, b) 用于比较每行之间的数据进行排序
  15. let compare;
  16. switch (type) {
  17. case 'number':
  18. compare = function(rowA, rowB) {
  19. return rowA.cells[colNum].innerHTML - rowB.cells[colNum].innerHTML;
  20. };
  21. break;
  22. case 'string':
  23. compare = function(rowA, rowB) {
  24. return rowA.cells[colNum].innerHTML.localeCompare(rowB.cells[colNum].innerHTML);
  25. };
  26. break;
  27. }
  28. // 开始排序
  29. rowsArray.sort(compare);
  30. tbody.append(...rowsArray); // 将最终排序结果添加到 tbody
  31. }

四、提示框行为

  1. let tooltipElem;
  2. document.onmouseover = function(event) {
  3. let target = event.target;
  4. // 如果我们有需要显示的提示语(HTML 结构)...
  5. let tooltipHtml = target.dataset.tooltip;
  6. if (!tooltipHtml) return;
  7. // ...创建提示元素
  8. tooltipElem = document.createElement('div');
  9. tooltipElem.className = 'tooltip';
  10. tooltipElem.innerHTML = tooltipHtml;
  11. document.body.append(tooltipElem);
  12. // 将提示元素放置在宿主元素之上(顶部中间的位置)
  13. let coords = target.getBoundingClientRect();
  14. let left = coords.left + (target.offsetWidth - tooltipElem.offsetWidth) / 2;
  15. if (left < 0) left = 0; // 不要超过窗口的左边缘
  16. let top = coords.top - tooltipElem.offsetHeight - 5;
  17. if (top < 0) { // 不要超过窗口的顶部边缘,就在下面显示提示语
  18. top = coords.top + target.offsetHeight + 5;
  19. }
  20. tooltipElem.style.left = left + 'px';
  21. tooltipElem.style.top = top + 'px';
  22. };
  23. document.onmouseout = function(e) {
  24. if (tooltipElem) {
  25. tooltipElem.remove();
  26. tooltipElem = null;
  27. }
  28. };

(完)