捕获和冒泡允许实现一种称为事件委托的强大的事件处理模式。

我们的想法是,如果我们有许多元素是以类似的方式处理的,那么我们就不需要给每个元素分配一个处理器 —— 而是在它们共同的祖先上面添加一个处理器。

在处理器中,我们可以得到 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> 元素设置一个 “catch-all” 处理器。

它会使用 event.target 来获取单击的元素并高亮显示它。

代码:

let selectedTd;

table.onclick = function(event) {
  let target = event.target; // 在哪里单击的?

  if (target.tagName != 'TD') return; // 不在 TD 上?那么我们就不会在意

  highlight(target); // 高亮显示
};

function highlight(td) {
  if (selectedTd) { // 移除任何已存在的高亮显示内容
    selectedTd.classList.remove('highlight');
  }
  selectedTd = td;
  selectedTd.classList.add('highlight'); // 高亮新的 td
}

代码不会关心在表中有多少单元格。随时可以动态添加/移除 <td>,高亮显示仍然有效。

尽管如此,还是存在缺点。

单击可能不是发生在 <td> 上,而是发生在其内部。

在我们的例子中,如果我们查看 HTML 内部,我们可以看到 <td> 内的嵌套标签,比如 <strong>

<td>
  <strong>Northwest</strong>
  ...
</td>

当然,如果单击该 <strong>,那么它将成为 event.target 的值。

在处理器 table.onclick 中,我们应该接受这样的 event.target,并确定单击是否在 <td> 内。

以下是改进后的代码:

table.onclick = function(event) {
  let td = event.target.closest('td'); // (1)

  if (!td) return; // (2)

  if (!table.contains(td)) return; // (3)

  highlight(td); // (4)
};

解释:

  1. elem.closest(selector) 方法返回与选择器匹配的最近的祖先。在我们的例子中,我们从源元素向上查找 <td>
  2. 如果 event.target 不在任何 <td> 中,那么调用将返回 null,我们不需要做任何事情。
  3. 在嵌套表的情况下,event.target 可能是位于当前表格之外的 <td>。因此我们需要检查这是否是我们的表格<td>
  4. 如果是的话,就高亮显示它。

委托示例:标记中的操作

事件委托可优化事件处理。我们使用单个处理器来对许多元素进行相似的操作。就像我们用于高亮显示 <td> 一样。

但我们仍然可以使用单个处理器作为许多不同事件的入口点。

例如,我们想要制作一个有“保存”、“加载”和“搜索”等功能的菜单。有一个拥有 saveloadsearch 等方法的对象。

第一个想法可能是为每个按钮分配一个单独的处理器。但有一个更优雅的解决方案。我们可以为整个菜单添加一个处理器,并为有方法调用的按钮添加 data-action 属性:

<button data-action="save">Click to Save</button>

处理器读取属性并执行方法。查看下述运行示例:

<div id="menu">
  <button data-action="save">Save</button>
  <button data-action="load">Load</button>
  <button data-action="search">Search</button>
</div>

<script>
  class Menu {
    constructor(elem) {
      this._elem = elem;
      elem.onclick = this.onClick.bind(this); // (*)
    }

    save() {
      alert('saving');
    }

    load() {
      alert('loading');
    }

    search() {
      alert('searching');
    }

    onClick(event) {
      let action = event.target.dataset.action;
      if (action) {
        this[action]();
      }
    };
  }

  new Menu(menu);
</script>

请注意,this.onClick(*) 中绑定到了 this。这很重要,否则内部 this 将引用 DOM 元素(elem),而不是菜单对象,this[action] 不是我们所需要的。

那么,这里的委托给我们带来了什么?

  • 我们不需要编写代码来为每个按钮分配一个处理器。只需要创建一个方法并将其放入标记中即可。
  • HTML 结构灵活,可以随时添加/移除按钮。

我们也可以使用 .action-save.action-load,但 data-action 属性在语义上更好。我们也可以在 CSS 规则中使用它。

“行为型”模式

我们还可以使用事件委托声明式地通过特定属性和类为元素添加“行为”。

模式分为两步:

  1. 我们向元素添加一个特殊属性。
  2. 用文档范围级的处理器追踪事件,如果事件发生在具有特定属性的元素上 —— 则执行该操作。

计数

例如,这里的 data-counter 属性给按钮添加了一个“点击增加”的行为。

Counter: <input type="button" value="1" data-counter>
One more counter: <input type="button" value="2" data-counter>

<script>
  document.addEventListener('click', function(event) {

    if (event.target.dataset.counter != undefined) { // if the attribute exists...
      event.target.value++;
    }

  });
</script>

如果我们点击按钮 —— 它的值就会增加。但不仅仅是按钮,一般的方法在这里也很重要。

我们可以有很多像 data-counter 一样的属性。我们可以在任何时候向 HTML 添加新的属性。使用事件委托,我们可以“扩展” HTML,添加一个描述新行为的属性。

对于文档级的处理器 —— 始终是 addEventListener

当我们将事件处理器分配给 document 对象,我们应该始终使用 addEventListener,而不是 document.onclick,因为后者会导致冲突:新的处理器会重写旧的。

对于实际项目来说。代码的不同部分设置的 document 上有许多处理器是正常的。

切换器

再举一个例子,单击一个具有 data-toggle-id 属性的元素将显示/隐藏具有给定 id 的元素:

<button data-toggle-id="subscribe-mail">
  Show the subscription form
</button>

<form id="subscribe-mail" hidden>
  Your mail: <input type="email">
</form>

<script>
  document.addEventListener('click', function(event) {
    let id = event.target.dataset.toggleId;
    if (!id) return;

    let elem = document.getElementById(id);

    elem.hidden = !elem.hidden;
  });
</script>

让我们再一次指出我们做了什么。现在,要将切换功能添加到元素中 —— 不需要了解 JavaScript,只需要使用 data-toggle-id 属性。

这可能变得非常方便 —— 不需要为每个这样的元素编写 JavaScript。只需要使用行为。文档级处理器使其可以用于页面的任何元素。

我们也可以结合单个元素上的多个行为。

“行为型”模式可以替代 JavaScript 的小片段。

总结

事件委托真的很酷!这是 DOM 事件最有用的模式之一。

它通常用于为许多相似的元素添加相同的处理,但不仅仅只是这样。

算法:

  1. 在容器上设置一个处理器。
  2. 在处理器中 —— 检查源元素的 event.target
  3. 如果事件发生在我们感兴趣的元素中,那么处理该事件。

好处:

  • 简化初始化并节省内存:不需要添加许多处理器。
  • 更少的代码:添加或移除元素时,不需要添加/移除处理器。
  • DOM 修改 :我们可以使用 innerHTML 等来大量添加/移除元素。

委托处理方式也有局限性:

  • 首先,事件必须冒泡。而有些事件不会冒泡。此外,低级别的处理器不应该使用 event.stopPropagation()
  • 其次,委托会增加 CPU 负载,因为容器等级的处理器对容器中任何位置的事件做出反应,不管它们是否会引起我们的兴趣。但是通常负载是可以忽略不计的,所以我们不考虑它。

任务

重要程度: 5

有一张带有移除按钮 [x] 的消息列表。让按钮可以工作。

就像这样:

P.S. 在容器上应该只有一个监听器,即使用事件委托。

打开一个任务沙箱。

重要程度: 5

创建一个单击可以显示/隐藏子节点的树形菜单:

要求:

  • 只有一个事件处理器(使用委托)
  • 单击节点标题以外的地方(空白区域)不做任何处理。

打开一个任务沙箱。

解决方案分为两步。

  1. 将每个树节点的标题用 <span> 包裹起来。然后我们可以在 :hover 上使用 CSS 样式,并准确地处理文本上的单击事件,因为 <span> 宽度正好是文本宽度(与没有它不同)。
  2. tree 的根节点设置处理器来处理 <span> 中标题的单击事件。

使用沙箱打开解决方案。

重要程度: 4

让表格可以排序:单击 <th> 元素让对应的列自动排序。

每个 <th> 都有类型属性,如下所示:

<table id="grid">
  <thead>
    <tr>
      <th data-type="number">Age</th>
      <th data-type="string">Name</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>5</td>
      <td>John</td>
    </tr>
    <tr>
      <td>10</td>
      <td>Ann</td>
    </tr>
    ...
  </tbody>
</table>

在上面的例子中,第一列为数字,第二列为 —— 字符串。排序函数应根据类型进行排序。

应该只支持 "string""number" 类型。

运行示例:

P.S. 表可以很大,有任意数量的行和列。

打开一个任务沙箱。

重要程度: 5

为提示工具行为编写 JavaScript 代码。

当鼠标移入带有 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 的元素中所有的 “over” 和 “out”,并从那里管理提示工具。

在实现了该行为后,即使是不熟悉 JavaScript 的人也可以添加带注释的元素。

P.S. 为了保持自然和简单:一次只能出现一个提示工具。

打开一个任务沙箱。

教程路线图

评论

在评论之前先阅读本内容…
  • 如果你发现教程有错误,或者有其他需要修改和提升的地方 — 请 提交一个 GitHub issue 或 pull request,而不是在这评论。
  • 如果你对教程的内容有不理解的地方 — 请详细说明。
  • 使用 <code> 标签插入只有几个词的代码,插入多行代码可以使用 <pre> 标签,对于超过 10 行的代码,建议你使用沙箱(plnkrJSBincodepen…)