Simulting jQuery's on() method

Simulting jQuery's on() method

8 Nov 2020

Here's a working demo of this tutorial.

I recently posted an extensive guide to JavaScript event delegation. As it happens, many developers who learned JS via jQuery are familiar with event delegation without even realising it.

This is because jQuery makes event delegation a breeze, by hiding it away as an implementation detail, behind the super-useful on() method.

$('#foo').on('click', function() { }); //direct event $('#foo').on('click', '.bar', function() { }); //delegated event

In the second example, we're doing event delegation just like we did above - listening in on elements with the class "bar" who live somewhere in the #foo container.

Wouldn't it be cool if we could mock up that function for our own use, without having to include all of jQuery? After all, native JavaScript doesn't give us event delegation - we have to do it ourselves, as we saw earlier. Let's have a stab at it.

Things get a little complex here, which just underlines how useful and powerful jQuery can be (and was, in its heyday).

Firstly let's set up a global container to store our events data. This will be a map with event types (e.g. "click") as keys.

window._events = null; HTMLElement.prototype.on = function(type, selector_or_callback, callback) { _events[type] = _events[type] || []; _events[type].push({ el: this, cb: callback || selector_or_callback, delSel: !callback ? null : selector_or_callback }); };

So what have we got so far? Firstly we define a method, on(), on the HTMLElement object's propertype (from which all elements inherit). When called, we store the event definition in our global window._events container, namely the element we're binding to, the callback and, if any, the delegation selector.

Like jQuery's on(), the arguments are fluid; the first is always the event type, but arguments two and three depend on whether we're delegating or not. If not, argument two is the callback; if yes, argument two is the selector and three the callback. We cater for both scenarios.

Now to listen for the events. We need to listen at the top level, i.e. on the body, and for all event types. Then, we'll look in our events container to see if anything should fire.

(Note that it's not possible in native JS to bind a single listener to multiple types of event, so we'll need to do this in a loop.)

const evtTypes = ['click', 'input', 'keydown'/* ... */]; //<-- add more types evtTypes.forEach(type => document.body.addEventListener(type, evt => { if (!_events[type]) return; _events[type] && _events[type][i].forEach(def => { if ( (evt.target == def.el || [...def.el.querySelectorAll('*')].includes(evt.target)) && (!def.delSel || evt.target.matches(def.delSel)) ) def.cb.call(evt.target, evt); }); }));

The scary-looking logic is basically to decide whether the callback should run or not. It compares the element we registered the event on with the actual event target, and also takes into account any delegation selector that was specified.

So there you go. It isn't perfect; for one thing, it doesn't allow for event namespacing like jQuery does (we'd need to use the Custom Events API for that) and we currently have no way to unbind events. But you get the idea. Here's a demo of it working.