Thursday, May 7, 2009

Prototype.js decoupling - Antenna

I am referring to "shedding the vile old ways"
(as Prototype developers called it) in http://www.prototypejs.org/api/event/observe

This is called a "decoupling".

Pros of this approach:
  1. Can dynamically attach listeners to any event in DOM
  2. Can attach multiple listeners
Cons:
  1. Easy to lose track of what is going on in the system on each event
  2. Very hard to debug without proper tools

Imagine if all of your objects are communicating via events (not method calls),
how would you trace these communications?

Apparently, there is no tools yet to do exactly that. And prototype.js does not provide a good way to trace events. So I decided to solve it for myself and, hopefully, some of you may find this solution useful.

I called this solution "Antenna" just because I could not think of a better way to describe what it does in one word. It catches the events "in the air", much like a real antenna does with all sorts of electromagnetic events.

So here goes:

1. We are going to modify prototype.js to fire debug events whenever the following things happen:
  • Event listener is registered
  • Event listener is unregistered
  • Event is fired
  • Event is handled
Find line that defines "Object.extend(Event, (function() { .... " (line 3936 in version 1.6.0.3)
Find "return" clause for this object (line 4009)
Modify it as follows.

return {
observe: function(element, eventName, handler) {
element = $(element);
var name = getDOMEventName(eventName);
/*************** ANTENNA CODE *****************/
if (DEBUG && eventName.indexOf('antenna:') != 0) {

var callerScope = arguments.callee.caller.caller;

handler = handler.wrap( function (f, e) {
document.fire('antenna:event_capture',{
eventName: (e.eventName ? e.eventName : e.type),
element: e.findElement(),
handler: f,
callerScope: callerScope
});
return f(e);
});

document.fire('antenna:event_observe', {
eventName: eventName,
element: element,
handler: handler,
callerScope: callerScope
});
}
/**********************************************/

var wrapper = createWrapper(element, eventName, handler);
if (!wrapper) return element;

if (element.addEventListener) {
element.addEventListener(name, wrapper, false);
} else {
element.attachEvent("on" + name, wrapper);
}

return element;
},

stopObserving: function(element, eventName, handler) {
element = $(element);
var id = getEventID(element), name = getDOMEventName(eventName);


if (!handler && eventName) {
getWrappersForEventName(id, eventName).each(function(wrapper) {
element.stopObserving(eventName, wrapper.handler);
});
return element;

} else if (!eventName) {
Object.keys(getCacheForID(id)).each(function(eventName) {
element.stopObserving(eventName);
});
return element;
}

var wrapper = findWrapper(id, eventName, handler);
if (!wrapper) return element;

/*************** ANTENNA CODE *****************/
if (DEBUG && eventName.indexOf('antenna:') != 0) {
document.fire('antenna:event_unobserve', {
eventName: eventName,
element: element,
handler: handler,
callerScope: arguments.callee.caller.caller
});
}
/**********************************************/

if (element.removeEventListener) {
element.removeEventListener(name, wrapper, false);
} else {
element.detachEvent("on" + name, wrapper);
}

destroyWrapper(id, eventName, handler);

return element;
},

fire: function(element, eventName, memo) {
element = $(element);
if (element == document && document.createEvent && !element.dispatchEvent)
element = document.documentElement;

/*************** ANTENNA CODE *****************/
if (DEBUG && eventName.indexOf('antenna:') != 0) {
document.fire('antenna:event_fire', {
eventName: eventName,
element: element,
memo: memo,
callerScope: arguments.callee.caller.caller
});
}
/**********************************************/

var event;
if (document.createEvent) {
event = document.createEvent("HTMLEvents");
event.initEvent("dataavailable", true, true);
} else {
event = document.createEventObject();
event.eventType = "ondataavailable";
}

event.eventName = eventName;
event.memo = memo || { };

if (document.createEvent) {
element.dispatchEvent(event);
} else {
element.fireEvent(event.eventType, event);
}

return Event.extend(event);
}
};
})());


Note, that we must have DEBUG=true in order for this to work - we would not want this to happen all the time: too much overhead, right?

2. In order to catch these events all you need to do is to define 4 observers as follows:



document.observe('antenna:event_observe', function(e) { ... });
document.observe('antenna:event_unobserve', function(e) { ... });
document.observe('antenna:event_fire', function(e) { ... });
document.observe('antenna:event_capture', function(e) { ... });



I am currently working on a crossbrowser presentation layer for this solution, so more code to follow.

3 comments:

  1. You could also wrap the Prototype Event methods like this:

    Event.observe = Event.observe.wrap(function(original, element, eventName, handler){console.info("Registering '%a' for element %b with handler: %c", eventName, element, handler); return original(element,eventName,handler)});

    ReplyDelete
  2. Yes, this would be very elegant, and I this was the first thing I tried. Unfortunately, this solution did not work for me.

    ReplyDelete
  3. I've asked a related question on StackOverflow, which is the best place these days to ask programming questions. The question links to this post on Antenna.

    http://stackoverflow.com/questions/871442/custom-event-logging-for-javascript-frameworks/871633#871633

    We can watch that thread to see if additional ideas emerge.

    ReplyDelete