This post is part of a series on event-driven programming. The complete series is:
- Events: they’re not just for the DOM, you know
- Observable objects
- Deferrable values
- Asynchronous methods
- First-leg round-up and final remarks
- Object lifecycle
- Asynchronous pipelines
- Testing event-driven apps
As I mentioned in the previous article, events are not things that only live in the DOM. An event is simply a point in time at which a change happens: the user clicks something, an animation completes, a piece of data changes. Events are everywhere in GUI programming and also crop up in various server-side applications, especially with the rise of Node.js. Making your APIs support events is a great way to build modular applications and keep your code maintainable and extensible at the same time.
Observable objects are simply objects that can publish events. Other parts of an application can listen to the object and execute additional code whenever the object changes in some way. This means the object can have additional functionality attached to its behaviour without modifying its source code. As a concrete example, I’m going to show how I’d implement Yehuda’s tabs example from yesterday’s post in an object-oriented style.
The markup and setup code in my implementation will look very similar:
<ul class="tabs">
<li data-pane="first">First</li>
<li data-pane="second" class="selected">
Second
</li>
<li data-pane="third">Third</li>
</ul>
<div class="pane" id="first">Some content</div>
<div class="pane" id="second">Some content</div>
<div class="pane" id="third">Some content</div>
<script type="text/javascript">
var tabs = new Tabs("ul.tabs");
tabs.on("change", function(index) {
tabs.getPanes().hide()
.at(index).show();
});
tabs.select(0);
</script>
This probably looks superficially similar to the first implementation. The
difference is that in this example, other scripts that want to interact with the
tabs
object have a proper API for doing so, so it’s clearer what behaviour is
deliberate public API and what is implementation detail. In the previous
version, not being able to tell the abstraction from the implementation (since
it all looks like DOM code) could create serious maintenance problems later on.
Our Tabs
will look like this: (I’m using Ojay for this example. It has
its own Observable module but we’ll ignore that for now.)
Tabs = new JS.Class({
initialize: function(selector) {
this._tabs = Ojay(selector).children();
this._tabs.forEach(function(tab, index) {
tab.on("click", function() { this.select(index) }, this);
}, this);
},
select: function(index) {
this._selectedIndex = index;
this._tabs.removeClass("selected");
this._tabs.at(index).addClass("selected");
this.trigger("change", [index]);
},
getPanes: function() {
var ids = this._tabs.map(function(tab) {
return "#" + tab.node.getAttribute("data-pane");
});
return Ojay.apply(this, ids);
}
});
It’s fine to have user interface widgets implemented as objects like this; in
fact it can make them easier to reuse and glue together as long as they have a
decent event API. Speaking of which, you’ll notice that Tabs
is missing two
methods our implementation depends on: on()
and trigger()
. These are the
bedrock of any observable object, allowing other parts of the system to add
listeners and allowing the object itself to notify listeners of changes. There
are many ways to implement these depending on your needs, but a good starting
point could look like this:
Observable = {
on: function(eventType, listener, scope) {
this._listeners = this._listeners || {};
var list = this._listeners[eventType] = this._listeners[eventType] || [];
list.push([listener, scope]);
},
trigger: function(eventType, args) {
if (!this._listeners) return;
var list = this._listeners[eventType];
if (!list) return;
list.forEach(function(listener) {
listener[0].apply(listener[1], args);
});
}
};
Observable.on()
takes three arguments: the name of the event, a listener
function, and (optionally) a scope that this
will be bound to when the
listener is called. (The scope
argument is useful in any JavaScript API method
that takes a callback function, making it easier to use the method in an
object-oriented context.) All that on()
does is store the listener in a
collection (this._listeners
) indexed by event type.
Observable.trigger()
is the other side of the bargain: it lets the object send
notifications that events are taking place. First it looks to see whether there
are any listeners for the triggered event, and if there are then it calls each
of them with the args
the event was triggered with. In our case our change
event sends the newly selected index to all the listeners as this is a useful
piece of information about the event that listeners are likely to use. Our
application above uses the index to hide and show a series of content panes.
We can take these methods and add them to any class we like since they’re totally generic. In Ojay this is done like this:
Tabs.include(Observable);
So when should you use an observable object? The short answer is, any time you want a change to have consequences that aren’t directly related to the class at hand. The canonical example is logging, for example we may decide we want to run analytics when the tab selection changes but don’t want to bloat the class up with tracking code:
tabs.on("change", function(index) {
pageTracker._trackPageview("/events/tabchange/" + index);
});
(See also the Facebook
examples in yesterday’s article.) Think of
trigger()
as an extension point that opens the class up to accept new
behaviour without having its source modified. The listeners you add to an object
ought to be independent; the idea of the observable object is to provide a way
of glueing together modules without tightly coupling them. One common gotcha (as
illustrated by Yehuda’s demo) is adding listeners that depend on each other:
$("ul.tabs").bind("change", function() {
$(this).attr("panes").hide();
$(this).attr("selected").show();
});
$("ul.tabs").bind("change", function(e) {
$(this).attr("selected", e.target);
$(e.target)
.addClass("selected")
.siblings()
.removeClass("selected");
});
These two listeners are bound to the same event, yet one reads and one writes
the value of $(this).attr("selected")
. This relies on the listeners being
executed in a certain order, which is an anti-pattern in any asynchronous or
event-driven system. Separate listeners ought not to know about each others’
existence, so if you have two listeners that depend on each other consider
merging them into one atomic function to make sure they work correctly, or find
another way to decouple them.
This piece also illustrates a more general approach that I take to building
GUIs. I provide an API for scripting every atomic action the widget can perform,
for example the Tabs#select(index)
method. This allows multiple scripts and UI
elements to control the widget. Then I wire up the UI elements in the DOM with
very thin event listeners that call this API, for example
this._tabs.forEach(function(tab, index) {
tab.on("click", function() { this.select(index) }, this);
}, this);
The widget’s methods perform the absolute core of required behaviour before firing any relevant events to allow other parts of the application perform additional actions. This takes a little discipline to stick to but it’s usually straightforward and yields modular, flexible and easily extensible code if you get it right.
Up next: waiting for responses with the Deferrable pattern.