2006/01/17

Idempotent event queue in JavaScript for use with Behaviour, etc.

[caveat: my original copy now does more, i.e. copies arguments and uses "this" properly. Will paste in later.]

So anyway, there's me using my (modified) copy of the very groovy Behaviour to extract all the JavaScript from my nice clean semantic HTML(or JSP (*cough* bleh)), and wondering how I ever lived with the mish-mash I just rescued.

Generally speaking, Behaviour rulesets are applied on page load, (hence Behaviour.addLoadEvent), whereupon all the selectors in all the rulesets are evaluated and all the events are assigned and added.

However, whenever the page changes (as a result of the Prototype Ajax.Updater, for example), the standard way of ensuring that the rules still apply is to call Behaviour.apply() again, which re-assigns all the events to all the elements etc.

BUT, we have two issues here. Fit the first: suppose one element matches two selectors. If we assign our events in the normal way that works 90% of the time, as follows (to take a simple example):
".button" : function(el) { el.onclick = pressButton(el.id); },
".important" : function(el) { el.onclick = warn("Atchung!"); }, //...

For this element:
<a class="important button">click</a>
Here, two events (one for each rule) would be assigned to the same element, and the first one would overwrite the second one since el.onclick can only ever be one thing.

The standard answer to this is simply to use addEventListener (FF/W3C) or attachEvent(MS). This solves our first problem. A second possible answer is to adapt Simon Willison's addLoadEvent code to enable the addition of multiple functions to arbitrary events, as follows:

/*functionally equivalent to the original*/
function addLoadEvent(func) {
addEvent(window, "onload", func);
}

/*generic version*/
function addEvent(obj, evt, func) {
var oldEvt = obj[evt];
if (typeof oldEvt != 'function') {
obj[evt] = func;
} else {
obj[evt] = function() {
oldEvt();
func();
}
}

HOWEVER, when adding events using addEventListener, attachEvent, or addEvent, subsequent calls to Behaviour.apply() will re-add new events to the same elements, with the result that events added via Behaviour will fire once for each re-application of the given ruleset, i.e. potentially far too many times.

The code that demonstrates this is as follows:

var f = function() { alert("foo"); };

var o = new Object();

addEvent(o, "bang", f);
addEvent(o, "bang", f);
addEvent(o, "bang", f);

o.bang();
With this example, using the above addEvent code (and the addEventListener etc. (although I haven't tested it and I'd look mighty stupid if I'm wrong)), the "foo" alert will appear three times. This is not what we want.

My solution was to write my own event queue for which the "add" function was idempotent. This means that adding the same function three times (as above) has no effect on the second and third time. (More properly, an idempotent operation is one in which one or many executions is functionally identical.)

This requires the ability to compare functions, and luckily the toString() of a Function object returns the source of that function - the ideal way to find identical chunks of code.

So, without further ado:

/*
this is functionally equivalent with the original - for reverse
compatibility
*/
function addLoadEvent(func) {
addEvent(window, "onload", func);
}

/*
usage as above, e.g. addEvent(element, "onclick", function() {} );
generalized and adapted from
http://simon.incutio.com/archive/2004/05/26/addLoadEvent
*/
function addEvent(obj, evt, func) {
var oldFunc = obj[evt];

if (typeof oldFunc != 'function') {
obj[evt] = getEvent();
obj[evt].addListener(func);
} else {
if(oldFunc.__EVT_LIST) {
obj[evt].addListener(func);
} else {
obj[evt] = getEvent();
obj[evt].addListener(oldFunc);
obj[evt].addListener(func);
}
}
}

/*
this could be put within the above function, but that causes a memory
leak in IE
*/
function getEvent() {
var list = [];

/*
this extends the array instance to allow
for string comparison of functions
*/
list.hasFunction = function(val) {
for (var i = 0; i != this.length; i++) {
if(this[i].toString() == val.toString()) return true;
}
return false;
}

/*
this is the actual function that is called when the event is fired.
if any of the listeners return false, then false is returned,
otherwise true
*/
var result = function(event) {
var finalResult = true;
for(var i = 0; i != list.length; i++) {
var evtResult = list[i](event);
if(evtResult == false) finalResult = false;
}
return finalResult;
}

/*
this is the function on the event that adds a listener
*/
result.addListener = function(f) {
if(f == null) return;
if(list.hasFunction(f)) return;
list.push(f);
}

/*
this is a debug function - feel free to remove
usage example: window.onload.list();
*/
result.list = function() {
var log = "";
for(var i = 0; i != list.length; i ++) {
log += "<pre>"+list[i]+"</pre><hr/>";
}
var wnd = window.open("", "Event dump");
wnd.document.write(log);
}

/*
this is a semaphore to ensure that we play nice with other code
*/
result.__EVT_LIST = true;

return result;
}


Enjoy. I think a nice touch is the ability to list the events attached to an element by calling (e.g.) window.onload.list(); but this is generally debug stuff.

I should sleep.

No comments: