原文链接:http://javascript.info/event-delegation,translate with ❤️ by zhangbao.
利用捕获和冒泡可以实现一种很强大的事件处理模式,称为事件代理。
情况是这样的,当大量元素通过同一种相似方式绑定事件的时候,我们无需给每一个元素都绑定相同的一个处理器,而是将这一个事件处理器放在众多元素的共同祖先元素上。
在处理器内部,我们使用 event.target,操作实际的目标元素。
我们看个例子:

HTML 结构如下:
<table><tr><th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th></tr><tr><td>...<strong>Northwest</strong>...</td><td>...</td><td>...</td></tr><tr>...2 more lines of this kind...</tr><tr>...2 more lines of this kind...</tr></table>
表格里有 9 个单元格,当然也可以是 99 或 9999 个,这都不重要。
我们的任务是高亮被点击 <td> 元素。
无需给每个 <td>(可以为空)都绑定 onclick 事件处理器,相反我们可以为 <table> 元素绑定一个通用的、可用来处理所有点击了内部 <td> 元素的委托事件处理器。
这里会用到 event.target 获得被点击的目标元素,然后高亮它。
代码如下:
let prevSelectedTd;table.onclick = function (event) {let target = event.target;if (target.tagName !== 'TD') { return ; } // 不关心除 <td> 之外的元素highlight(target); // 高亮被点击 <td>};function highlight(td) {if (prevSelectedTd) {prevSelectedTd.classList.remove('highlight'); // 去掉旧元素高亮效果}td.classList.add('highlight'); // 为新元素添加高亮效果prevSelectedTd = td;}
上面的代码不关心表格里到底有多少个单元格(即 <td>)。我们可以在任何时间动态地为 <td> 添加/移除高亮效果。
但这个方案仍有一个缺点。
就是当点击的是 <td> 中的元素时。
如果我们进一步查看 HTML 代码,会发现 <td> 中还包含 <strong> 这样的标签:
<td><strong>Northwest</strong>...</td>
很自然的,如果点击发生在 <strong> 上,event.target 自然也就表示它了。

在 table.onclick 处理程序内部,我们要考虑 event.target 不是 <td> 的情况,即是否点击发生于 <td> 内部。
这是改进后的代码:
table.onclick = function (event) {let td = event.target.closest('td'); // (1)if (td === null) { return ; } // (2)if (!table.contains(td)) { return ; } // (3)highlihght(td); // (4)};
下面就代码内容来解释下:
elem.closest(selector)返回距离目标元素elem最近的匹配祖先元素。在我们的例子中,就是指<td>。如果点击的
event.target不在<td>中,就会返回null,什么都不做。如果
<td>元素内部包含<table>,我们就有要判断这个td是不是属于最外层表格的,保证高亮的是正确的<td>。如果找到了,就高亮。
委托例子:标签的 data-action 特性
事件委托可以用来优化事件处理。我们使用一个处理器,可以同时处理多个具有相似事件行为的元素。就像之前高亮 <td> 元素那样。
我们也可以将单个处理器,作为不同行为的入口来使用。
例如:我们有一个包含“保存”、“加载”和“搜索”等按钮的菜单。那么,就可以使用包含 save、load 和 search 方法的对象来处理这些按钮的事件逻辑。
我们首先想到的,可能是为每个按钮绑定一个单独的事件处理器,但现在有更加优雅的方式——使用标签的 data-action 特性,作为事件处理器处理的依据,点击不同的按钮调用不同的处理方法。
<button data-action="save">点击保存</button>
在处理程序中读取特性值,然后执行对应方法。来看下面的代码:
<div id="menu"><button data-action="save">保存</button><button data-action="load">加载</button><button data-action="search">搜索</button></div><script>class Menu {constructor (elem) {this._elem = elem;elem.onclick = this.onClick.bind(this); // (*)}save () {alret('保存中');}load () {alert('加载中');}search () {alert('搜索中');}onClick () {let action = event.target.dataset.action;if (action) {this[action]();}}}new Menu(menu)</script>
请注意,在 (*) , this.onClick 方法的上下文环境绑定到了 this(也就是 Menu 实例对象),这很重要,否则 onClick 方法里使用的 this 指代的就是 DOM 元素 elem,这样的话,执行 this[action] 就是不对的。
因此,事件委托给我们带来的好处是:
我们没必要为每个
button都单独绑定事件处理器,只需给祖先元素绑定下就行了。HTML 结构变得灵活,我们可以在任何时候添加/删除按钮。
当然,我们也可以使用 .action-save,.action-load 这些类名来实现同样功能,但使用 data-action 的方式更加语义化,而且也支持用 CSS 样式修饰。
“行为”模式
我们也可以使用事件委托,为元素声明式添加“行为”,使用一些特殊的特性或类名。
该模式分为两个部分:
为元素添加一个特殊特性。
文档级别的事件处理器,如果事件在拥有这个特殊特性的元素上触发了,就执行操作。
计数器
例如,我们为拥有 data-conuter 这个特性的元素添加行为:按钮在“点击时增加计数值”。
一个计数器: <input type="button" value="1" data-counter>再来一个计数器: <input type="button" value="2" data-counter><script>document.addEventListener('click', function (event) {if (event.target.dataset.counter !== undefined) {event.target.value++;}});</script>
执行上面的代码,点击按钮,按钮显示数值就会相应增加。
网页里我们可以添加尽可能多的具有 data-counter 特性的元素,我们可以随时向 HTML 插入新的。使用事件委托,我们“扩展”了 HTML,添加一个特性就增加了一个新的行为描述。
文档级别的处理器,总应该用 **addEventListener`` 绑定**
在为
document对象绑定事件的时候,应该总是使用addEventListener,而非document.onclick这种绑定方式——因为后者会导致覆盖:新的处理器会覆盖旧的。真实项目中,
document通常会在很多地方使用,被绑定许多不同的事件处理器。
Toggler
再来个例子。一个拥有 data-toggle-id 特性的元素,该特性值表示网页中具有此 id 值的元素,点击后会显示/隐藏这个元素:
<button data-toggle-id="subscribe-mail">显示注册表单</button><form id="subscribe-mail" hidden>Email:<input type="mail"></form><script>document.addEventListener('click', function (event)) {let id = event.target.dataset.toggleId;if (!id) { return ; }let elem = document.getElementById(id);elem.hidden = !elem.hiddden;}</script>
现在实现的点击显示/隐藏某个元素的,不需要书写额外 JavaScript 代码,直接为元素指定 data-toggle-id 特性即可实现。
这非常方便——无需为具有相同行为的元素编写相似的 JavaScript 代码,我们可以提炼出行为。文档级别的处理程序,能捕获发生在页面中的任何元素行为。
我们也可以在同一个元素上使用多个行为模式代码。
“行为”模式可以替代 JavaScript 的迷你代码片段。
总结
事件委托非常酷,这是 DOM 事件中最有用的模式之一。
这种模式通常用来处理具有相同事件行为的多个元素,但又不限于此。
下列是它的算法:
为容器绑定一个事件处理器。
在处理器中,使用
event.target检查元素。如果目标元素就是我们需要处理的,就开始执行处理逻辑。
使用事件委托的好处:
简化初始化过程,节省内存:无需同时添加多个事件处理器了。
更少的代码:添加/删除元素,无需手动添加/删除事件处理器。
DOM 修改:对使用
innerHTML或类似方式添加/删除的元素同样生效。
当然,也存在一些限制:
首先,事件必须会冒泡的,一些事件是不冒泡的。而且不能再低阶的事件处理器中使用
event.stopPropagation()。第二,委托可能会增加 CPU 负载,因为容器级别的处理器会捕获容器中发生的所有行为,不论我们是否需要。但这类负载通常是微不足道的,所以我们不用担心。
练习题
问题
一、使用事件委托隐藏信息
这里有一列携带删除按钮的消息框。写代码让关闭按钮能够正常使用。
页面样式如下图:

页面结构是这样的:
<div class="container"><div class="pane"><h3>Horse</h3><p>The horse is one of ...</p><button class="remove-button">[x]</button></div><div class="pane">...</div></div>
PS. 使用事件委托,我们只需要在容器上绑定一个事件处理器就可以了。
二、树目录
创建一个树目录,在点击这个树中的孩子节点时能够显示/隐藏子菜单。

HTML 结构如此:
<style>.tree span:hover {font-weight: bold;}.tree span {cursor: pointer;}</style><ul class="tree" id="tree"><li>Animals<ul><li>Mammals<ul><li>Cows</li><li>Donkeys</li><li>Dogs</li><li>Tigers</li></ul></li>...</ul></li>...</ul>
要求:
仅使用一个事件处理器(用到事件委托)
点击发生在节点标题之外(或者空白区域)的不会产生任何效果。
三、可排序表格
制作一个可排序的表格:在 <th> 元素上点击的时候,会排序对应列的数据。
每个 <th> 都可以使用 data-type 特性来标记要排序的数据类型,像这样:
<style>table {border-collapse: collapse;}th, td {border: 1px solid black;padding: 4px;}th {cursor: pointer;}th:hover {background: yellow;}</style><table id="grid"><thead><tr><th data-type="number">年龄</th><th data-type="string">姓名</th></tr></thead><tbody><tr><td>5</td><td>John</td></tr><tr><td>10</td><td>Ann</td></tr>...</tbody></table>
上例中,表格的第一列数据是数值类型的,第二列数据是字符串类型的。
仅支持 "string" 和 "number" 数据类型。
排序之前:
按照姓名排序:
PS. 表格可以使任意尺寸的,拥有任意数量的行或者列数据。
四、提示框行为
创建使用 JS 驱动的提示框行为。
当鼠标落在具有 data-tooltip 特性的元素上时,就会出现提示框;鼠标离开元素时,提示框消失。
HTML 结构如下:
<button data-tooltip="the tooltip is longer than the element">Short button</button><button data-tooltip="HTML<br>tooltip">One more button</button>
这是效果图

我们假设所有具有 data-tooltip 特性的元素仅包含文本,没有内嵌标签。
详细:
提示框不能超出窗口边缘。正常来说,出现在元素之上,如果元素位于页面顶部没有足够空间显示提示框,就将提示框置于元素之下显示。
使用
data-tooltip特性标记需要显示提示语的元素,支持任意 HTML 元素。
这里我们会用到两中事件类型:
mouseover会在鼠标落在元素上时触发。mouseout会在鼠标离开元素时触发。
请使用事件委托:在 document 上绑定鼠标“落在”和“离开”data-tooltip 元素时的提示框显示逻辑。
功能实现之后,即使是不熟悉 JavaScript 的人也可以添加带提示语的元素。
PS. 为了让事情简单,一个元素上只支持显示一条提示语。
答案
一、使用事件委托隐藏信息
container.onclick = function(event) {if (event.target.className === 'remove-button') {let pane = event.target.closest('.pane');pane.remove();}};
二、树目录
// 移动所有文本至 <span>for (let li of tree.querySelectorAll('li')) {let span = document.createElement('span');li.prepend(span);span.append(span.nextSibling); // 将文本移动至 span}// 捕获在真个树上发生的所有点击事件tree.onclick = function(event) {if (event.target.tagName === 'SPAN') {let childrenContainer = event.target.parentNode.querySelector('ul');if (childrenContainer) {childrenContainer.hidden = !childrenContainer.hidden;}}}
三、可排序表格
grid.onclick = function(e) {if (e.target.tagName === 'TH') {let th = e.target;// 如果是 TH 的说, 就执行排序逻辑// cellIndex 就是指当前 th 元素的索引值:// 0 表示第一列// 1 表示第二列,以此类推sortGrid(th.cellIndex, th.dataset.type);}};function sortGrid(colNum, type) {let tbody = grid.querySelector('tbody');let rowsArray = Array.from(tbody.rows);// compare(a, b) 用于比较每行之间的数据进行排序let compare;switch (type) {case 'number':compare = function(rowA, rowB) {return rowA.cells[colNum].innerHTML - rowB.cells[colNum].innerHTML;};break;case 'string':compare = function(rowA, rowB) {return rowA.cells[colNum].innerHTML.localeCompare(rowB.cells[colNum].innerHTML);};break;}// 开始排序rowsArray.sort(compare);tbody.append(...rowsArray); // 将最终排序结果添加到 tbody}
四、提示框行为
let tooltipElem;document.onmouseover = function(event) {let target = event.target;// 如果我们有需要显示的提示语(HTML 结构)...let tooltipHtml = target.dataset.tooltip;if (!tooltipHtml) return;// ...创建提示元素tooltipElem = document.createElement('div');tooltipElem.className = 'tooltip';tooltipElem.innerHTML = tooltipHtml;document.body.append(tooltipElem);// 将提示元素放置在宿主元素之上(顶部中间的位置)let coords = target.getBoundingClientRect();let left = coords.left + (target.offsetWidth - tooltipElem.offsetWidth) / 2;if (left < 0) left = 0; // 不要超过窗口的左边缘let top = coords.top - tooltipElem.offsetHeight - 5;if (top < 0) { // 不要超过窗口的顶部边缘,就在下面显示提示语top = coords.top + target.offsetHeight + 5;}tooltipElem.style.left = left + 'px';tooltipElem.style.top = top + 'px';};document.onmouseout = function(e) {if (tooltipElem) {tooltipElem.remove();tooltipElem = null;}};
(完)
文档级别的处理器,总应该用 **addEventListener`` 绑定**