Skip to main content Accessibility Feedback

How to build an event delegation helper library

Yesterday, I shared my new vanilla JS event delegation library. Today, I wanted to walk you through how it works under-the-hood.

Setup

Events is built on a revealing module pattern wrapped in a UMD module.

(function (root, factory) {
	if ( typeof define === 'function' && define.amd ) {
		define([], function () {
			return factory(root);
		});
	} else if ( typeof exports === 'object' ) {
		module.exports = factory(root);
	} else {
		root.events = factory(root);
	}
})(typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this, function (window) {

	'use strict';

	//
	// Variables
	//

	var publicAPIs = {};


	//
	// Return public APIs
	//

	return publicAPIs;

});

We’ll also setup an activeEvents object to hold any event listeners that get registered.

//
// Variables
//

var publicAPIs = {};
var activeEvents = {};

Adding events

First, let’s setup an on() method to add events.

We’ll pass in the event type(s), a selector to apply the events to, and the callback to run for the event as arguments. We’ll also make sure a selector and callback were provided before continuing.

/**
 * Add an event
 * @param  {String}   types    The event type or types (space separated)
 * @param  {String}   selector The selector to run the event on
 * @param  {Function} callback The function to run when the event fires
 */
publicAPIs.on = function (types, selector, callback) {

	// Make sure there's a selector and callback
	if (!selector || !callback) return;

};

Events allows you to pass in multiple event types as a comma-separate list, like this: scroll, click.

We’ll use the split() method to convert that string into an array, and loop through each event type with the forEach() method.

/**
 * Add an event
 * @param  {String}   types    The event type or types (space separated)
 * @param  {String}   selector The selector to run the event on
 * @param  {Function} callback The function to run when the event fires
 */
publicAPIs.on = function (types, selector, callback) {

	// Make sure there's a selector and callback
	if (!selector || !callback) return;

	// Loop through each event type
	types.split(',').forEach(function (type) {
		// Our code will go here...
	});

};

We’ll use the trim() method to remove any leading or trailing whitespace from the event type.

If there’s no event of this type in the activeEvents object yet, we’ll create one and assign an empty array. We’ll also set an event listener on the window, passing in the type.

We’ll use an internal eventHandler method (which we’ll look at soon) as the callback, and set useCapture to true to make sure we can use event delegation with events that don’t naturally bubble.

/**
 * Add an event
 * @param  {String}   types    The event type or types (space separated)
 * @param  {String}   selector The selector to run the event on
 * @param  {Function} callback The function to run when the event fires
 */
publicAPIs.on = function (types, selector, callback) {

	// Make sure there's a selector and callback
	if (!selector || !callback) return;

	// Loop through each event type
	types.split(',').forEach(function (type) {

		// Remove whitespace
		type = type.trim();

		// If no event of this type yet, setup
		if (!activeEvents[type]) {
			activeEvents[type] = [];
			window.addEventListener(type, eventHandler, true);
		}

	});

};

Finally, we’ll push a new object to the event type array in activeEvents, with the selector and callback as keys.

Now we have a single event listener running for that event type, and a key in activeEvents with all of the listeners/callbacks that should run with it.

/**
 * Add an event
 * @param  {String}   types    The event type or types (space separated)
 * @param  {String}   selector The selector to run the event on
 * @param  {Function} callback The function to run when the event fires
 */
publicAPIs.on = function (types, selector, callback) {

	// Make sure there's a selector and callback
	if (!selector || !callback) return;

	// Loop through each event type
	types.split(',').forEach(function (type) {

		// Remove whitespace
		type = type.trim();

		// If no event of this type yet, setup
		if (!activeEvents[type]) {
			activeEvents[type] = [];
			window.addEventListener(type, eventHandler, true);
		}

		// Push to active events
		activeEvents[type].push({
			selector: selector,
			callback: callback
		});

	});

};

Running our callbacks

We passed an eventHandler method in to the event listener. Let’s create that method and use it to run all of our callbacks for that event.

/**
 * Handle listeners after event fires
 * @param {Event} event The event
 */
var eventHandler = function (event) {
	// Code will go here...
};

Before doing anything else, let’s check if the event.type exists in the activeEvents object, and bail if it doesn’t.

/**
 * Handle listeners after event fires
 * @param {Event} event The event
 */
var eventHandler = function (event) {
	if (!activeEvents[event.type]) return;
};

Next, we’ll use the forEach() method to loop through each listener in activeEvents for the event.type.

/**
 * Handle listeners after event fires
 * @param {Event} event The event
 */
var eventHandler = function (event) {
	if (!activeEvents[event.type]) return;
	activeEvents[event.type].forEach(function (listener) {
		// Run the callback
	});
};

We need to check if the callback method should run, and, if it should, run it.

To help, let’s create a doRun() method to handle that for us. If it passes, we’ll run the callback.

/**
 * Handle listeners after event fires
 * @param {Event} event The event
 */
var eventHandler = function (event) {
	if (!activeEvents[event.type]) return;
	activeEvents[event.type].forEach(function (listener) {
		if (!doRun(event.target, listener.selector)) return;
		listener.callback(event);
	});
};

Checking if the event should run

In the doRun() method, we’ll use the closest() method to check if the clicked element matches the selector for the callback, or is inside an element that does.

/**
 * Check if the listener callback should run or not
 * @param  {Node}         target   The event.target
 * @param  {String|Node}  selector The selector to check the target against
 * @return {Boolean}               If true, run listener
 */
var doRun = function (target, selector) {
	return target.closest(selector);
};

This works great in most cases, but will fail if run on the window, document, or document.documentElement. Those elements don’t have a closest() method on them.

We’ll first check to see if the selector is one of those, and if so, always return true.

/**
 * Check if the listener callback should run or not
 * @param  {Node}         target   The event.target
 * @param  {String|Node}  selector The selector to check the target against
 * @return {Boolean}               If true, run listener
 */
var doRun = function (target, selector) {
	if ([
		'*',
		'window',
		'document',
		'document.documentElement',
		window,
		document,
		document.documentElement
	].indexOf(selector) > -1) return true;
	return target.closest(selector);
};

Finally, to add a bit more flexibility to our library, we’ll also let users pass in an element instead of a selector.

We’ll check if the typeof for the selector is a string. If not, we’ll check to see if the element is the target, or contains the target inside it using the contains() method.

/**
 * Check if the listener callback should run or not
 * @param  {Node}         target   The event.target
 * @param  {String|Node}  selector The selector to check the target against
 * @return {Boolean}               If true, run listener
 */
var doRun = function (target, selector) {
	if ([
		'*',
		'window',
		'document',
		'document.documentElement',
		window,
		document,
		document.documentElement
	].indexOf(selector) > -1) return true;
	if (typeof selector !== 'string' && selector.contains) {
		return selector === target || selector.contains(target);
	}
	return target.closest(selector);
};

We can now add various events in various components and attach them all to a single event listener.

Removing an event listener

One last thing to do is let users remove event listeners later.

Let’s create an off() method. It will accept the same arguments as on().

/**
 * Remove an event
 * @param  {String}   types    The event type or types (space separated)
 * @param  {String}   selector The selector to remove the event from
 * @param  {Function} callback The function to remove
 */
publicAPIs.off = function (types, selector, callback) {
	// Code goes here...
};

We’ll once again use split() to get an array of types, and use forEach() to loop through them. Then we’ll trim() the whitespace off of the type.

/**
 * Remove an event
 * @param  {String}   types    The event type or types (space separated)
 * @param  {String}   selector The selector to remove the event from
 * @param  {Function} callback The function to remove
 */
publicAPIs.off = function (types, selector, callback) {

	// Loop through each event type
	types.split(',').forEach(function (type) {

		// Remove whitespace
		type = type.trim();

	});

};

If the type isn’t in the activeEvents object, we’ll bail.

Otherwise, we’ll check the length of the type array in the activeEvents object. If there’s only one event left, we’ll use the delete operator to remove the key entirely from the object. We’ll also remove the event listener.

If no selector is provided, we’ll do the same thing.

/**
 * Remove an event
 * @param  {String}   types    The event type or types (space separated)
 * @param  {String}   selector The selector to remove the event from
 * @param  {Function} callback The function to remove
 */
publicAPIs.off = function (types, selector, callback) {

	// Loop through each event type
	types.split(',').forEach(function (type) {

		// Remove whitespace
		type = type.trim();

		// if event type doesn't exist, bail
		if (!activeEvents[type]) return;

		// If it's the last event of it's type, remove entirely
		if (activeEvents[type].length < 2 || !selector) {
			delete activeEvents[type];
			window.removeEventListener(type, eventHandler, true);
			return;
		}

	});

};

Otherwise, we want to get the index of that listener in the type array in the activeEvents object. We’ll use splice() to remove the listener from the array.

To get the index, we’ll create a helper method, getIndex(), and pass in the array, selector, and callback.

/**
 * Remove an event
 * @param  {String}   types    The event type or types (space separated)
 * @param  {String}   selector The selector to remove the event from
 * @param  {Function} callback The function to remove
 */
publicAPIs.off = function (types, selector, callback) {

	// Loop through each event type
	types.split(',').forEach(function (type) {

		// Remove whitespace
		type = type.trim();

		// if event type doesn't exist, bail
		if (!activeEvents[type]) return;

		// If it's the last event of it's type, remove entirely
		if (activeEvents[type].length < 2 || !selector) {
			delete activeEvents[type];
			window.removeEventListener(type, eventHandler, true);
			return;
		}

		// Otherwise, remove event
		var index = getIndex(activeEvents[type], selector, callback);
		if (index < 0) return;
		activeEvents[type].splice(index, 1);

	});

};

Getting the index

In the getIndex() method, we’ll loop through each item in the array. If the array’s selector and callback match those from the event listener we’re trying to remove, we’ll return that items index.

We use the toString() method to get a string representation of the function that we can compare.

If there’s no match, we’ll return -1.

/**
 * Get the index for the listener
 * @param  {Array}   arr      The listeners for an event
 * @param  {Array}   listener The listener details
 * @return {Integer}          The index of the listener
 */
var getIndex = function (arr, selector, callback) {
	for (var i = 0; i < arr.length; i++) {
		if (
			arr[i].selector === selector &&
			arr[i].callback.toString() === callback.toString()
		) return i;
	}
	return -1;
};

And with that, we’ve got a complete library. You can find Events on GitHub.