webdev: (Default)
[personal profile] webdev
jQuery does a great job of providing a normalized event model across all browsers. It’s my go-to library for that particular task, plus it has several other useful features. One of the great features jQuery has for its event system is namespacing. You can namespace your event types as you attach, detach, and call them. For example:
// Attach a simple event handler to the element
$(“#targetEl”).on(“click”, function(event) {
  console.log(‘A click event happened!’);
});

// Now attach a namespaced event handler to the element
$(‘#targetEl’).on(‘click.plugin’, function(event) {
  console.log(‘A click.plugin event happened!’);
});

Now, when you click on the target element, both event handlers will fire. If you manually trigger a click event you can add the namespace to it:
$(‘#targetEl’).trigger(‘click.plugin’);

This will trigger only the click.plugin event handler. This gives you an easy way of segregating your event handlers, and is particularly useful with custom events.

Event namespacing is a great feature, and I decided to think about how I might implement it from scratch, just as an exercise. The reason jQuery has namespaced events is because it has its own event handling system, so I started to think about what it would take to write a simple event manager that would provide a namespacing feature.

So here’s a first stab at a general event manager. The goal here isn’t to produce production-quality code, but rather to explore the desired features and get some code up that implements them, and if we like the direction we can later create something that’s more structured.

Let’s start with a single object that implements an Event Listener interface:
var eventManager = {
  handleEvent: function(event) {
  }
}

In my previous post I demonstrated how to use this basic event handler to handle all events on a single element. Now let’s take it a step further: let’s make it handle all events for all elements. Such an event manager would need the following features

  • a way to know what elements had event handlers registered on them,

  • a way to know what each element’s individual event handlers are, so they can be executed correctly,

  • a way to register event handlers on an element, and

  • a way to deregister event handlers on an element.


Here’s an event manager object with some stubs--we can fill in the stubs as we go:
var eventManager = {
  registry : [],
  register: function(eventType, domEl, handler, bubble) {
  },
  unregister: function(eventType, domEl) {
  },
  handleEvent: function(event) {
  }
}

Now that we can see a direction, let’s think about the three methods from a high level.

When you register an event handler on the element using the register method, it will need to do three things:

  1. Create a new object that implements the specified handler as an EventListener interface

  2. Record the fact that for that element, that particular eventType has that handler registered

  3. Actually bind the eventManager object itself to domEl as the event handler for eventType.


When eventType occurs on domEl, eventManager.handleEvent gets called. It will need to check the registry to find out how to correctly delegate the event.

The unregister method just undoes what the register event does: removes eventManager as the event listener for eventType from domEl, and then updates the registry to reflect that domEl no longer has eventType registered.

This brings us to the registry. What do we need to record in the registry?

  • We need to know that a particular DOM element has events registered on it, so it makes sense to use dom elements as a key somehow.

  • We need to know what event types are registered for a DOM element--you can have more than one.

  • We need to know what event handlers are registered for a given event type--you can have more than one.


An associative array would be perfect here, but JavaScript doesn’t have any concept of that. We can mock up a simple association, though, using two simple arrays. The first array will be an array of DOM elements, and the second array will be an array of objects containing event type/event handler information. The two arrays will be associated by indices; a DOM element at index i will have its information stored in the data array at index i. When we use this method, we can use a DOM element as a key--just go to the array of DOM elements and use the indexOf method to find out where its data is stored.

Great, but how do we store that data? Recall that we’re storing event types, and for each event type will be a set of event handlers. We can use the same association technique we used above: Two arrays, with the first one being a key on eventType specifying the index in the second array. In the second array, we have arrays of event handler objects. Let’s name these arrays:

  • eventManager.elementRegistry: the array of DOM elements.

  • eventManager.handlerRegistry: The array of objects that contain the handler information for a given DOM element.


Each entry in eventManager.handlerRegistry would have the following schema:

handlerRegistryEntry.arrTypes: The array of event types
handlerRegistryEntry.arrHandlerStacks: The array of arrays of handlers for each type.

So our registry and a lookup method would look like this in the eventManager:
var eventManager = {
  elementRegistry : [],
  handlerRegistry : [],
  getEventHandlerStackForElement: function(domEl, eventType) {
  
    // Start by checking for a registry entry for domEl.
    var handlerRegistryIndex = this.elementRegistry.indexOf(domEl),
        handlerRegistryEntry = this.handlerRegistry[handlerRegistryIndex];
  
    if (handlerRegistryEntry == null) {
      // domEl has no registry entry.  Return false.
      return false;
    }
  
    // Next look for the stack of event handlers for the provided eventType.
    var typeIndex = handlerRegistryEntry.arrTypes.indexOf(eventType),
        handlerStack = handlerRegistryEntry.arrHandlerStacks[typeIndex];
  
    if (handlerStack == null) {
      // No event handlers for eventType.  Return false.
      return false;
    } else {
      return handlerStack;
    }
  },
  register: function() {
  },
  unregister: function() {
  },
  handleEvent: function(event) {
  }
}

Now we’re getting somewhere! We’ve defined our registry schema and provided a method for retrieving data from it. We could probably genericise our functionalities better--you might think in terms of getters and setters on the registry, for example--but this should serve for our thought experiment.

Before we go any further, let’s think about namespacing. We haven’t really taken that must-have feature into account yet. I’d like to implement jQuery-esque namespacing, where you append a single namespace to an event type, e.g. click.container or mouseenter.titlepage. jQuery actually allows you to have as many namespaces as you want (e.g. click.container.topleft.titlepage) but to keep things simple for our thought experiment let’s just do one level.

Since our registry will be storing event handlers as custom objects, we can just apply the namespace as a property to that object. In our delegate, we can check the event type for a namespace, and if it’s present only call the handlers that share the namespace.

Here’s a simple convenience method for handling namespaces--it reads an event type and returns an object with two properties: the base event type and the namespace, if any. This gives is an easy to use interface to our namespaces:
var eventManager = {
  elementRegistry : [],
  handlerRegistry : [],
  getEventHandlerStackForElement: function(domEl, eventType) {
  
    // Start by checking for a registry entry for domEl.
    var handlerRegistryIndex = this.elementRegistry.indexOf(domEl),
        handlerRegistryEntry = this.handlerRegistry[handlerRegistryIndex];
  
    if (handlerRegistryEntry == null) {
      // domEl has no registry entry.  Return false.
      return false;
    }
  
    // Next look for the stack of event handlers for the provided eventType.
    var typeIndex = handlerRegistryEntry.arrTypes.indexOf(eventType),
        handlerStack = handlerRegistryEntry.arrHandlerStacks[typeIndex];
  
    if (handlerStack == null) {
      // No event handlers for eventType.  Return false.
      return false;
    } else {
      return handlerStack;
    }
  },
  getTypeAndNamespace : function(type) {
    var objReturn = {
      type: '',
      namespace: ''
    },
    arrStrings = type.split('.');
    objReturn.type = arrStrings[0];
    objReturn.namespace = arrStrings[1];
    return objReturn;
  },
  register: function() {
  },
  unregister: function() {
  },
  handleEvent: function(event) {
  }
}

Next is the register function. It will be responsible for the following:

  • Add information to the registry correctly. A single element can have multiple event types, and a single type can have multiple handlers.

  • Attach the eventManager as the event handler for the given event to the target element (if it hasn’t already done so).


Those two points encapsulate a lot of logic: If the element already has an event handler registered for that event type, then we’re appending a new handler to the handler array for that type. If the element doesn’t have that event type registered then we’re creating a new event handler array. And if the element isn’t registered in the eventHandler registry at all, we’re doing everything from scratch. And we’ll need to add actual event handlers in the case of a whole new registry entry, or a new event type for an existing element.

That’s a lot to think about, but really it’s not hard to code. Everything is there, we just need to check what’s in the registry and act appropriately. Here’s the code, with comments:
  register : function(type, domEl, evtHandler, opt_isBubble) {
    // Start by getting the index in the registry for the provided DOM 
    // element.  If one doesn't exist, add it to the registry.
    var handlerRegistryIndex = this.elementRegistry.indexOf(domEl);
    if (handlerRegistryIndex === -1) {
      handlerRegistryIndex = this.elementRegistry.push(domEl) - 1;
    }

    // Get the registry entry for the provided DOM element.
    var handlerRegistryEntry = this.handlerRegistry[handlerRegistryIndex],
        objName = this.getTypeAndNamespace(type);

    // Are we making a new registry entry, or appending to an existing one?
    if (handlerRegistryEntry == null) {
      // Making a new registry entry.
      var newHandlerObject = {},
          newEntry = {};

      // If the provided event handler isn't an object that implements the
      // EventListener interface, create one.
      if (evtHandler.handleEvent != null) {
        newHandlerObject = evtHandler;
        newHandlerObject.namespace = objName.namespace;
      } else {
        newHandlerObject.handleEvent = evtHandler;
        newHandlerObject.namespace = objName.namespace;
      }

      // Provide a default value for bubbling.
      if (opt_isBubble == null) {
        opt_isBubble = true;
      }

      // Create the new event handler entry for the element, and add it to
      // registry.
      newEntry = {
        arrTypes : [objName.type],
        arrHandlerStacks : [[newHandlerObject]],
        isBubble : opt_isBubble
      };
      this.handlerRegistry[handlerRegistryIndex] = newEntry;

      // Since this is a brand new entry in the handler, we need to actually
      // register the base event handler on the object.
      domEl.addEventListener(objName.type, this, opt_isBubble);
    } else {
      // We are appending to an existing registry entry.
      // Start by getting the stack index for the event type.
      var handlerStackIndex = handlerRegistryEntry.arrTypes.indexOf(objName.type),
          handlerStack = handlerRegistryEntry.arrHandlerStacks[handlerStackIndex],
          newHandlerObject = {};

      // If there isn't an entry for this event type, then we haven't
      // even added the event listener to the DOM element.  Do that here.
      if (handlerStackIndex === -1) {
        domEl.addEventListener(objName.type, this, opt_isBubble);
      }

      // If the stack is empty, we're making a new one from scratch.
      if (handlerStack == null) {
        handlerRegistryEntry.arrTypes.push(objName.type);
        handlerStack = [];
        handlerRegistryEntry.arrHandlerStacks.push(handlerStack);
      }

      // We are storing all handlers as objects that implement the EventListener
      // interface.
      if (evtHandler.handleEvent != null) {
        newHandlerObject = evtHandler;
        newHandlerObject.namespace = objName.namespace;
      } else {
        newHandlerObject.handleEvent = evtHandler;
        newHandlerObject.namespace = objName.namespace;
      }

      // Store the event handler object.
      handlerStack.push(newHandlerObject);
    }
  }

This register method does everything we need in a pretty straightforward way. Good enough for the thought experiment.

You would invoke the method like this:
var domEl = document.getElementById(‘targetElement’);
function myClickHandlerFunction(event) {
    console.log(‘Named functions work’);
}
var myClickHandlerObject = {
  handleEvent: function(event) {
    console.log(‘Objects that implement the Event Listener interface work.’);
  }
}
eventManager.register(‘click’, domEl, myClickHandlerFunction);
eventManager.register(‘click.object’, domEl, myClickHandlerObject);
eventManager.register(‘click.anonymous’, domEl, function(event) {
  console.log(‘Even anonymous functions work’);
});

The unregister method basically undoes the work of the register method:

  • remove the event listener from the DOM element.

  • remove the event information from the registry.


It’s actually a lot simpler in terms of logic. Here’s the code:
  unregister : function(type, domEl) {

    var handlerRegistryIndex = this.elementRegistry.indexOf(domEl);
    if (handlerRegistryIndex === -1) {
      // No event handlers registered for this element, so just return.
      return;
    }

    var handlerRegistryEntry = this.handlerRegistry[handlerRegistryIndex],
        objName = this.getTypeAndNamespace(type),
        handlerStackIndex = handlerRegistryEntry.arrTypes.indexOf(objName.type),
        handlerStack = handlerRegistryEntry.arrHandlerStacks[handlerStackIndex];
    if (objName.namespace == null) {
      // Remove all event handlers for this type.
      handlerRegistryEntry.arrHandlerStacks[handlerStackIndex] = [];
      domEl.removeEventListener(objName.type, this, handlerRegistryEntry.isBubble);
    } else {
      var i = 0,
          handlerStackLength = handlerStack.length;
      for (i = 0; i < handlerStackLength; i++) {
        if (handlerStack[i].namespace === objName.namespace) {
          handlerStack.splice(i,1);
        }
      }
      // TODO: If handlerStack is now empty, we should probably clean up
      // everything.
    }
  }

You’ll notice this method is a lot simpler. One of the things to remember about removal methods like this is they are prime places to watch out for memory leaks. I’ve added a TODO to keep that on the radar. But for now this is enough for the experiment.

Finally, we come to the EventListener interface. All it has to do is look through the registry for a given element and execute any event handlers it finds for the specified event type. Very simple!
  handleEvent: function(event) {
    var objName = this.getTypeAndNamespace(event.type),
        handlerStack = this.getEventHandlerStackForElement(event.currentTarget, objName.type),
        i = 0,
        handlerStackLength;

    if (handlerStack === false) {
      console.log('No event of type ', event.type, ' registered for DOM element', event.currentTarget);
      return;
    }

    handlerStackLength = handlerStack.length;
    for (i = 0; i < handlerStackLength; i++) {
      if (objName.nameSpace == null) {
        handlerStack[i].handleEvent.call(this, event);
      } else if (handlerStack[i].namespace === objName.namespace) {
        handlerStack[i].handleEvent.call(this, event);
      }
    }
  }

You can see we’re handling our namespaces correctly here, so that if an event has a namespace, only namespaced handlers are invoked. For non-namespaced events, all events of that type (namespaced or not) are invoked. That matches the behavior of jQuery’s event handling system.

Ok, let’s put everything together. I’ve also added in some jsDoc-style comments.
/**
 * @class eventManager
 */
var eventManager = {

  /**
   * DOM element registry; used to get the index of the associated handler
   * in handlerRegistry.
   * @type {array}
   */
  elementRegistry : [],

  /**
   * Handler registry: each element is an array of event handler objects
   * for the element registered in elementRegistry
   * @type {array}
   */
  handlerRegistry : [],

  /**
   * Register an event handler for a particular event on a given element.
   * @param {string} type The event type, possibly namespaced.
   * @param {domElement}} domEl The target DOM element.
   * @param {Object} evtHandler The event handler to be triggered when the
   * event fires. Valid functions and objects implementing EventListener are
   * permitted.
   * @param {boolean=} opt_isBubble Whether or not to execute the event handler
   * in the bubble or capture phase.
   */
  register : function(type, domEl, evtHandler, opt_isBubble) {
    // Start by getting the index in the registry for the provided DOM 
    // element.  If one doesn't exist, add it to the registry.
    var handlerRegistryIndex = this.elementRegistry.indexOf(domEl);
    if (handlerRegistryIndex === -1) {
      handlerRegistryIndex = this.elementRegistry.push(domEl) - 1;
    }

    // Get the registry entry for the provided DOM element.
    var handlerRegistryEntry = this.handlerRegistry[handlerRegistryIndex],
        objName = this.getTypeAndNamespace(type);

    // Are we making a new registry entry, or appending to an existing one?
    if (handlerRegistryEntry == null) {
      // Making a new registry entry.
      var newHandlerObject = {},
          newEntry = {};

      // If the provided event handler isn't an object that implements the
      // EventListener interface, create one.
      if (evtHandler.handleEvent != null) {
        newHandlerObject = evtHandler;
        newHandlerObject.namespace = objName.namespace;
      } else {
        newHandlerObject.handleEvent = evtHandler;
        newHandlerObject.namespace = objName.namespace;
      }

      // Provide a default value for bubbling.
      if (opt_isBubble == null) {
        opt_isBubble = true;
      }

      // Create the new event handler entry for the element, and add it to
      // registry.
      newEntry = {
        arrTypes : [objName.type],
        arrHandlerStacks : [[newHandlerObject]],
        isBubble : opt_isBubble
      };
      this.handlerRegistry[handlerRegistryIndex] = newEntry;

      // Since this is a brand new entry in the handler, we need to actually
      // register the base event handler on the object.
      domEl.addEventListener(objName.type, this, opt_isBubble);
    } else {
      // We are appending to an existing registry entry.
      // Start by getting the stack index for the event type.
      var handlerStackIndex = handlerRegistryEntry.arrTypes.indexOf(objName.type),
          handlerStack = handlerRegistryEntry.arrHandlerStacks[handlerStackIndex],
          newHandlerObject = {};

      // If there isn't an entry for this event type, then we haven't
      // even added the event listener to the DOM element.  Do that here.
      if (handlerStackIndex === -1) {
        domEl.addEventListener(objName.type, this, opt_isBubble);
      }

      // If the stack is empty, we're making a new one from scratch.
      if (handlerStack == null) {
        handlerRegistryEntry.arrTypes.push(objName.type);
        handlerStack = [];
        handlerRegistryEntry.arrHandlerStacks.push(handlerStack);
      }

      // We are storing all handlers as objects that implement the EventListener
      // interface.
      if (evtHandler.handleEvent != null) {
        newHandlerObject = evtHandler;
        newHandlerObject.namespace = objName.namespace;
      } else {
        newHandlerObject.handleEvent = evtHandler;
        newHandlerObject.namespace = objName.namespace;
      }

      // Store the event handler object.
      handlerStack.push(newHandlerObject);
    }
  },

  /**
   * Unregister an event type from domEl.
   * @param {string} type The event type to unregister, with optional namespace.
   * @param {domElement} domEl The target DOM element.
   */
  unregister : function(type, domEl) {

    var handlerRegistryIndex = this.elementRegistry.indexOf(domEl);
    if (handlerRegistryIndex === -1) {
      // No event handlers registered for this element, so just return.
      return;
    }

    var handlerRegistryEntry = this.handlerRegistry[handlerRegistryIndex],
        objName = this.getTypeAndNamespace(type),
        handlerStackIndex = handlerRegistryEntry.arrTypes.indexOf(objName.type),
        handlerStack = handlerRegistryEntry.arrHandlerStacks[handlerStackIndex];
    if (objName.namespace == null) {
      // Remove all event handlers for this type.
      handlerRegistryEntry.arrHandlerStacks[handlerStackIndex] = [];
      domEl.removeEventListener(objName.type, this, handlerRegistryEntry.isBubble);
    } else {
      var i = 0,
          handlerStackLength = handlerStack.length;
      for (i = 0; i < handlerStackLength; i++) {
        if (handlerStack[i].namespace === objName.namespace) {
          handlerStack.splice(i,1);
        }
      }
      // TODO: If handlerStack is now empty, we should probably clean up
      // everything.
    }
  },

  /**
   * Convenience method to extract a namespace from an event type and return
   * the results in an object.
   * @param {string} type The event type, may include optional namespace.
   * @return {Object} objReturn An object with both the type and namespace
   * as properties.
   */
  getTypeAndNamespace : function(type) {
    var objReturn = {
      type: '',
      namespace: ''
    },
    arrStrings = type.split('.');
    objReturn.type = arrStrings[0];
    objReturn.namespace = arrStrings[1];
    return objReturn;
  },

  /**
   * Get a stack of event handlers registered for a given event type on a
   * DOM element.
   * @param {domElement} domEl The DOM element.
   * @param {string} eventType The event type.
   * @return {boolean|array} Either the stack of array handlers or false if 
   * no handlers were registered.
   */
  getEventHandlerStackForElement: function(domEl, eventType) {
  
    // Start by checking for a registry entry for domEl.
    var handlerRegistryIndex = this.elementRegistry.indexOf(domEl),
        handlerRegistryEntry = this.handlerRegistry[handlerRegistryIndex];
  
    if (handlerRegistryEntry == null) {
      // domEl has no registry entry.  Return false.
      return false;
    }
  
    // Next look for the stack of event handlers for the provided eventType.
    var typeIndex = handlerRegistryEntry.arrTypes.indexOf(eventType),
        handlerStack = handlerRegistryEntry.arrHandlerStacks[typeIndex];
  
    if (handlerStack == null) {
      // No event handlers for eventType.  Return false.
      return false;
    } else {
      return handlerStack;
    }
  },

  /**
   * The main EventListener interface for this object. This method is
   * responsible for executing the correct event handler based on what
   * has been registered.
   * @param {DOMEvent} event A standard DOM event object.
   */
  handleEvent: function(event) {
    var objName = this.getTypeAndNamespace(event.type),
        handlerStack = this.getEventHandlerStackForElement(event.currentTarget, objName.type),
        i = 0,
        handlerStackLength;

    if (handlerStack === false) {
      console.log('No event of type ', event.type, ' registered for DOM element', event.currentTarget);
      return;
    }

    handlerStackLength = handlerStack.length;
    for (i = 0; i < handlerStackLength; i++) {
      if (objName.nameSpace == null) {
        handlerStack[i].handleEvent.call(this, event);
      } else if (handlerStack[i].namespace === objName.namespace) {
        handlerStack[i].handleEvent.call(this, event);
      }
    }
  }
};

And here’s how you would use it:
var testEl = document.getElementById("test");
function handleClick(event) {
  console.log('handleClick, ', this, event);
}
function nsHandleClick(event) {
  console.log('nsHandleClick, ', this, event);
}
function handleMouseup(event) {
  console.log('handleMouseup, ', this, event);
}
function nsHandleMouseup(event) {
  console.log('nsHandleMouseup, ', this, event);
}
function handleMousedown(event) {
  console.log('handleMousedown, ', this, event);
}
function nsHandleMousedown(event) {
  console.log('nsHandleMousedown', this, event);
}
eventManager.register("click", testEl, handleClick);
eventManager.register("click.ns", testEl, nsHandleClick);
eventManager.register("mousedown", testEl, handleMousedown);
eventManager.register('mousedown.ns', testEl, nsHandleMousedown);
eventManager.register("mouseup", testEl, handleMouseup);
eventManager.register('mouseup.ns', testEl, nsHandleMouseup);

If you click on the target element, you’ll see all the event handlers fire. You can remove them one by one, testing namespacing, and it seems to work.

So what have we learned?

  • A basic event manager isn’t all that hard to build.

  • The Event Listener interface is super-awesome.


What would it take to bring this to the next level? It’s not production code, certainly. This was an experiment. But the foundation is sound and it could be moved forward:

  • More abstraction. There’s some repeated patterns in there, particularly around getting and setting information in the registry. We could easily abstract that, making it easier to change the underlying registry implementation should we so desire.

  • Efficiency. We need to look at the unregister method in particular for memory leaks. Using DOM elements as we are, we can end up with references to detached DOM elements hanging out in our registry, resulting in memory leaks.

  • Scalability. We pretty much didn’t discuss whether or not this method would scale well for large applications. Imagine your most complex JS application. Think about all of the events it had. Now imagine all of them going through this one manager. Hmmm.


But that, as the man says, is a whole other show.

Profile

webdev: (Default)
Jon Reid

October 2013

S M T W T F S
  12 345
6789101112
13141516171819
20212223242526
272829 3031  

Links