There are plenty of libraries out there for attaching scripted
behavior to HTML elements. Unfortunately, we're
using Prototype, which
lacks support for this. While we could integrate one of these
libraries, in theory, I believe it's not worth the side-effects:
possible destabilization due to conflicts, learning a new library,
and additional download time for our clients.
Instead, I wrote one up myself. It doesn't have to do very much:
scan the DOM when the page loads and monitor the DOM for dynamic
updates. I've chosen to do strict class-based behavior. To enable
behavior on an element, you must do three things:
-
On the JavaScript side, you code up the actual behavior (for
instance, submitting an Ajax request for a link, instead of
following it normally).
-
On the HTML side of things, you have to add a class to your
element so our DOM watcher can know where to attach it. For
instance, you would specify a class of "async_link" on the A tags
where you want an Ajax request submitted.
-
Finally, you have to inform your DOM watcher that a given CSS
class has a given JS behavior. With the module I've written,
this is as simple as:
DOMWatcher.EventHandlers.async_link = AsyncLink.Watcher;
Well, that's the theory anyway. On to the code.
First, let's create
a module
to contain all this code, along with the public API methods:
var DOMWatcher = function () {
return {
EventHandlers: {},
scanDocument: function () {
attachBehavior();
},
addWatcher: function (klass, watcher) {
DOMWatcher.EventHandlers[klass] = watcher;
if (document.body) {
attachBehavior();
}
},
removeWatcher: function (klass, watcher) {
delete DOMWatcher.EventHandlers[klass];
}
};
}();
Simple enough. A method to scan the document and attach behavior,
and a couple of accessors to add and remove behavior after the
document has been loaded.
To define the element behaviors themselves, I've gone with a simple
map of CSS classes to behavior objects, which is stored
in DOMWatcher.EventHandlers
. The format of this object
is straightforward: a behavior object may contain
a setup
method, which is called with a single argument
of the element to be initialized, and methods starting with 'on'
which specify the event on this element to attach behavior.
Here's a simple example:
var AlertLink = {
setup: function () {
this._alert = 'Hello World!';
},
onclick: function (event) {
alert(this._alert);
}
};
DOMWatcher.EventHandlers.alert_link = AlertLink;
During execution of behavior methods, I want the this
object to be set to the element for which the behavior applies. It's
not terribly important - we could just pass it in, but I like this
way better.
So now that we know what we want the code to look like, lets have a
go at the attachBehavior
function which makes all this
possible:
var DOMWatcher = function () {
var ATTRIBUTE_BOUND = '_DOMWatcher_bound';
function attachBehavior(target) {
var elements, elt, klass, i, length, handler, method;
target = target || document.body;
elements = target.getElementsByTagName('*');
for (i = 0, length = elements.length; i < length; i++) {
elt = $(elements[i]);
for (klass in DOMWatcher.EventHandlers) {
if (DOMWatcher.EventHandlers.hasOwnProperty(klass) &&
!elt[ATTRIBUTE_BOUND] && elt.hasClassName(klass)) {
elt[ATTRIBUTE_BOUND] = true;
handler = DOMWatcher.EventHandlers[klass];
if (handler.setup) {
handler.setup.call(elt);
}
for (method in handler) {
if (method.substring(0, 2) == 'on') {
Event.observe(elt, method.substring(2, method.length),
handler[method].bindAsEventListener(elt));
}
}
}
}
}
}
...
}();
Let's break that down: first we grab the node from which to begin
scanning, defaulting to document.body, if it wasn't passed in, and
grab all its children:
target = target || document.body;
elements = target.getElementsByTagName('*');
Once we have all the child elements, we can iterate over them,
looking for classes with behavior defined:
for (i = 0, length = elements.length; i < length; i++) {
elt = $(elements[i]);
for (klass in DOMWatcher.EventHandlers) {
if (DOMWatcher.EventHandlers.hasOwnProperty(klass) &&
!elt[ATTRIBUTE_BOUND] && elt.hasClassName(klass)) {
elt[ATTRIBUTE_BOUND] = true;
...
}
}
}
Note that we're using elt[ATTRIBUTE_BOUND] in order to store whether
or not we've already attached behavior to this element, as an
optimization to prevent reattaching behavior.
With the element stored in our temporary elt
variable
and a behavior class in klass
, we can now run the setup
routine and attach event handlers
from DOMWatcher.EventHandlers
:
...
handler = DOMWatcher.EventHandlers[klass];
if (handler.setup) {
handler.setup.call(elt);
}
for (method in handler) {
if (method.substring(0, 2) == 'on') {
Event.observe(elt, method.substring(2, method.length),
handler[method].bindAsEventListener(elt));
}
}
...
Now let's set up the scan to happen when the document is finished
loading, so our behavior will get attached when the page is ready:
Event.observe(window, 'load', DOMWatcher.scanDocument);
And we're almost done. We also need to handle the case of
Ajax updates to the DOM. Unfortunately, there's no good
cross-browser way to do this, as only a few support
DOMNodeInserted. Notably, IE does not, and has no equivalent that we
could use in its stead. Also, Prototype has no facilities to support
this, and in fact makes it quite painful to try and do it cleanly.
Luckily, JavaScript is incredibly dynamic, and has no real security
model, so what we can do instead of events is scan the document when
certain Prototype functions are called. Since we use Prototype
exclusively, this is only a matter of figuring out which calls
update the DOM, and wrapping them to add a call
to DOMWatcher.scanDocument
:
(function () {
var oldReplace = Element.Methods.replace;
var oldUpdate = Element.Methods.update;
var oldInsertion = Abstract.Insertion.prototype.initialize;
Element.Methods.replace = function (element, html) {
oldReplace(element, html);
DOMWatcher.scanDocument();
};
Element.replace = Element.Methods.replace;
Element.Methods.update = function (element, html) {
oldUpdate(element, html);
DOMWatcher.scanDocument();
};
Element.update = Element.Methods.update;
Abstract.Insertion.prototype.initialize = function (element, content) {
oldInsertion.call(this, element, content);
DOMWatcher.scanDocument();
};
})();
It's worth noting that I used three temporary variables, because IE
had issues when I tried to use a single variable inside an iterative
function. Oh well. People would probably find this version easier to
read anyway.
And that's all there is to it. Really. With this foundation in
place, we now have the ability to add behaviors to elements fairly
cleanly, which encourages a nice separation of code from HTML and from
other code by use of the module pattern.
Coming up: using DOMWatcher to automatically enable and disable
links and forms.