Last year, I wrote about my preferred approach to event delegation. Specifically, I prefer to attach my delegated event handlers to the closest common ancestor of two or more elements. Although I don't use jQuery, the following snippet from the documentation for the on() method also applies to vanilla JS:

Attaching many delegated event handlers near the top of the document tree can degrade performance. Each time the event occurs, jQuery must compare all selectors of all attached events of that type to every element in the path from the event target up to the top of the document. For best performance, attach delegated events at a document location as close as possible to the target elements. Avoid excessive use of document or document.body for delegated events on large documents.

I normally just find the common ancestor manually, but I thought "hey, wouldn't it be cool if I could write a helper function to do this for me?"

Over the weekend, that's exactly what I did.

getCommonAncestor.js

If you just want the helper function, here it is:

/**
* Get the common ancestor of two or more elements
* {@link https://gist.github.com/kieranbarker/cd86310d0782b7c52ce90cd7f45bb3eb}
* @param {String} selector A valid CSS selector
* @returns {Element} The common ancestor
*/

function getCommonAncestor (selector) {
// Get the elements matching the selector
const elems = document.querySelectorAll(selector);

// If there are no elements, return null
if (elems.length < 1) return null;

// If there's only one element, return it
if (elems.length < 2) return elems[0];

// Otherwise, create a new Range
const range = document.createRange();

// Start at the beginning of the first element
range.setStart(elems[0], 0);

// Stop at the end of the last element
range.setEnd(
elems[elems.length - 1],
elems[elems.length - 1].childNodes.length
);

// Return the common ancestor
return range.commonAncestorContainer;
}

You can view a couple of demos on CodePen:

If you want to learn how it works, read on.

Getting the matching elements

The first thing we do is use the document.querySelectorAll() method to get a NodeList of all elements matching the CSS selector string.

If there are no matching elements, we just return null.

If there's only one matching element, then there's no point in using event delegation, so we just return it. That way, you can attach the event listener directly to the element and not bother with event delegation.

Getting the common ancestor

If there are at least two elements, then we want to get their common ancestor. To do that, we can use the Range API.

We create a Range using the document.createRange() method, then set its boundaries using the setStart() and setEnd() methods.

We want to start the range at the beginning of the first element, and stop the range at the end of the last element. The setStart() and setEnd() methods both accept two arguments:

  1. The Node to use; and
  2. An integer greater than or equal to zero representing the offset for the start/end of the Range from the start of the Node. If the Node is a Text, Comment, or CDataSection node, then it's a number of characters from the start of the Node. Otherwise, it's a number of child nodes from the start of the Node. The latter is applicable to this helper function.

For the first element, we pass the following to setStart():

  1. elems[0] — the first element
  2. 0 — the beginning of the first element

For the last element, we pass the following to setEnd():

  1. elems[elems.length - 1]the last element
  2. elems[elems.length - 1].childNodes.length — the end of the last element

Finally, now that the Range is set, we return the common ancestor using the commonAncestorContainer property.