Upgrade to Prototype 1.6: real world examples

Recently I have undertaken upgrading Radiant CMS JavaScripts to Prototype 1.6. Radiant depends on a fair amount of scripting in its administrative interface which was previously done with Prototype 1.5.0 (now almost a year old). Simply replacing the old prototype.js with the new one doesn’t immediately work – some APIs changed (most notably Hash) and I also wanted to slim down old code using some fresh features. I will now show you some examples of what I’ve done, how I did it and why; you might find this writeup useful when doing the same in your application.

A screenshot of Radiant CMS interface

The complete patch is viewable on Pastie. I have submitted it to the developers of Radiant. Update: these changes are effective in Radiant CMS as of changeset 563. Thanks, Sean!

Finding and manipulating DOM nodes

Find first element with the class tabs under a node referenced by this.element:

// before:
this.tab_container = document.getElementsByClassName('tabs', this.element).first();

// after:
this.tab_container = this.element.down('.tabs');

Some drawbacks of the original approach:

  1. It’s a bit verbose;
  2. getElementsByClassName is deprecated;
  3. It fetches all the elements with the class tabs, but we only need the first one.

In the refactored approach I used the Element#down method which returns the first descendant that matches the CSS selector. If I, for instance, wanted to fetch all the matching elements I would have used Element#select:

// returns all elements under the current element matching the selector
this.element.select('.tabs')

OK, so this was easy. Let’s skip forward a bit, right to the next snippet:

new Insertion.Bottom(
  this.tab_container,
  '<a class="tab" href="javascript:TabControl.controls[\''
  + this.control_id
  + '\'].select(\'' + tab_id + '\');">' + caption + '</a>');

Uh-oh: the Insertion module is a deprecated API. Its awesome functionality is still available–and even more improved!–but through the Element#insert method. There is also some ugly HTML string concatenation here; a recipe for disaster… We will use string interpolation provided by the Template class through String#interpolate:

this.tab_container.insert(
  '<a class="tab" href="javascript:TabControl.controls[\'#{id}\'].select(\'#{tab_id}\');">#{caption}</a>'.interpolate({
    id: this.control_id, tab_id: tab_id, caption: caption
  })
);

Definitely much more readable. You’ll notice inline event handling (href attribute) that is in practice bad, but I didn’t remove it here so that this snippet remains a demonstration of string interpolation.

Let’s move forward. This change may be self-descriptive to most of you:

// before:
divs = $$("div.tag-description");
$A(divs).each(function(div){ Element.show(div) });

// after:
$$("div.tag-description").invoke('show');

First of all, passing the result of $$() through $A isn’t at all necessary before iteration; why convert an Array to Array? Second, I shortened this further by recognizing a common pattern where we invoke a particular method on every item of the collection – that pattern is already encapsulated in Enumerable#invoke. The Enumerable mixin is truly a gem.

There were also a lot of cases of getting element siblings this way; the next sibling, in particular:

var sibling = row.nextSibling;

This works, but the next sibling could be whitespace (a text node), and we’re interested only in HTML elements. Let’s use Element#next instead:

var sibling = row.next();

There are 2 benefits: the returned node is guaranteed to be an element (whitespace is skipped), and you could pass a CSS selector if you wanted.

I also made a couple of stylistic changes:

// before:
Element.removeClassName(row, 'children-visible');
Element.addClassName(row, 'children-hidden');

// after:
row.removeClassName('children-visible');
row.addClassName('children-hidden');

The last lines are more readable and concise. We only have to make sure that row is a DOM-extended element in this context.

This was certainly an interesting find; apparently, these were faster than addClassName and removeClassName:

onMouseOverRow: function(event) {
  this.className = this.className.replace(/\s*\bhighlight\b|$/, ' highlight');
},

onMouseOutRow: function(event) {
  this.className = this.className.replace(/\s*\bhighlight\b\s*/, ' ');
},

This was true in Prototype 1.5.0 where classname operations were slower, but now we don’t have to resort to such string hacks anymore. Simply:

onMouseOverRow: function(event) {
  this.addClassName('highlight');
},

onMouseOutRow: function(event) {
  this.removeClassName('highlight');
},

Inspecting elements is not easy. This long line checks if the element is an image with a specific class name:

isExpander: function(element) {
  return (element.tagName.strip().downcase() == 'img') && /\bexpander\b/i.test(element.className);
},

But, Prototype has great CSS selector support for some time now. Why don’t we just check if an element matches a selector?

isExpander: function(element) {
  return element.match('img.expander');
},

Lastly, let’s observe a constructor method that performs some setup on every row of a given table:

// before:
initialize: function(element_id) {
  var table = $(element_id);
  var rows = table.getElementsByTagName('tr');
  for (var i = 0; i < rows.length; i++) {
    this.setupRow(rows[i]);
  }
}

// after:
initialize: function(element_id) {
  $(element_id).select('tr').each(this.setupRow, this)
}

The change is pretty self-explanatory. One note, though: we’ve given this as the second argument to each. This is a feature new in Enumerable; it will bind the iterator function (this.setupRow) to that object. We call that a context argument. (It is an equivalent of .each(this.setupRow.bind(this)), in case you wondered.)

Event handling

The Event module of Prototype 1.6.0 has become pretty strong. It works around quite a number of browser bugs; most notably, context of handler execution in Internet Explorer. Now you can be sure that this keyword inside an event handler refers to the observing element in all browsers and you don’t have to explicitly bind it anymore:

// before:
Event.observe(row, 'mouseover', this.onMouseOverRow.bindAsEventListener(row));
Event.observe(row, 'mouseout', this.onMouseOutRow.bindAsEventListener(row));

// after:
row.observe('mouseover', this.onMouseOverRow);
row.observe('mouseout', this.onMouseOutRow);

In this case when a mouseover/out event fires, the context of onMouseOver/OutRow execution will be row, just like in all standards-compliant browsers. Of course, you may always choose to bind the listener to some object other than row.

Prototype extends not only DOM elements with its useful methods, it also extends event objects. Observe this little fragment:

onMouseClickRow: function(event) {
  var element = Event.element(event);
  if (this.isExpander(element)) {
    var row = Event.findElement(event, 'tr');
    ...

Event instance methods like element and findElement are now available straight on the event instance:

var element = event.element();
...
var row = event.findElement('tr');

This is true for all listeners set up through the observe() method, but not for inline event handlers; have that in mind. There is no reason to use inline event handling anymore, anyway.

Classes and inheritance

The following snippet is a signature of old class creation in Prototype:

var SiteMap = Class.create();
// Inherit from RuledTable:
SiteMap.prototype = Object.extend({}, RuledTable.prototype);

Object.extend(SiteMap.prototype, {

  ruledTableInitialize: RuledTable.prototype.initialize,

  initialize: function(id, expanded) {
    this.ruledTableInitialize(id);
    this.expandedRows = expanded;
  },
  ...
});

This is long and tedious. First we define the SiteMap class, then we inherit instance methods from RuledTable, then define rest of the instance methods for SiteMap while preserving the reference to the original constructor. We can abandon those ways, however–Prototype has much better class support now:

var SiteMap = Class.create(RuledTable, {
  initialize: function($super, id, expanded) {
    $super(id);
    this.expandedRows = expanded;
  },
  ...
});

Observe the call to the original initialize method through a special word $super. You can find documentation for this and much more in “Defining classes and inheritance”, a Prototype tutorial.

Hash API

All of the previous enhancements were purely optional–since Prototype is so much backwards-compatible, almost all of the old 1.5.0 code would work with Prototype 1.6.0 release. There is but one exception: Hash.

Hash has been completely rewritten to use an internal store for all the key-value pairs. We are forcing the use of getters and setters to avoid mixing hash data with its instance methods; the chance of keys colliding with Hash methods were just not acceptable anymore. This means that whenever you have to get or set a value for a key, you have to use get() and set() methods. This is how updating the TabControl code looked like:

// new Hash instance:
this.tabs = $H();
// old style:
this.tabs[tab_id] = tab;
var object = this.tabs[something];
// new style:
this.tabs.set(tab_id, tab);
var object = this.tabs.get(something);

The job was simply finding all such places and redefining the code to use get/set. There was also some ill-written code to remove a key defined in id from this.tabs hash:

new_tabs = $H();
this.tabs.each(function(pair) {
  if (pair.key != id) new_tabs[pair.key] = pair.value;
});
this.tabs = new_tabs;

This code was too complicated, because with the old Hash a simple delete this.tabs[id] was enough. With the new Hash this isn’t possible anymore because of the internal store. Instead, you have to use unset:

this.tabs.unset(id);

Final notes

An experienced Prototype hacker can by examining old code find even more places for enhancements. However, I’ve refrained from doing that to keep this first patch simple. When you are rewriting or refactoring, you don’t have to cover every possibility in a single run. Instead, decide what you want to do in advance and make it happen through multiple iterations while using version control. When your JavaScript code doesn’t have unit tests, incremental micro-refactoring is probably the best methodology for big rewrites. Now that this has been commited to Radiant trunk I will be making more incremental enhancements, possibly reducing the amount of code significantly.

Further reading