By Michael Woloszynowicz

By Michael Woloszynowicz

Friday, July 22, 2011

The Joy of Event-Centric JavaScript

While you've no doubt used JavaScript to connect to events fired by DOM elements, I've found the idea of extending the events based model to my own objects and methods extremely useful. The concept is heavily used throughout the Dojo framework, as each Dojo widget throws its own set of custom events that other objects can connect to. I largely ignored this idea for some time, and continued to toil away by maintaining pointers between different classes and calling their respective methods when I needed them to do something. The problem with this approach is that it tightly couples the classes and makes them less re-usable. The objects must know a good amount about each other and expect to be used in certain contexts, with little opportunity for re-use. Once I began writing large, long-lived JavaScript interfaces this approach became unsustainable, thus forcing me into the event-centric model I will describe.While my examples are Dojo based, you could apply the same ideas to jQuery or even bare JavaScript by writing some non-trivial helper code (anyone know of any existing options please provide them in the comments). That being said Dojo really does an excellent job at this so you may want to consider using Dojo core, if for this reason alone (although there are many great things about Dojo).

Custom Event Basics
For starters let's look at a typical example of where custom made events are very useful. The most common case is where we have a list of items in one class, with each item in the list represented by another class.

//A simple item in a list. Extends a custom class of ours
//called EventManager which we'll talk about later
dojo.provide("my.example.Item");
dojo.declare("my.example.Item", [my.base.EventManager], { 

  //The constructor
  constructor: function(args) { 
    this.name = args.name;
    this.id = args.id;
  },

  //Event fired when the item is deleted
  onDelete: function(item) {
  }, 

  //Fired when a server request fails, let another class handle displaying errors 
  onRequestFailed: function(item, operation, error) { 
   //The list class can either connect to the onRequestFailed method, or to this pubSub
    dojo.publish("my/example/item/request/failed", [item, operation, error]);
  }, 

  //Make a call to the server to delete the item 
  deleteFromServer: function(args) {
    //Make your AJAX request
    if (success)
      args.onComplete.call(args.scope);
    else
      this.onRequestFailed(this, 'delete', response.error);
  },

  //Build the UI for our class
  buildNode: function() {
    //Naturally your UI would be more complex than this
    var domNode = dojo.create('div', {className: 'myItem'});
    dojo.create('span', { innerHTML: this.name }, domNode); //Add the label
    var delA = dojo.create('a', { innerHTML: 'delete', className: 'deleter' }, domNode);
    //Connect an on click event of our delete anchor
    var handle = dojo.connect(delA, 'onclick', this, function(evt) { 
      //Do some fancy stuff like adding a loading icon or message
      this.deleteFromServer({
        scope: this,
        onComplete: function() {
          dojo.destroy(domNode); //Remove the UI piece
          dojo.disconnect(handle); //Cleanup our on click handle
          this.onDelete(this);
        } //end onComplete
      });
    });
    //Listen if someone calls the destroy function, in this case we remove the DOM node
    var destHandle = dojo.connect(this, 'destroy', this, function() {
      dojo.destroy(domNode);
      dojo.disconnect(destHandle);
    });
  }

  //destroy: function() {} <= defined in EventManager base class
});

//---------------------------------------------------------------------------------------------------
//A class representing our list of items
dojo.provide("my.example.ItemsList");
dojo.declare("my.example.ItemsList", [my.base.EventManager], { 

  //The constructor for our list editor
  //To create it you'll create a new instance of my.example.ItemsList
  //Then call .startup();
  constructor: function(args) {      
    //In this case we'll use pubSub to demonstrate it
    this.subscribeTo("my/example/item/request/failed", this, function(item, op, error) {
      //Alert the user that a server update failed on an item
    }); 
  }, 

  //Fire up our list editor 
  startup: function(items) {
    this.buildUI();
    items?this.setItems(items):this.fetchItemsFromServer();
  }, 

  //Add a single item to our internal storage
  _addItem: function(rawItem, insertInto) {
    var item = new my.example.Item(rawItem); //Create our Item object
    this._itemsById[rawItem.id] =  item;//Store the item in the hash by its id
    //When it's deleted we remove it from our store
    item.connectTo('onDelete', this, function(deleteItem) {
      delete this._itemsById[deleteItem.id];
      deleteItem.destroy();//Call to EventManager, cleanup all event handles
    });

    //Could have also connected to onRequestFailed here if we didn't use
    //the pubSub that you see in the constructor
    insertInto.appendChild(item.buildNode());
  }, 

  //Add the items to our object regardless of the source
  setItems: function(items) {
    this._itemsById = {}; //New hash for our items
    var that = this; 
    var frag = document.createDocumentFragment();
    dojo.forEach(items, function(item) {
      that._addItem(item, frag);
    });
    this.listNode.appendChild(frag);
  }, 

  //Grab the entries from the server
  fetchItemsFromServer: function() {
    //Use whatever method you like to fetch data from the server via an AJAX call
    this.setItems(response.items);
  }, 

  //Build the necessary UI components along with a node to hold our list
  buildUI: function() {
    //Build a bunch of nodes here
    this.listNode = dojo.create('div', {}, somethingToAppendTo);
  },

  //Overwrite the destroy method in EventManager, cleanup
  destroy: function() {
    for (i in this._itemsById) {
      this._itemsById[i].destroy();
    } //end for
    delete this._itemsById;
    dojo.destroy(this.listNode);
    //Call to super method in EventManager, cleanup up this classes handles
    this.inherited(arguments);
  }
});

Although the above example is relatively basic, and your case is sure to be more complex, it nicely demonstrates how to use custom events as well as pub/subs in an event-centric context. The great thing about this approach is that the Item class doesn't have to know anything about the class that uses it and so it can be re-used in many different contexts. I've done this in many cases, most commonly with an object and its corresponding UI being embedded in different containers such as a modal dialog versus a node on a page. The power of this approach grows exponentially with the size of your application, and the complexity of your page, so while it may not seem terribly helpful in the above example, I promise you it's a lifesaver.

The additional benefit is that it makes reasoning about problems and logic much easier. You no longer have to keep in mind multiple classes or methods when responding to a method call or state change. You merely respond to that stage change in each of the locations that are affected by it, each piece handling its own logic and nothing more. In the example below we can see how useful this is when we have a typical property setter method.

//A simple item in a list. Extends a custom class of ours called 
//EventManager which we'll talk about later
dojo.provide("my.example.SampleItem");
dojo.declare("my.example.SampleItem", [my.base.EventManager], { 

  //The constructor
  constructor: function(args) { 
    dojo.mixin(this, args);//Copy the arguments into our object
  },

  //Event fired when the item is deleted
  setName: function(name) {
    this.name = name; 
    //Notice set name doesn't care about anything other than setting the property
  },  

  //Build the UI for our class
  buildNode: function() {
    //Naturally your UI would be more complex than this
    var domNode = dojo.create('div', {className: 'myItem'});
    var label = dojo.create('span', { innerHTML: this.name }, domNode); //Add the label
    this.connectTo(this, 'setName', this, function(name) {
      dojo.attr(label, 'innerHTML', name); //Update the UI thanks to the label closure
    });
    //When the classes destroy method is called, delete the UI components
    this.connectTo(this, 'destroy', this, function() {
      dojo.destroy(domNode);
    });
  }

  //Inherited destroy method in our EventManager does the cleanup
});

Once again, the UI related changes are dealt with by the method that created the UI and the node pointers are reference by the closure so we don't have to store any object level node pointers that are used throughout various methods. Our code remains nice and clean, and highly readable.


Pub/Sub vs. Events
As you can see in the first example, we used a mixture of regular event connections and Pub/Subs, so the question is which one do you use and when? What I've found works best for me is to always start with a standard event connection and then consider the following:
  • Is it too onerous to maintain these event connections or do I have to setup too many of them?
  • Do I or will I have a one-to-many, or many-to-many relationship between my components?
  • Do I need to propagate an event far down a chain of connected classes where a low level class may not know that the firing object exists, or know anything about it for that matter?
  • Will multiple object types be firing this type of event, and if so, do I want my handler to know about all of them?
  • Do I want the loosest coupling possible?
The above are all good reasons for choosing pub/sub communication over maintaining event connections, so always evaluate what the easiest and most pragmatic choice is for your situation. With pub/sub communication, it is especially important to disconnect handlers as they may fire at strange times and lead to unexpected errors. For example let's say you have a modal dialog listening to various published events and the user then closes the dialog. Now if you fail to disconnect the subscribe handles and some other elements on your page begin publishing the event, the dialog object may still exist in memory (even if the dialog DOM node's are destroyed) and may try to respond to the event. In this case the DOM nodes that need to be updated no longer exist, thus leading to some unexpected errors. For this we utilize the below helper class and its subscribeTo method.

A simple event management base class
In the examples above, I've used the event manager base class to help with managing some of the event handles that result from making Pub/Sub or standard event connections. Although Dojo cleans up all handles when the page is reloaded, long running pages require manual event cleanup to help with garbage collection, and this is where our event manager comes into play. Each time we call connectTo or subscribeTo on an object that extends EventManager, the manager stores the resulting handle in an array so that it can clean them up when the item is destroyed.
dojo.provide("my.base.EventManager");
dojo.declare("my.base.EventManager", null, { 

  "-chains-": { //chain the destroy method to ensure it's called by sub classes
    destroy: "before"
  },


  //The constructor
  constructor: function() { 
    this._em_events = [];
    this._em_subscriptions = []; 
    //You can use a map here as well (see disconnectEvent) 
    //this._em_events = {};
  },

  //Add an event or subscription to our list of events being tracked
  addEvent: function(/*Event or Subscription handle*/ eventHandle) {
    //Quick check for handle type, event handles have a length of 4, subscribes are 2 
    var ref = eventHandle.length == 4?this._em_events:this._em_subscriptions;
    ref.push(eventHandle);
    return eventHandle;
  },

  //Disconnect a specific event or subscription
  disconnectEvent: function(/*Event or Subscription handle*/ eventHandle) {
    //Quick check for handle type, event handles have a length of 4, subscribes are 2
    var ref = eventHandle.length == 4?this._em_events:this._em_subscriptions;
    //If you make frequent accesses to this method use a map instead of an array
    //for storing handles, this would obviously become a "for x in ref" instead
    for (var i = 0; i < ref.length; i++) {
      if (ref[i]=== eventHandle) {
        dojo.disconnect(eventHandle);
        ref[i] = null;
        break;
      }
    };
  },

  //Connect to an event that the extending object throws
  connectTo: function(/*String*/event, context, /*Callback Function*/func) {
    return this.addEvent(dojo.connect(this, event, context, func));
  },

  //Connect to a published event and keep track of it
  subscribeTo: function(/*String*/eventName, context, /*Callback Function*/func) {
    return this.addEvent(dojo.subscribe(eventName, dojo.hitch(context, func)));
  },

  //The important function, do the cleanup when the object is destroyed
  destroy: function() {
    //Automatically gets propagated to parents since we added it to the chain
    dojo.forEach(this._em_events, dojo.disconnect);
    dojo.forEach(this._em_subscriptions , dojo.unsubscribe);
    delete this._em_events;
    delete this._em_subscriptions;
  }
});

Some closing thoughts
While your able to get away without an event-centric approach in many cases, as the scope and size of your application and components increase, you'll find it more and more difficult to keep your code organized and re-usable. While this approach is incredibly powerful, it's also important not to overuse it and begin creating connections at every turn, as debugging will start to become more difficult beyond a certain point. If you choose to adopt this approach, be sure that it's something that utilized by your whole team as it requires a very different approach to writing and debugging the code. Your team has to stop thinking in a linear fashion and understand that there are several points that can be affected by a method call, not just what's in the method itself. As a best practice, try to constrain your event connection to setter methods and any method that begins with "on" so that it becomes easier to infer which methods may have connections to them. If you're anything like me, once you've made the switch to event-centric JavaScript, you'll wonder how you ever did without it.

If you liked this post please Tweet it, or follow me on Twitter for more.

No comments:

Post a Comment