Taming DOM Events - A better way to listen for JavaScript events

06/05/2014

How much time have you spent trying to find specific event listeners in your code? Some frameworks such as AngularJS and Ember.js have specific locations where listeners are created, usually in the framework’s controller layer. Others such as jQuery and Backbone.js let developers manage events but don’t really care where the event listeners are created. At Craftsy we used to just put event listeners in the HTML or a relevant JavaScript file already on the page. This structure - or lack thereof - led to quite a bit of wasted time; at one point I spent close to half an hour locating a specific piece of code for the “Add to Cart” button on our class pages.

1

<button id="signUpClass" class="signUp signUpPreview">Add to Cart</button>

There are a number of different ways the jQuery selector for the element could be structured. The button has an ID attribute, so we could look for $( '#signUpClass' ). There are two classes which might be used, so the jQuery selector could be '.signUp' or '.signUpPreview' or 'signUp.signUpPreview'. It’s a button, so maybe the listener is bound to 'button#signUpClass.signUp'. When you consider the button’s parent elements the combinations for its selector rapidly increase. It could also be in one of many files, perhaps the HTML code has a <script> block creating the listener, maybe it’s in a JavaScript file specific to the page, or perhaps it is contained in a global JavaScript file included on every page. Obviously some of these combinations are much better choices, but most bets are off when dealing with older code that has been through many changes.

Knowing our time was being wasted looking for our event listeners, we had to find a pattern to standardize on. Personally, I am a big fan of AngularJS - especially how it handles data and event binding. Having an existing code base sprawled across a lot of pages meant we couldn't implement a full framework like Angular, Ember, or Backbone without rewriting our site from the ground up - definitely not an option. Instead, we decided to emulate how Angular’s event binding looks: adding something to the HTML tag specifying what method should be fired when interacting with the element. After some discussion we came up with a solution that addresses the problem and provides additional benefits. First, we wanted to use HTML5 data attributes to describe what events each element is bound to. In the Add to Cart example, our HTML would be changed to

<button id="signUpClass" data-model="course" data-action="addToCart" data-courseid="500">
    Add to Cart
</button>

This approach lets us group events logically by “models”, such as “course”, “project”, or “pattern”. Actions indicate what is being done to the model, like “addToCart” for courses or “create” for patterns and projects. When the element is clicked the model & action are combined into a custom event (supported by many modern libraries like jQuery, YUI, Backbone.js, etc) which is transformed into “action-model”, e.g. “addToCart-course”. This changes our event listeners to target business-level ideas like creating a user or submitting payment instead of focusing on what element initiated the event. By using data-* attributes we make id and class selectors CSS-centric once again; our designers can modify those values however they want without worrying their changes will break functionality.

$( document.body ).on(
    
'addToCart-course',
    function( e, data, trigger ){ /* Actually add the course to cart */ }
);
 

The custom listener receives three arguments as shown above. e is the original DOM event you get in a normal listener. data is a map containing the element's data-* attributes and their values, as generated by jQuery's data() method. The third argument is the event object generated by jQuery which describes the action-model trigger.

To use these listeners, each DOM event like click must be converted into the custom events. We place a delegate event handler on document.body which listens for events from any DOM element with data-model and data-action attributes. These two attributes are combined into the custom event name and collect any additional data attributes to pass on to any bound listeners. 

// Listen for all click events on elements with data-model & data-action attributes
$( document.body )
.on(
    'click',
    '*[data-model][data-action]',
    function( e )
    {
        var self = $( this ),
        data = self.data();
        // Trigger the custom event 'action-model'
        
self.trigger( data.action + '-' + data.model, [ data, e ] );
    
}
);

Now it is immediately evident which action an element will trigger, and by convention we place the listeners in files named %model%Triggers.js (e.g. CourseTriggers.js) for easy finding. This structure has other benefits as it encourages code re-use and allows our non-JavaScript developers to easily include already-defined behavior without figuring out how code on a page is formatted. By passing in additional information via data-* attributes we avoid creating global variables like var courseIdOnThisPage = 500; which helps simplify and declutter code.

 

Bringing it all together in a demo

Comments (0)

The comments to this entry are closed.