利用原生 Javascript 实现 Delegated Event

2hu10n92hen9

想要实现类似于 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);
  }
};

原文链接

阅读 4.7k

生吞橘子

335 声望
8 粉丝
0 条评论
你知道吗?

生吞橘子

335 声望
8 粉丝
宣传栏