原文链接: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;
}
};
(完)