Friday, May 9, 2008

Prototype JavaScript framework

Prototype code sample

Prototype is a JavaScript Framework that aims to ease development of dynamic web applications.

Featuring a unique, easy-to-use toolkit for class-driven development and the nicest Ajax library around, Prototype is quickly becoming the codebase of choice for web application developers everywhere.

How Prototype extends the DOM

The biggest part of the Prototype framework are its DOM extensions. Prototype adds many convenience methods to elements returned by the $() function: for instance, you can write $('comments').addClassName('active').show() to get the element with the ID 'comments', add a class name to it and show it (if it was previously hidden). The 'comments' element didn't have those methods in native JavaScript; how is this possible? This document reveals some clever hacks found in Prototype.

The Element.extend() method

Most of the DOM methods are encapsulated by the Element.Methods object and then copied over to the Element object (for convenience). They all receive the element to operate with as the first parameter:


Element.hide('comments');
var div_height = Element.getHeight(my_div);
Element.addClassName('contactform', 'pending');

These examples are concise and readable, but we can do better. If you have an element to work with, you can pass it through Element.extend() and it will copy all those methods directly to the element. Example, to create an element and manipulate it:


var my_div = document.createElement('div');


Element.extend(my_div);
my_div.addClassName('pending').hide();


// insert it in the document
document.body.appendChild(my_div);

Our method calls just got shorter and more intuitive! As mentioned before, Element.extend() copies all the methods from Element.Methods to our element which automatically becomes the first argument for all those functions. The extend() method is smart enough not to try to operate twice on the same element. What's even better, **the dollar function $() extends every element passed through it** with this mechanism.

In addition, Element.extend() also applies Form.Methods to FORM elements and Form.Element.Methods to INPUT, TEXTAREA and SELECT elements:


var contact_data = $('contactform').serialize();
var search_terms = $('search_input').getValue();

Note that not only the dollar function automatically extends elements! Element.extend() is also called in document.getElementsByClassName, Form.getElements, on elements returned from the $$() function (elements matching a CSS selector) and other places - in the end, chances are you will rarely need to explicitly call Element.extend() at all.

Adding your own methods with Element.addMethods()

If you have some DOM methods of your own that you'd like to add to those of Prototype, no problem! Prototype provides a mechanism for this, too. Suppose you have a bunch of functions encapsulated in an object, just pass the object over to Element.addMethods():


var MyUtils = {
    truncate: function(element, length){
        element = $(element);
        return element.update(element.innerHTML.truncate(length));
    },
    updateAndMark: function(element, html){
        return $(element).update(html).addClassName('updated');
    }
}


Element.addMethods(MyUtils);


// now you can:
$('explanation').truncate(100);

The only thing to watch out here is to make sure the first argument of these methods is the element itself. In your methods, you can also return the element in the end to allow for chainability (or, as practiced in the example, any method which itself returns the element).

Native extensions

There is a secret behind all this.

In browsers that support adding methods to prototype of native objects such as HTMLElement all DOM extensions on the element are available by default without ever having to call Element.extend(), dollar function or anything! This will then work in those browsers:


var my_div = document.createElement('div');
my_div.addClassName('pending').hide();
document.body.appendChild(my_div);

Because the prototype of the native browser object is extended, all DOM elements have Prototype extension methods built-in. This, however, isn't true for IE which doesn't let anyone touch HTMLElement.prototype. To make the previous example work in IE you would have to extend the element with Element.extend(). Don't worry, the method is smart enough not to extend an element more than once.

Because of browsers that don't support this you must take care to use DOM extensions only on elements that have been extended. For instance, the example above works in Firefox and Opera, but add Element.extend(my_div) after creating the element to make the script really solid. You can use the dollar function as a shortcut like in the following example:


// this will error out in IE:
$('someElement').parentNode.hide()
// to make it cross-browser:
$($('someElement').parentNode).hide()

Don't forget about this! Always test in all the browsers you plan to support.

Defining classes and inheritance

In the early versions of Prototype, the framework came with basic support for class creation: the Class.create() method. Until now the only feature of classes defined this way was that the constructor called a method called initialize automatically. Prototype 1.6.0 now comes with inheritance support through the Class module, which has taken several steps further since the last version; you can make richer classes in your code with more ease than before.

The cornerstone of class creation in Prototype is still the Class.create() method. With the new version of the framework, your class-based code will continue to work as before; only now you don't have to work with object prototypes directly or use Object.extend() to copy properties around.

Example

Let's compare the old way and a new way of defining classes and inheritance in Prototype:

/** obsolete syntax **/ 


var Person = Class.create();
Person.prototype = {
  initialize: function(name) {
    this.name = name;
  },
  say: function(message) {
    return this.name + ': ' + message;
  }
};


var guy = new Person('Miro');
guy.say('hi');
// -> "Miro: hi"

var Pirate = Class.create();
// inherit from Person class:
Pirate.prototype = Object.extend(new Person(), {
  // redefine the speak method
say: function(message) {
    return this.name + ': ' + message + ', yarr!';
  }
});


var john = new Pirate('Long John');
john.say('ahoy matey');
// -> "Long John: ahoy matey, yarr!"

Observe the direct interaction with class prototypes and the clumsy inheritance technique using Object.extend. Also, with Pirate redefining the say() method of Person, there is no way of calling the overridden method like you can do in programming languages that support class-based inheritance.

This has changed for the better. Compare the above with:

/** new, preferred syntax **/ 


// properties are directly passed to `create` method
var Person = Class.create({
  initialize: function(name) {
    this.name = name;
  },
  say: function(message) {
    return this.name + ': ' + message;
  }
});


// when subclassing, specify the class you want to inherit from
var Pirate = Class.create(Person, {
  // redefine the speak method
say: function($super, message) {
    return $super(message) + ', yarr!';
  }
});


var john = new Pirate('Long John');
john.say('ahoy matey');
// -> "Long John: ahoy matey, yarr!"

You can see how both class and subclass definitions are shorter because you don't need to hack their prototypes directly anymore. We have also demonstrated another new feature: the "supercall", or calling the overridden method (done with special keyword super in the Ruby language) in Pirate#say.

How to mix-in modules

So far you have seen the general way of calling Class.create.

var Pirate = Class.create(Person, { /* instance methods */ }); 

But in fact, Class.create takes in an arbitrary number of arguments. The first—if it is another class—defines that the new class should inherit from it. All other arguments are added as instance methods; internally they are subsequent calls to addMethods (see below). This can conveniently be used for mixing in modules:

// define a module 
var Vulnerable = {
  wound: function(hp) {
    this.health -= hp;
    if (this.health < 0) this.kill();
  },
  kill: function() {
    this.dead = true;
  }
};


// the first argument isn't a class object, so there is no inheritance ...
// simply mix in all the arguments as methods:
var Person = Class.create(Vulnerable, {
  initialize: function() {
    this.health = 100;
    this.dead = false;
  }
});


var bruce = new Person;
bruce.wound(55);
bruce.health; //-> 45

The $super argument in method definitions

When you override a method in a subclass, but still want to be able to call the original method, you will need a reference to it. You can obtain that reference by defining those methods with an extra argument in the front: $super. Prototype will detect this and make the overridden method available to you through that argument. But, from outside world the Pirate#say method still expects a single argument; this implementation detail doesn't affect how your code interacts with the objects.

Types of inheritance in programming languages

Generally we distinguish between class-based and prototypal inheritance, the latter being specific to JavaScript. While upcoming JavaScript 2.0 will support true class definitions, current versions of the JavaScript language implemented in modern browsers are restricted to prototypal inheritance.

Prototypal inheritance, of course, is a very useful feature of the language, but is often too verbose when you are actually creating your objects. This is why we are emulating class-based inheritance (like in the Ruby language) through prototypal inheritance internally. This has certain implications. For instance, in PHP you can define the inital values for instance variables:

class Logger {    
public $log = array();
function write($message) {
$this->log[] = $message;
}
}
$logger = new Logger;
$logger->write('foo');
$logger->write('bar');
$logger->log; // -> ['foo', 'bar']

We can try to do the same in Prototype:

var Logger = Class.create({ 
  initialize: function() { },
  log: [],
  write: function(message) {
    this.log.push(message);
  }
});


var logger = new Logger;
logger.log; // -> []
logger.write('foo');
logger.write('bar');
logger.log; // -> ['foo', 'bar']

It works. But what if we make another instance of Logger?

var logger2 = new Logger; 
logger2.log; // -> ['foo', 'bar']

// ... hey, the log should have been empty!

You can see that, although some of you expected an empty array in the new instance, we have the same array as the previous logger. In fact, all logger objects will share the same array object because it is copied by reference, not by value. The correct way is to initialize your instances to default values:

var Logger = Class.create({ 
  initialize: function() {
    // this is the right way to do it:
this.log = [];
  },
  write: function(message) {
    this.log.push(message);
  }
});

Defining class methods

There is no special support for class methods in Prototype 1.6.0. Simply define them on your existing classes:

Pirate.allHandsOnDeck = function(n) { 
  var voices = [];
  n.times(function(i) {
    voices.push(new Pirate('Sea dog').say(i + 1));
  });
  return voices;
}


Pirate.allHandsOnDeck(3);
// -> ["Sea dog: 1, yarr!", "Sea dog: 2, yarr!", "Sea dog: 3, yarr!"]

If you need to define several at once, simply use Object.extend as before:

Object.extend(Pirate, { 
  song: function(pirates) { ... },
  sail: function(crew) { ... },
  booze: ['grog', 'rum']
});

When you inherit from Pirate, the class methods are not inherited. This feature may be added in future versions of Prototype. Until then, copy the methods manually, but not like this:

var Captain = Class.create(Pirate, {}); 
// this is wrong!
Object.extend(Captain, Pirate);

Class constructors are Function objects and it will mess them up if you just copy everything from one to another. In the best case you will still end up overriding subclasses and superclass properties on Captain, which is not good.

Special class properties

Prototype 1.6.0 defines two special class properties: subclasses and superclass. Their names are self-descriptive: they hold references to subclasses or superclass of the current class, respectively.

Person.superclass 
// -> null
Person.subclasses.length
// -> 1
Person.subclasses.first() == Pirate
// -> true
Pirate.superclass == Person
// -> true
Captain.superclass == Pirate
// -> true
Captain.superclass == Person
// -> false

These properties are here for easy class introspection.

Adding methods on-the-fly with Class#addMethods()

Class#addMethods was named Class.extend in Prototype 1.6 RC0.
Please update your code accordingly.

Imagine you have an already defined class you want to add extra methods to. Naturally, you want those methods to be instantly available on subclasses and every existing instance in the memory! This is accomplished by injecting a property in the prototype chain, and the safest way to do it with Prototype is to use Class#addMethods:

var john = new Pirate('Long John'); 
john.sleep();
// -> ERROR: sleep is not a method

// every person should be able to sleep, not just pirates!
Person.addMethods({
  sleep: function() {
    return this.say('ZzZ');
  }
});


john.sleep();
// -> "Long John: ZzZ, yarr!"

The sleep method was instantly available not only on new Person instances, but on its subclasses and existing instances currently in memory.

No comments: