利用原生 Javascript 实现 Delegated Event

想要实现类似于 jQuery 中类似于 .on() 中的 Delegated Event,却又不想用 jQuery 怎么破?

先看问题

举个例子说明一下,有一组按钮,每当点击其中一个按钮,就把这个按钮的状态变为 "active",再点一下就取消 "active" 状态,代码如下:

<ul class="toolbar">
  <li><button class="btn">Pencil</button></li>
  <li><button class="btn">Pen</button></li>
  <li><button class="btn">Eraser</button></li>
</ul>

用最普通的 js 可以这样处理:

var buttons = document.querySelectorAll(".toolbar .btn");

for(var i = 0; i < buttons.length; i++) {
  var button = buttons[i];
  button.addEventListener("click", function() {
    if(!button.classList.contains("active"))
      button.classList.add("active");
    else
      button.classList.remove("active");
  });
}

不过并没有达到预期的效果。

闭包惹的祸

有经验的读者可能已经看出不对劲的地方了。那是因为处理点击事件的 handler 函数形成独立的作用域,是其中的 button 会尝试去更上级的作用域去寻找。
不过真正当你去点击按钮的时候,循环已经完成,button 就会一直指向最后一个按钮,所以效果就是不管点击哪个按钮都是最后一个按钮的状态在变化。

把代码改善一下:

var buttons = document.querySelectorAll(".toolbar button");
var createToolbarButtonHandler = function(button) {
  return function() {
    if(!button.classList.contains("active"))
      button.classList.add("active");
    else
      button.classList.remove("active");
  };
};

for(var i = 0; i < buttons.length; i++) {
  button.addEventListener("click", createToolBarButtonHandler(buttons[i]));
}

好了,现在就满足要求了。

不过。。。

虽然可以勉强使用,但还可以做地更好一些。

首先上面的代码会产生许多 handler,在只有三个按钮的时候还是可以接受的。

不过当有上千个按钮需要监听点击事件的情况:

<ul class="toolbar">
  <li><button id="button_0001">Foo</button></li>
  <li><button id="button_0002">Bar</button></li>
  // ... 997 more elements ...
  <li><button id="button_1000">baz</button></li>
</ul>

就没那么轻松了,虽说不会崩溃,但这种方式非常不理想。上面的实现方式是绑定了好多不同的却功能相似的函数,其实根本不需要这样。只需要绑定一个共享的函数就够了。

改动很简单,可以使用对应的事件对象作为 handler 的参数,就可以通过event.currentTarget很方便地找到对应点击的按钮了。

译者注:这里的 event.currentTarget 也就相当于 handler 中的 this

var buttons = document.querySelectorAll(".toolbar button");

var toolbarButtonHandler = function(e) {
  var button = e.currentTarget;
  if(!button.classList.contains("active"))
    button.classList.add("active");
  else
    button.classList.remove("active");
};

for(var i = 0; i < buttons.length; i++) {
  button.addEventListener("click", toolbarButtonHandler);
}

到此我们的确实现了绑定同一个 handler,而且增加了代码的可读性。

不过还可以做的更好。

假设这样一种场景,按钮组中会动态的添加新的按钮进来,这样就还得在新添加的按钮上绑定监听处理。这就有点麻烦了。

不如换一种方法。

先回想一下 DOM 中 event 的工作原理。

DOM Event 的工作原理简析

当点击一个元素,会产生一个点击事件,这个事件分为三个阶段。

  • Capturing 捕获阶段
  • Target 目标阶段
  • Bubbling 冒泡阶段

NOTE: Not all events bubble/capture, instead they are dispatched directly on the target, but most do.
The event starts outside the document and then descends through the DOM hierarchy to the target of the event. Once the event reaches it's target, it then turns around and heads back out the same way, until it exits the DOM.
注:虽然并不是所有事件的都有 冒泡/捕获 阶段,但绝大部分都有。捕获阶段是从最外层的 document 开始,穿过目标元素的祖先元素,到达目标元素,然后再原路冒泡回到 document。

从一段 HTML 代码的例子来看:

<html>
<body>
  <ul>
    <li id="li_1"><button id="button_1">Button A</button></li>
    <li id="li_2"><button id="button_2">Button B</button></li>
    <li id="li_3"><button id="button_3">Button C</button></li>
  </ul>
</body>
</html>

如果点击 Button A 按钮,事件的过程是这样的:

START
| #document  \
| HTML        |
| BODY         } CAPTURE PHASE
| UL          |
| LI#li_1    /
| BUTTON     <-- TARGET PHASE
| LI#li_1    \
| UL          |
| BODY         } BUBBLING PHASE 
| HTML        |
v #document  /
END

我们可以注意到在事件的冒泡阶段,按钮的祖先元素 ul 也可以收到点击事件。我们可以利用这个现象和已知元素的层级简化代码,实现 Delegated Events。

Delegated Events

Delegated Events 是把事件处理绑定在真正需要被绑定元素的祖先元素上,然后通过一定的条件筛选出真正需要被绑定的元素。

还是最初的代码:

<ul class="toolbar">
  <li><button class="btn">Pencil</button></li>
  <li><button class="btn">Pen</button></li>
  <li><button class="btn">Eraser</button></li>
</ul>

既然每次事件冒泡的阶段 ul.toolbar 也可以收到点击事件,我们就把事件绑定在它上面。修改对应的 js 代码:

var toolbar = document.querySelectorAll(".toolbar");
toolbar.addEventListener("click", function(e) {
  var button = e.target;
  if(!button.classList.contains("active"))
    button.classList.add("active");
  else
    button.classList.remove("active");
});

That cleaned up a lot of code, and we have no more loops! Notice that we use e.target instead of e.currentTarget as we did before. That is because we are listening for the event at a different level.
去掉了 for 循环使代码看起来清爽多了。注意这次用的是 e.target 而非 e.currentTarget

  • e.target 是事件的目标元素,也就是例子的 button.btn
  • e.currentTarget 是被绑定事件处理的元素,也就是例子中的 ul.toolbar

More Robust Delegated Events

现在已经可以处理所有 ul.toolbar 后代元素的点击事件,不过这样有些太简单了,我们需要过滤掉不能被点击的后代元素:

<ul class="toolbar">
  <li><button class="btn"><i class="fa fa-pencil"></i> Pencil</button></li>
  <li><button class="btn"><i class="fa fa-paint-brush"></i> Pen</button></li>
  <li class="separator"></li>
  <li><button class="btn"><i class="fa fa-eraser"></i> Eraser</button></li>
</ul>

我们并不需要处理对 li.separator 的点击事件,那就加一个过滤辅助函数:

var delegate = function(criteria, listener) {
  return function(e) {
    var el = e.target;
    do {
      if (!criteria(el)) continue;
      e.delegateTarget = el;
      listener.apply(this, arguments);
      return;
    } while( (el = el.parentNode) );
  };
};

这个过滤辅助函数的作用,一是判断 e.target 和它的所有祖先元素是否满足过滤条件。如果满足就在事件对象上增加一个 delegateTarget 属性,用于后面使用,然后调用事件的处理函数。如果一路检查所有祖先元素,都不符合条件则不触发处理函数。

具体使用:

var toolbar = document.querySelector(".toolbar");
var buttonsFilter = function(elem) { return elem.classList && elem.classList.contains("btn"); };
var buttonHandler = function(e) {
  var button = e.delegateTarget;
  if(!button.classList.contains("active"))
    button.classList.add("active");
  else
    button.classList.remove("active");
};
toolbar.addEventListener("click", delegate(buttonsFilter, buttonHandler));

没错!就是这个意思。只需要在一个元素上绑定一个 handler,就够了。并且也不需要担心动态增加的元素。这就是所谓的 Delegated Events。

封装

上面已经实现了在不使用 jQuery 的情况下实现 Delegated Events。

还可以把代码进一步封装一下:

  • Create helper functions to handle criteria matching in a unified functional way. Something like:
var criteria = {
  isElement: function(e) { return e instanceof HTMLElement; },
  hasClass: function(cls) {
    return function(e) {
      return criteria.isElement(e) && e.classList.contains(cls);
    }
  }
  // More criteria matchers
};
  • A partial application helper would also be nice:
var partialDelgate = function(criteria) {
  return function(handler) { 
    return delgate(criteria, handler);
  }
};

原文链接


生吞橘子

335 声望
8 粉丝
0 条评论
推荐阅读
单文件组件下的vue,可以擦出怎样的火花
与时俱进吧,看着 vue3 和 vite,虽然不会用,但还是心痒痒,然后就把原先基于 vue@2 的实现做了重构。不周之处,大家见谅!下面关于过期的内容,我就用删除线标记了。

leftstick64阅读 45.2k评论 18

从零搭建 Node.js 企业级 Web 服务器(零):静态服务
过去 5 年,我前后在菜鸟网络和蚂蚁金服做开发工作,一方面支撑业务团队开发各类业务系统,另一方面在自己的技术团队做基础技术建设。期间借着 Node.js 的锋芒做了不少 Web 系统,有的至今生气蓬勃、有的早已夭折...

乌柏木149阅读 12.3k评论 10

正则表达式实例
收集在业务中经常使用的正则表达式实例,方便以后进行查找,减少工作量。常用正则表达式实例1. 校验基本日期格式 {代码...} {代码...} 2. 校验密码强度密码的强度必须是包含大小写字母和数字的组合,不能使用特殊...

寒青54阅读 7.8k评论 11

JavaScript有用的代码片段和trick
平时工作过程中可以用到的实用代码集棉。判断对象否为空 {代码...} 浮点数取整 {代码...} 注意:前三种方法只适用于32个位整数,对于负数的处理上和Math.floor是不同的。 {代码...} 生成6位数字验证码 {代码...} ...

jenemy46阅读 5.9k评论 12

从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望
总结截止到本章 “从零搭建 Node.js 企业级 Web 服务器” 主题共计 16 章内容就更新完毕了,回顾第零章曾写道:搭建一个 Node.js 企业级 Web 服务器并非难事,只是必须做好几个关键事项这几件必须做好的关键事项就...

乌柏木66阅读 6.1k评论 16

再也不学AJAX了!(二)使用AJAX ① XMLHttpRequest
「再也不学 AJAX 了」是一个以 AJAX 为主题的系列文章,希望读者通过阅读本系列文章,能够对 AJAX 技术有更加深入的认识和理解,从此能够再也不用专门学习 AJAX。本篇文章为该系列的第二篇,最近更新于 2023 年 1...

libinfs39阅读 6.3k评论 12

封面图
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
分层规范从本章起,正式进入企业级 Web 服务器核心内容。通常,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,如下图:从上至下,抽象层次逐渐加深。从下至上,业务细节逐渐清晰。视图...

乌柏木43阅读 7.3k评论 6

生吞橘子

335 声望
8 粉丝
宣传栏