avatar

Michaël Duerinckx

 

Event-driven JavaScript: a simple event dispatcher

One of the more valuable lessons that I’ve learned in the years I’ve been programming is that loose coupling will make your life better. A very good way to achieve loose coupling in your projects is by dividing functionality in self-sufficient modules that communicate merely by subscribing to and triggering events. To work with these events, you need one component that is relatively tightly coupled with all your modules. This little bit of coupling is definitely worth it though.

Before diving into specifics, let’s talk about just what I mean.

Loose coupling

When the code base for a project gets sizeable, one thing that can make it a nightmare to work with is tight coupling. When modules are tightly coupled in a project, it means that changing one method in module A may require you to change the way certain things are implemented in module D, F and G. In order to make any modifications, you need to be well aware of how other modules rely on the module you wish to modify.

Since having a single module in your head can be taxing enough for the mind, considering basically the whole project when making a local change is awful; oversights are bound to occur.

To ensure you do not need to wrap your head around the entire code base at all time, you’ll want to make sure your modules are loosely coupled. You can safely change the modules’ internals without having to worry about how other modules have to interact with this module. You need to consciously engineer your modules to be entirely agnostic of the inner workings of others.

You’ll find that employing this method makes it much less of a daunting task to change some functionality around.

Bring on event-driven programming

Event-driven programming is a fairly simple pattern where component subscribe to events and trigger them. A variant of the pattern is the observer pattern; the difference however lies in where the event dispatching happens.

In an observer pattern, you can register observers (subscribers) to event a certain module emits. This way you can subscribe to, for example, an after-save event, which is emitted every time an object’s data is saved. The observer subscribing to this event will be triggered every time that happens, and a function can then be run.

In this case, we move the subscribing facilities away from the modules to a dedicated event dispatcher module. This provides one central location where modules sign up to be notified when a certain event occurs, as well as where modules go to broadcast an event they want to make the application aware of.

Doing this, all each module is concerned with if it comes to communicating with the rest of the application is 2 uniform things: Emitting events and responding to events emitted elsewhere. The modules do their thing, throw out some information whenever there is something that may concern the rest of the application, and they remain alert for external events pertaining to the module in question.

Responsibilities of the event dispatcher

The event dispatcher module that drives this whole pattern has only few responsibilities:

  • Keeping track of event subscriptions
  • Dispatching incoming events to all the subscribers of that event


Implementing an event dispatcher in JavaScript

A really useful feature of JavaScript is that functions are objects, which can be passed around like any other object can. You can therefore pass functions as parameters to other functions, often referred to as callback functions.

Callback functions are functions that will be called after certain execution is finished. In this case, we will be calling back functions when certain events occur.

Our event dispatcher object will need at least two publicly accessible functions: subscribe(eventName, callback) and trigger(eventName, data).

These subscribe and trigger functions will work with a shared subscribers object, which will be a hash of event names and arrays with callback functions. A subscription list could look like this:

{
	"PostSaved": [
		callbackFunction,
		callbackFunction,
		callbackFunction
	],

	"PostDeleted": [
		callbackFunction
	]
}

The subscribe function takes an event name and a callback function. It will have to first find out whether or not there are any existing subscribers for this event, or if it needs to start a new list to add to. Then the callback function gets added to the array associated with the event name.

eventSubscriptions is an object like the example above, declared outside the subscribe function.

function subscribe(eventName, callback) {
  // Retrieve a list of current subscribers for eventName (if any)
  var subscribers = eventSubscriptions[eventName];

  if (typeof subscribers === 'undefined') {
    // If no subscribers for this event were found,
    // initialize a new empty array
    subscribers = eventSubscriptions[eventName] = [];
  }

  // Add the given callback function to the end of the array with
  // eventSubscriptions for this event.
  subscribers.push(callback);

}

Comparing the type of eventSubscribtions[eventName] (subscribers) with undefined lets us find out whether this event was subscribed to before. If that isn’t the case, a new array is created at this location, with the [] syntax.

Then we simply push the function callback onto the end of this array.

The trigger function will iterate over this array of callbacks, and call each function with passed data as arguments:

function trigger(eventName, data, context) {
  var
    // Retrieve a list of subscribers for the event being triggered
    subscribers = eventSubscriptions[eventName],
    i, iMax;

  if (typeof subscribers === 'undefined') {
    // No list found for this event, return early to abort execution
    return;
  }

  // Ensure data is an array or is wrapped in an array,
  // for Function.prototype.apply use
  data = (data instanceof Array) ? data : [data];

  // Set a default value for `this` in the callback
  context = context || App;

  for (i = 0; iMax = subscribers.length; i < iMax; i += 1) {
    subscribers[i].apply(context, data);
  }
}

Once again we check whether there are any subscribers for this particular event. If there are none, execution of trigger is halted through an early return.

For the Function.prototype.apply function, we need our data to be an array. The value is either wrapped in an array, or left as is, if it already was an array.

Function.prototype.apply allows passing an object to be bound to this inside the callback function. This is referred to as context in the parameters of trigger. The parameter is made optional by defaulting to App, which is an object declared outside this function's scope. I use App as the general namespace for my application, which will be clearer in the full example.

After that preparation is done, we iterate over the subscribers list with a simple for loop, and apply the context (this) and data parameter to every callback function that was subscribed to this event.

The end result could look something like this, as an object in the App namespace:

(function (App) {
  "use strict";

  var eventSubscriptions = {};

  App.eventDispatcher = {

    subscribe: function (eventName, callback) {
      // Retrieve a list of current subscribers for eventName (if any)
      var subscribers = eventSubscriptions[eventName];

      if (typeof subscribers === 'undefined') {
        // If no subscribers for this event were found,
        // initialize a new empty array
        subscribers = eventSubscriptions[eventName] = [];
      }

      // Add the given callback function to the end of the array with
      // eventSubscriptions for this event.
      subscribers.push(callback);
    },

    trigger: function (eventName, data, context) {

      var
        // Retrieve a list of subscribers for the event being triggered
        subscribers = eventSubscriptions[eventName],
        i, iMax;

      if (typeof subscribers === 'undefined') {
        // No list found for this event, return early to abort execution
        return;
      }

      // Ensure data is an array or is wrapped in an array,
      // for Function.prototype.apply use
      data = (data instanceof Array) ? data : [data];

      // Set a default value for `this` in the callback
      context = context || App;

      for (i = 0; iMax = subscribers.length; i += 1) {
        subscribers[i].apply(context, data);
      }
    }
  };
}(this.AppNamespace));

Assuming every module in your App has a similar closure wrapped around it, you can then access the event dispatcher from anywhere through App.eventDispatcher.

Implementing the event dispatcher in your modules

I start the closure of a module with a shorthand reference to the event dispatcher, usually simply events. I make a habit of grouping the subscribe calls together on bottom of the module, after all internal functions have been declared and whatnot. This gives you a single point of control to manage the event subscriptions in the module.

Event triggers, however, will be strewn through the functionality of the module, wherever they're appropriate. It helps to add a @triggers eventName to the doc-blocks of your functions to more easily see what events are being triggered in them.

A quick example will make this much clearer:

// Transport UI module for a music player
(function (App) {
  "use strict";

  // Local reference to the dispatcher
  var events = App.eventDispatcher;

  function playStarted() {
    // Update the UI to indicate the player is playing
  }

  function stopped() {
    // Update UI to indicate player is stopped
  }

  /**
   * Skip the current track and play the next one
   * @triggers UI.Transport.SkipTrack
   */
  function skipTrack() {
    events.trigger('UI.Transport.SkipTrack');
  }

  /**
   * Volume was changed through the interface, trigger event
   * @triggers UI.Transport.SetVolume
   */
  function setVolume(vol) {
    events.trigger('UI.Transport.SetVolume', vol);
  }

  // Subscribe to external events
  events.subscribe('Player.PlayStarted', playStarted);
  events.subscribe('Player.Stopped', stopped);

}(this.AppNamespace));

As you can see, it's rather simple to work with. For passing callback functions, I've simply specified the name of functions declared earlier, however, you can of course write them as anonymous functions right in the subscribe call:

events.subscribe('UI.Transport.SetVolume', function (newVolume) {
  // Code to set the volume in the core player
});

Stupidly simple and effective debugging

If an application does all its inter-module communication through this event dispatcher object, it becomes very easy to debug. When I was working on my project, I found that most of the bugs I encountered were related to inter-module communication, as I could properly focus on what happened inside each module. After adding some simple logging facilities to the event dispatcher I could easily trace the majority of bugs back to miss-spelling an event name and forgetting to trigger events some modules were subscribed to.

I added some simple logging to the trigger function; it lets me know whenever an event is triggered, and to how many callback functions it is being dispatched. The clever part here is that it can also let you know if there are no subscribers for the event being triggered.

If a tree falls in a forest and no one is around to hear it, does it make a sound? Spoiler: It doesn't, since the definition of sound specifies that it isn't considered sound if no-one's around to hear it.

I digress. With this logging, an event with no subscribers will still make a sound in your console, and it will let you easily correct the problem.

The new trigger function will look something like this:

function trigger(eventName, data, context) {
  var
    subscribers = eventSubscriptions[eventName],
    i, iMax;

  if (typeof subscribers === 'undefined') {
    // No list found for this event, return early to abort execution
    console.log('[EventDispatcher] trigger: no subscribers for event "' + eventName + '"');
    return;
  }

  data = (data instanceof Array) ? data : [data];
  context = context || App;

  for (i = 0; iMax = subscribers.length; i += 1) {
    subscribers[i].apply(context, data);
  }
  console.log('[EventDispatcher] trigger: event "' + eventName + '" dispatched to ' + subscribers.length + ' subscribers, with data: ', data);
}

This is a simplified version of what I actually used, as the real version includes checking whether console.log is available or not. My module also allowed toggling logging on or off, as well as excluding arbitrary events from logging. My application had certain events that were triggered very frequently, so it would be hard to see the events I was interested in through the stream of other events.

Conclusion

If you're interested in my final implementation, you can view the file in my step-sequencer repository. It also makes use of a priority sorting, which has only been briefly mentioned in this post. Another new thing in that version of the dispatcher is the ability to subscribe to any number of events in one subscribe call.

I started employing this pattern as a bit of an experiment at first, but as I progressed through the project, I was time after time just delighted by how easy it made things. I'm pretty sure the logging has saved me many hours of hunting down bugs. Let me know how you're implementing it, and do let me know of any improvements you can think of.

Comments are closed.