Evented Programming With jQuery
Over the past several years, I've been actively using jQuery for a variety of things. Early on, I shared the frustration that people had around using jQuery for more substantial projects. Starting with version 1.2 and continuing with version 1.3, however, jQuery provides a powerful evented model that can be used to build up fairly complex applications using evented, as opposed to traditional objected oriented programming.
The basic idea is that by leveraging asynchronous events, it is easier to model the fundamentally asynchronous nature of client-side web applications. Also, by writing components of your application to simply emit and handle events, it becomes a lot easier to extend or modify behavior down the road.
To illustrate this point, I'm going to show how you can build a tab widget using evented techniques, and show how it can be extended in several useful ways.
First, a few general principles
We're going to use basic jQuery plugins to write the widget, but instead of putting all of the startup code directly inside the plugin constructor, we're going to pass in an Object containing functions to be executed. In order to simplify matters, we'll create a default set of functions to be executed containing the default behavior, but it will be possible to clone, extend, delete, or otherwise modify that set of functions and pass in an alternative.The core of the tabs plugin will not handle any DOM manipulation. It will simply generate events that can be bound to the main tab node that will perform the manipulation. This increases the flexibility of the widget.
We're also going to treat the main tabs node as a stateful container, holding information about the widget that we can retrieve later. We will achieve this using $().data(), an API added to jQuery in jQuery 1.2 and enhanced several times since then.
Finally, we're going to use the executing anonymous closure trick ((function() { ... })()
) to create a scratch-pad for helper functions that are used in various parts of the codebase. I use this trick frequently to create simple namespaces for internal methods.
To start (a quick detour)
Let's start with a quick bit of sugar that I added to simplify setting and getting state out of a node. As I said above, jQuery already supplies the facilities for this using it's internal data cache. ```javascript var $$ = function(param) { var node = $(param)[0]; var id = $.data(node); $.cache[id] = $.cache[id] || {}; $.cache[id].node = node; return $.cache[id]; }; ``` Now, instead of doing$("#foo").data("foo")
and ("#foo").data("foo", "bar")
, you can do $$("#foo").foo
and $$("#foo").foo = bar
. The main motivation for this is that you can also do $$("#foo").bar = $$("#foo").bar + 1
.
Step 1
The first step is to determine which events we're going to need. After brainstorming a bit, we can come to:- An event each time a tab is activated. Parameter: the tab that was clicked
- An event at startup time for each panel that is bound to a tab. Parameter: the panel
- An event once startup is complete. No parameters.
// Initialize
this.each(function() {
var tabList = $(this);
$$(tabList).panels = $();
$("li a", tabList)
.click(function() {
tabList.trigger("activated", this);
return false;
}).each(function() {
var panel = $($(this).attr("href"));
$$(tabList).panels = $$(tabList).panels.add(panel);
tabList.trigger("setupPanel", [panel]);
});
tabList.trigger("initialize");
});
return this;
};
First, we get a reference to the tab list, since we will be creating callbacks later that will need access to it. Next, store a list of panels in the tab list using $$ (<code>$$(tabList).panels = $();</code>). We start by storing an empty jQuery object as "panels" in the tab widget.
Next, we bind a click handler to each <code>"li a"</code> inside the tab list ul. When it's clicked, we simply trigger the activate event. We'll implement the default behavior in a bit.
Finally, for each tab, we collect its associated panel and trigger the setupPanel event. The default behavior for setupPanel will simply be to hide it. We'll implement that behavior in a bit as well.
Once we're done, we trigger the panel's initialize event.
<h3>Step 2</h3>
The next step is to declare the default functionality. Before we do that, let's create a small jQuery helper that applies a set of functions to an object. You'll see how it's used in a moment.
```javascript
jQuery.fn.setupPlugin = function(setup, options) {
for(extra in setup) {
var self = this;
if(setup[extra] instanceof Array) {
for(var i=0; i<setup[extra].length; i++)
setup[extra][i].call(self, options);
} else {
setup[extra].call(self, options);
}
}
};
This method is called on a jQuery object. It takes an object containing a set of setup methods, and the options that were passed into the plugin. For each key in the setup object, this method takes each function attached to it (the value for the key can either be a function or an Array of functions), and calls it with the jQuery object as "this" and the options as the first parameter.
What we're going to do is call that method with a default set of methods (which we'll call $.fn.tabs.base
this.setupPlugin(options.setup || $.fn.tabs.base, options);
Next, under the implemention (or in a separate file), define the defaults.
var getPanel = function(selected) {
return $($(selected).attr("href"));
};
$.fn.tabs.base = {
setupPanel: [function(options) {
this.bind("setupPanel", function(e, selector) {
$(selector).hide();
});
}],
initialize: [function(options) {
this.bind("initialize", function() {
var firstTab = $(this).find("li a:first")[0];
$(this).trigger("activated", firstTab);
});
}],
activate: [function(options) {
this.bind("activated", function(e, selected) {
var panel = getPanel(selected);
$$(this).panels.hide();
$(panel).show();
$(this).find("li a").removeClass("active");
$(selected).addClass("active").blur();
});
}]
};
First, we define a small helper method that gets the associated panel for a tab. We could also have used the new $$ technique above to add it directly to the node.
Next, we define a few categories of methods to be run. Each of this methods will be triggered when we call setupPlugin, and takes the options hash as a parameter (optional). We will make each of the categories an Array, so they can be extended easily by prepending a setup method.
The setupPanel method binds the setupPanel event. If you recall, setupPanel takes the panel as its second parameter. As with all events, the first parameter is the event object, which we will not use now. When setupPanel is triggered, we will simply hide the panel. Note that we have decoupled the display details of the tab list from the events.
The next thing we do is handle initialization. In the simplest case, we'll just always activate the first tab. To activate the tab, we'll just trigger another event: "activated".
Finally, we'll define the default behavior for activation. When a tab is activated, we will:
- Get the associated panel
- Get all of the panels in the tab widget and hide them
- Show the associated panel
- Remove the "active" class from all tabs
- Add the active class to this tab, and blur it
Step 3: Add support for Ajax
Now that we have the basic functionality completed, let's add support for Ajax. The API we'll use it to support an option passed into the main widget like {xhr: {"#nameOfTab": "url_to_load"}}
.
The first thing that we'll do is make a clone of the default setup object so we can modify it.
var wycats = $.extend({}, $.fn.tabs.base);
Next, we'll want to add a new function to run immediately before the default activated function. We still want to run the default activated function, which handles all the logic for displaying the tab, but we want to do some stuff first. Here's the code:
wycats.activate.unshift(function(options) {
var xhr = options.xhr;
this.bind("activated", function(e, selected) {
var url = xhr && xhr[$(selected).attr("href")];
if(url) {
var panel = getPanel(selected);
panel.html("<img src='throbber.gif'/>").load(url);
}
});
});
Now you can see why we pass the options into the setup methods. First, we store off the xhr options into a local variable, which will be available to callbacks. Next, we bind the activated event. Note that since this is event, all bound events (including the default activated event from above) will get triggered. We're using unshift here to bind this activated event first, which will cause it to get triggered before the default behavior later.
When the tab is activated, we check to see whether the tab's href property is listed inside the xhr option. If it is, we replace the HTML of the panel with a throbber, and load the URL into it.
Step 4: Adding support for history
Finally, let's add support for modifying the hash tag as we click on tabs, and loading the right tab on startup.
delete wycats.initialize;
wycats.hash = [function(options) {
var tabs = this;
this.bind("initialize", function() {
var tab = $(this).find("li a");
if(window.location.hash) tab = tab.filter("a[href='" + window.location.hash + "']");
$(this).trigger("activated", tab[0]);
});
this.bind("activated", function(e, selected) {
window.location.hash = $(selected).attr("href");
});
}];
We add a new category of functionality called "hash". First, we delete the default initialize function, because it forces the first tab to be activated no matter what. We want to activate the tab that is present in the hash value (window.location.hash).
On initialization, we first get all of the tabs. Next, we check to see whether window.location.hash is populated. If it is, we filter the list of tabs to include only the one that matches the hash. Next, we activate the first remaining tab.
When a tab is activated, we update the hash to match the tab's href.
Finally, because there is no generic "hashchange" event in the browser, we need to emulate one (in case the user presses the back button after clicking a tab):
var lastHash = window.location.hash;
setInterval(function() {
if(lastHash != window.location.hash) {
var tab = $(tabs).find("li a[href='" + window.location.hash + "']");
if(!tab.is(".active")) $(tabs).trigger("activated", tab[0]);
lastHash = window.location.hash;
}
}, 500);
Every 1/2 second, we check to see the if the hash has changed. If it did, and the tab is not yet active (which would happen if the user explicitly triggered the event), we trigger the activated event.
Wrapup
The basic idea here is that we have created an extensible tab system. The core of the tab system is just the necessary events, and we added a set of default event handlers for the events. Adding functionality, for the most part, simply required binding additional functionality to those events, and occasionally deleting a handler.
There are a few unconventional techniques here ($$, setupOptions), but they make it easier to implement the basic idea espoused in this article. It's only one possible implementation, and I'd love to hear about tweaks to this approach or entirely different event-driven designs.