纯JavaScript事件委托

19

什么是使用纯JavaScript进行事件委托的最佳方式(最快/适当)?

例如,如果我在jQuery中有这样的代码:

$('#main').on('click', '.focused', function(){
    settingsPanel();
});

我该如何将它翻译成原生JavaScript?也许可以使用 .addEventListener()

我能想到的方法是:

document.getElementById('main').addEventListener('click', dothis);
function dothis(){
    // now in jQuery
    $(this).children().each(function(){
         if($(this).is('.focused') settingsPanel();
    }); 
 }

但是如果#main有很多子元素,那似乎效率不高。

那么这样做正确吗?

document.getElementById('main').addEventListener('click', doThis);
function doThis(event){
    if($(event.target).is('.focused') || $(event.target).parents().is('.focused') settingsPanel();
}

4
event.target开始,判断是否匹配选择器,如果匹配,则调用函数。然后向上迭代其祖先元素,并重复相同的步骤直到到达this元素。你可以使用node.matchesSelector()来进行测试,尽管在一些浏览器中,它是通过特定于浏览器的标志实现的,因此你需要将其包装在一个函数中或将标志方法放在Element.prototype.matchesSelector上。 - cookie monster
1
建议您将其发布为答案。 - Evan Trimboli
1
@MrGuru:因为这就是事件委托的工作原理。你必须查看元素层次结构中是否有任何节点与选择器匹配,从事件发起的元素开始,并在绑定处理程序的元素处停止。 - Felix Kling
1
我猜jQuery的实现相当不错 :) - Ian Clark
显示剩余7条评论
4个回答

19

与其改变内置原型(这样会导致代码脆弱,常常会出现问题),不如检查被点击的元素是否有一个最接近的元素与你想要匹配的选择器相匹配。如果有,就调用你想要执行的函数。例如,要翻译:

$('#main').on('click', '.focused', function(){
    settingsPanel();
});

不使用jQuery,可以使用:

document.querySelector('#main').addEventListener('click', (e) => {
  if (e.target.closest('#main .focused')) {
    settingsPanel();
  }
});

除非内部选择器也可能存在为父元素(这可能相当不寻常),否则仅将内部选择器传递给.closest即可(例如,.closest('.focused'))。

使用此类模式时,为了保持紧凑,我经常在早期返回下方放置代码的主要部分,例如:

document.querySelector('#main').addEventListener('click', (e) => {
  if (!e.target.closest('.focused')) {
    return;
  }
  // code of settingsPanel here, if it isn't too long
});

演示实例:

document.querySelector('#outer').addEventListener('click', (e) => {
  if (!e.target.closest('#inner')) {
    return;
  }
  console.log('vanilla');
});

$('#outer').on('click', '#inner', () => {
  console.log('jQuery');
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div id="outer">
  <div id="inner">
    inner
    <div id="nested">
      nested
    </div>
  </div>
</div>


1
如果DOM尚未加载,document.querySelector将无法正常工作。这并不完全是委托。 - mlt
1
当然可以 - 就像 OP 的 jQuery 代码 $('#main').on('click', '.focused', function(){#main 内的点击代理到 #main 的点击监听器一样,我的代码也是这样做的。如果 #main 的子元素是动态的(比如 #main 是一个待办事项列表容器),那么这就是使用的技术。如果 #main 本身可能不存在,那么情况不同,当然需要将事件代理到外部父级。 - CertainPerformance
我的错,确实OP的情况可以工作。我应该详细说明一下,因为我正在寻找与Turbolinks兼容的解决方案。它删除了除文档和窗口之外的所有内容。最终我使用了只有完整选择器和没有在文档上使用querySelector.matches - mlt
1
第二个片段使用了一个简单的 target.matches(),它与 .closest() 不同,因为它需要事件直接在该元素上触发,而不是在其后代元素上触发。我猜这不是你想要的。 - Kaiido

15

我已经想出了一个简单的解决方案,看起来效果非常不错(尽管旧版IE不支持)。在这里,我们通过扩展EventTarget的原型来提供delegateEventListener方法,并使用以下语法:

EventTarget.delegateEventListener(string event, string toFind, function fn)

我已经创建了一个相当复杂的示例,通过代理所有绿色元素的事件来演示它的工作原理。停止事件传播仍然有效,并且可以通过this(与jQuery相同)访问应该是event.currentTarget的内容。

以下是完整的解决方案:

(function(document, EventTarget) {
  var elementProto = window.Element.prototype,
      matchesFn = elementProto.matches;

  /* Check various vendor-prefixed versions of Element.matches */
  if(!matchesFn) {
    ['webkit', 'ms', 'moz'].some(function(prefix) {
      var prefixedFn = prefix + 'MatchesSelector';
      if(elementProto.hasOwnProperty(prefixedFn)) {
        matchesFn = elementProto[prefixedFn];
        return true;
      }
    });
  }

  /* Traverse DOM from event target up to parent, searching for selector */
  function passedThrough(event, selector, stopAt) {
    var currentNode = event.target;

    while(true) {
      if(matchesFn.call(currentNode, selector)) {
        return currentNode;
      }
      else if(currentNode != stopAt && currentNode != document.body) {
        currentNode = currentNode.parentNode;
      }
      else {
        return false;
      }
    }
  }

  /* Extend the EventTarget prototype to add a delegateEventListener() event */
  EventTarget.prototype.delegateEventListener = function(eName, toFind, fn) {
    this.addEventListener(eName, function(event) {
      var found = passedThrough(event, toFind, event.currentTarget);

      if(found) {
        // Execute the callback with the context set to the found element
        // jQuery goes way further, it even has it's own event object
        fn.call(found, event);
      }
    });
  };

}(window.document, window.EventTarget || window.Element));

你应该首先检查标准的未加前缀的版本。 - gilly3
@gilly3 我应该只计算一次而不是每次都计算。我会更新的。 - Ian Clark
我建议使用 window.Node 而不是 window.Element。文档本身继承自 Node,使得在 IE 中能够正常工作(IE 没有 EventTarget)。 - Kevin Drost

1
我有一个类似的解决方案来实现事件委托。它利用了数组函数slicereversefilterforEach
  • slice将查询到的NodeList转换为数组,必须在允许反转列表之前完成。
  • reverse反转数组(使最终遍历尽可能接近事件目标)。
  • filter检查哪些元素包含event.target
  • forEach为过滤结果中的每个元素调用提供的处理程序,只要处理程序不返回false

该函数返回创建的委托函数,这使得稍后可以删除侦听器。请注意,原生的event.stopPropagation()无法停止通过validElements的遍历,因为冒泡阶段已经遍历到委托元素。

function delegateEventListener(element, eventType, childSelector, handler) {
    function delegate(event){
        var bubble;
        var validElements=[].slice.call(this.querySelectorAll(childSelector)).reverse().filter(function(matchedElement){
            return matchedElement.contains(event.target);
        });
        validElements.forEach(function(validElement){
            if(bubble===undefined||bubble!==false)bubble=handler.call(validElement,event);
        });
    }
    element.addEventListener(eventType,delegate);
    return delegate;
}

虽然不建议扩展原生的原型,但是可以将此函数添加到EventTarget的原型中(在IE中为Node)。这样做时,在函数内部用this替换element,并删除相应的参数(EventTarget.prototype.delegateEventListener = function(eventType, childSelector, handler){...})。


0

委托事件

事件委托是在需要在现有或动态元素(将来添加到DOM中)接收事件时执行函数的情况下使用的。
策略是将事件监听器分配给已知的静态父级,并遵循以下规则

  • 使用evt.target.closest(".dynamic")获取所需的动态子元素
  • 使用evt.currentTarget获取#staticParent的父级委托者
  • 使用evt.target获取确切的点击元素(警告!这也可能是一个后代元素,不一定是.dynamic

代码示例:

document.querySelector("#staticParent").addEventListener("click", (evt) => {

  const elChild = evt.target.closest(".dynamic");

  if ( !elChild ) return; // do nothing.

  console.log("Do something with elChild Element here");

});

完整的示例,包含动态元素:

// DOM utility functions:

const el = (sel, par) => (par || document).querySelector(sel);
const elNew = (tag, prop) => Object.assign(document.createElement(tag), prop);


// Delegated events
el("#staticParent").addEventListener("click", (evt) => {

  const elDelegator = evt.currentTarget;
  const elChild = evt.target.closest(".dynamicChild");
  const elTarget = evt.target;

  console.clear();
  console.log(`Clicked:
    currentTarget: ${elDelegator.tagName}
    target.closest: ${elChild?.tagName}
    target: ${elTarget.tagName}`)

  if (!elChild) return; // Do nothing.

  // Else, .dynamicChild is clicked! Do something:
  console.log("Yey! .dynamicChild is clicked!")
});

// Insert child element dynamically
setTimeout(() => {
  el("#staticParent").append(elNew("article", {
    className: "dynamicChild",
    innerHTML: `Click here!!! I'm added dynamically! <span>Some child icon</span>`
  }))
}, 1500);
#staticParent {
  border: 1px solid #aaa;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.dynamicChild {
  background: #eee;
  padding: 1rem;
}

.dynamicChild span {
  background: gold;
  padding: 0.5rem;
}
<section id="staticParent">Click here or...</section>

直接事件

或者,您可以在子元素创建时直接附加点击处理程序:

// DOM utility functions:

const el = (sel, par) => (par || document).querySelector(sel);
const elNew = (tag, prop) => Object.assign(document.createElement(tag), prop);

// Create new comment with Direct events:
const newComment = (text) => elNew("article", {
  className: "dynamicChild",
  title: "Click me!",
  textContent: text,
  onclick() {
    console.log(`Clicked: ${this.textContent}`);
  },
});

// 
el("#add").addEventListener("click", () => {
  el("#staticParent").append(newComment(Date.now()))
});
#staticParent {
  border: 1px solid #aaa;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.dynamicChild {
  background: #eee;
  padding: 0.5rem;
}
<section id="staticParent"></section>
<button type="button" id="add">Add new</button>

资源:

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接