Objectified: Custom element methods in Prototype

Posted in Development, JavaScript, Tutorials, Web

N.B.: This article assumes basic knowledge of JavaScript and familiarity with a very recent version of Prototype (ideally 1.5_rc0). For an introduction to Prototype, try Sitepoint’s Painless JavaScript Using Prototype; to get a recent version of Prototype, download Scriptaculous or follow the instructions for Subversion checkout on the Prototype homepage.

The Scandalous De‐Objectification of the DOM

One of the complaints leveled against Prototype is its use of arbitrary “namespaces” for some of its functions. In a perfect world where every browser implemented the DOM flawlessly, we’d have code like this:

someNode.addEventListener('mouseover', Eye.candy.init, false;

Prototype has to abstract between addEventListener and IE’s proprietary attachEvent, so instead we’re required to write:

Event.observe(someNode, 'mouseover', Eye.candy.init, false);

Take a look at the first example. Notice how the action is being performed on someNode. If this line of code were an English sentence, we’d call someNode the direct object, because it’s acted upon by the verb addEventListener. (Technically, “add” is the verb, “event listener” is the direct object, and someNode is the indirect object. But English doesn’t afford straightforward examples.)

This is what is meant by the term object‐oriented programming. Object in this sense doesn’t mean “thing”; it means “that which is acted upon.” Object‐oriented languages like JavaScript arrange their functions so that they’re actually attached to the things they modify.

Now look at the Prototype code. The “object” isn’t really the receiver of the action — Event functions more like a namespace here. What was the object in the first example is now the first argument in the second.

Another notable example: Prototype gives us a very handy document.getElementsByClassName function, but in order to restrict it to a certain page contect, we have to provide that context as a second argument. So instead of the expected $("container").getElementsByClassName("foo";), we must do document.getElementsByClassName("foo", $('container')).

This is nothing to write an elegy about, but it’s unintuitive and inconsistent. It’s the Document Object model, dammit, not the Document First‐Argument Model. JavaScript is object‐oriented, so these methods ought to be, well, oriented around objects.

The Problem (and the Solution)

As usual, Internet Explorer is to blame. Its DOM support is half‐assed for reasons that would take a whole other article to explain. But suffice it to say that IE doesn’t let you add custom methods to the HTMLElement prototype (the object all DOM HTML elements inherit from). Thus Prototype methods like hide had to become Element.hide; observe had to become Event.observe.

About a month ago, though, Sam Stephenson added a brilliant hack to Prototype’s nightly builds. Though IE doesn’t expose its DOM prototypes, it does support “expando” methods on DOM node instances, so Prototype’s clever workaround is to “extend” some of its core methods upon any DOM node instance returned by its custom DOM functions: $, $$, and document.getElementsByClassName. Because IE won’t let us define these methods in one place, we have to slap them onto each element one‐by‐one; it ain’t pretty, but it gets the job done.

So as long as a DOM element is run through one of these wringers (or through Element.extend), it can be given any number of arbitrary methods. (Elements obtained through core DOM methods, like getElementsByTagName, should be run through $ so that they’ll get the extra methods.)

The Possibilities

At long last, Element.hide("container") can become $("container").hide(). That’s not all, though. Open your own copy of prototype.js and search for “Element.Methods” — it’s the object that contains all the custom methods that are “extended” onto DOM elements by default.

Of course, you can add to this list, and that’s where the magic happens.

When I use Prototype at work, I try to design my components so that they can be dropped into a page without a SCRIPT block. Long story short: each component gets its own object, but not all of them are going to be used on a given page. There’s no reason to load a calendar widget, for example, unless there’s a form field on the page for entering a date. So I have to figure out what to load and what not to load.

I do this by checking for custom class names on elements. My date‐picker widget, for example, looks for any elements with the class widget-datepicker on the page and generates an instance for each element it finds.

So I find it handy to have a detectClass method that will help a component figure out whether it’s needed. I can add it to Element.Methods like so:

Element.addMethods({
    detectClass: function(element, className) {
      return ( document.getElementsByClassName(className, element).length > 0 );
    }
});

This function will run document.getElementsByClassName within the given page context and will return true if it finds at least one element, or false if it finds none. Now I can do…

if ( $('maincontent').detectClass('widget-datepicker') ) { 
    Widgets.DatePicker.run();
});

(Notice how I defined two arguments in the function definition, but omitted the first when I used the function? Behind the scenes, Prototype “shifts” the arguments when it applies these methods to nodes. In short, it takes the first argument, assumes it’s the DOM node you want to apply the method to, and shifts all the remaining arguments up one slot. In short: don’t use this in any methods you add. Just follow the existing convention and define element as your first function argument, then use that as a reference to the node throughout your function.)

We can do other cool stuff, of course. We can extend Event.observe onto our elements like so:

Element.addMethods({
    observe: Event.observe,
    stopObserving: Event.stopObserving
});

Why is this so easy? Because Event.observe takes a DOM node as its first argument, which means it’s ready to use as‐is. Now you can do $('container').observe('mouseover', Eye.candy, false) instead of Event.observe($('container'), 'mouseover', Eye.candy, false) — just as the DOM gods intended. (In fact, you could rename the methods addEventListener and removeEventListener and have them work the exact same way as the standard DOM methods!)

The Conclusion

I’ll leave you with two final methods. One of them will turn document.getElementsByClassName('foo', $('bar')) into $('bar').getElementsByClassName('foo'). The other lets you leverage the power of the $$ function within a specific page context.

Element.addMethods({
  getElementsByClassName: function(element, className) {
    var children = ($(element) || document.body).getElementsByTagName('*');
    return $A(children).inject([], function(elements, child) {
      if (child.className.match(new RegExp("(^|\s)" + className + "(\s|$)")))
        elements.push(Element.extend(child));
      return elements;
    });
  },

  getElementsBySelector: function() {
    var args = $A(arguments), element = args.shift();
    return args.map(function(expression) {
      return expression.strip().split(/\s+/).inject([null], function(results, expr) {
        var selector = new Selector(expr);
        return results.map(selector.findElements.bind(selector, element)).flatten();
      });
    }).flatten();
  }
});

Go forth and objectify!

Comments