Search This Blog

Saturday, July 4, 2009

Extending JavaScript Objects and Classes

Extending JavaScript Objects and Classes

Summary

  • You can dynamically create properties and methods of existing objects through simple assignment.
  • Using the prototype property of intrinsic JS Objects, you can extend the functionality of the very objects you know and love in ways that can make your coding far easier.

Table of Contents

Background — Objects in JS

In JavaScript, objects can have properties dynamically added to them. [This comes as no surprise to JS programmers who have written something like myObject.backgroundColor='black' or myObject.style.backgroundcolor='black' instead of the correct myObject.style.backgroundColor='black' and consequently pulled out their hair for hours trying to figure out why the background color wasn't changing. The answer to "Why isn't this working?!" (i.e. "Because you coded it wrong") isn't nearly so enlightening as the information that it looked like it was working because the JavaScript engine was happily creating a new (meaningless) property of the object and setting it to the string value 'black'.]

For the curious, this feature of JS is possible because the language is evaluated at run-time, and because all Objects are implemented as hash tables. This also explains why it's possible to refer to the same property either directly or as a string:

var spacing = myTable.cellSpacing;
var spacing = myTable['cellSpacing']; //equally as valid

and why you can create property names that no reasonable compiler would ever accept, such as:

myTable['% What stupid name!']=someValue;

That JS auto-creates and references previously-undefined properties can be dangerous, as mentioned above—the JS interpretter never yells at you if you attempt to read the value of, or go ahead and set the value of a property that just doesn't exist. On the other hand, this can be a real boon over compiled programming. For example, assume you are a web developer and you want to keep track of how many times the user changes the value of a certain text input (say, the quantity of items ordered). With a traditional compiled OOP language you'd need to subclass the input object and create a custom flavor that allows a timesChanged property. With JavaScript, you simply write:

if (myInput.timesChanged==null) myInput.timesChanged=1;
else myInput.timesChanged+=1;

and the JS Interpretter creates that property for that object instance on the fly.

Compared to those who know that you can create custom properties for any object on the fly, fewer know that you can create custom methods for objects just as easily and in virtually the same way. For example, the following is legal JavaScript code:

function Poke(){
  alert('Owch! Stop that!');
}
myTable.poke = Poke; // When passing a function as a pointer, do not use the () after the name

myTable.poke();      // Using the parentheses invokes the method.

myTable.yellAttributes=function(){ //This is known as creating an anonymous function on the fly
  var atts = "border:"+this.border
             +"; cellpadding:"+this.cellPadding
             +"; cellspacing:"+this.cellSpacing;
  alert(atts);

}
myTable.yellAttributes();

Extending an object by adding a custom method can be quite convenient, but it only applies to that particular object's instance. What if you wanted to modify the entire existing class to add new functionality? For this, we use the prototype property.

The prototype Property

To add a property or method to an entire class of objects, the prototype property of the object class must be modified. The intrinsic object classes in JavaScript which have a prototype property are:

  • Object.prototype — Modifies both objects declared through the explicit new Object(...) contructor and the implicit object {...} syntax. Additionally, all other intrinsic and user-defined objects inherit from Object, so properties/methods added/modified in Object.prototype will affect all other intrinsic and user-defined objects.
  • Array.prototype — modifies arrays created using either the explicit new Array(...) constructor or the implicit [...] array syntax.
  • String.prototype — modifies strings created using either the explicit new String(...) constructor or the implicit "..." string literal syntax.
  • Number.prototype — modifies numbers created using either the explicit new Number(...) constructor or with inline digits.
  • Date.prototype — modifies date objects created with either the new Date(...) contructor.
  • Function.prototype — modifies functions created using either the explicit new Function(...) constructor or defined inline with function(...){...}.
  • RegExp.prototype — modifies regular expression objects created using either the explicit new RegExp(...) constructor or the inline /.../ syntax.
  • Boolean.prototype — applies to boolean objects created using the explicit new Boolean(...) constructor or those created using inline true|false keywords or assigned as the results of a logical operator.

Adding properties or methods to the prototype property of an object class makes those items immediately available to all objects of that class, even if those objects were created before the prototype property was modified.

It is with great sadness that I must point out that Internet Explorer does not inherit its DHTML objects from Object, and I can find no way to modify the prototype for the class of any of its web-based objects. (For example, window.constructor is an empty property on IEWin6, whereas Mozilla 1.2 reports that Object is the constructor of the window object.) This is why above the terms "instrinsic" and "user-defined" are used to qualify the object types. If you want to make a custom method for all textareas on a page for IEWin, you need to extend the object using Microsoft's proprietary behaviors. For example, you would specify the following CSS rule textarea { behavior:url(extendArea.htc) }

Note that adding a public property to a class of objects creates a single value which all instance objects share. However, modifying this value through this.globalPropertyName will result in a local public property of the object being created and set. Modifications to the class-wide property must be made through the prototype property of the class. This is demonstrated in the following code snippet:


Person.prototype.populationCount=0;
function Person(name,sex){

  Person.prototype.populationCount++;
  this.getName=function(){ return name }
  this.getSex=function(){ return sex }

  this.setSex=function(newSex){ if (confirm('Really change the sex of "'+name+'" to '+newSex+'?')) sex=newSex; }

  this.kill=function(){ Person.prototype.populationCount-- }
}
var gk = new Person('Gavin','male');

var lrk = new Person('Lisa','female');

//Following yields "There are 2 people in my world."

alert("There are "+gk.populationCount+" people in my world.");

//Following creates a new public property of 'gk' and sets it to 102
gk.populationCount+=100;


var geo = new Person('George','male');
alert('GK thinks there are '+gk.populationCount+' people, but everyone else knows there are '+lrk.populationCount+' people.');

//Above yields "GK thinks there are 102 people, but everyone else knows there are 3 people."

If you use private properties (e.g. 'sex' above) in your object, you need private methods to access them (e.g. getName() above). Note that these private methods eat up a lot of memory with each instantiation of the object, and so it's often better to be 'sloppy' by allowing your properties to be publically accessible, so that the accessor methods can be shared throughout the class's .prototype, saving memory.

Following are a bunch of examples of useful ways to extend various intrinsic objects using the prototype property.

Example 1 — Adding slice() to Arrays

The slice() method of an array object returns a subsection of the array. While quite useful, this method was not part of the original ECMAScript specification, and not supported by all JavaScript interpretters. When writing code which may be run on older browsers where you'd like to slice some arrays, you can either write code which doesn't use this convenient method (an annoying approach) or you can roll your own implementation of slice().

To do this propertly, we need to know exactly what the slice() method does. From MSDN:

arrayObj.slice(start,[end]); "The slice method copies up to, but not including, the element indicated by end. If start is negative, it is treated as length + start where length is the length of the array. If end is negative, it is treated as length + end. If end is omitted, extraction continues to the end of arrayObj. If end occurs before start, no elements are copied to the new array."

Thus armed, following is a custom implementation of the slice() method. By using Array.prototype this method is made available to all array objects. Note that even if the following isn't the most efficient code possible, it will only be used on those few browsers where the slice() method isn't available. (As it turns out, the implementation below is almost identical in speed to the built-in method.)

//Only add this implementation if one does not already exist.

if (Array.prototype.slice==null) Array.prototype.slice=function(start,end){

  if (start<0) start=this.length+start; //'this' refers to the object to which the prototype is applied

  if (end==null) end=this.length;
  else if (end<0) end=this.length+end;
  var newArray=[];
  for (var ct=0,i=start;i<end;i++) newArray[ct++]=this[i];
  return newArray;

}

As you can see, the first three lines change the parameters passed in to values useful for the for loop, and then this method simply creates a new array, copies the desired items over one at a time, and then returns that new array as the result of the method. (Like most methods in JScript, the original object is not modified.)

Examples of other useful methods which may not be present in older JS implementations that you may want to add to the Array object are pop(), push(), and concat().

Example 2 — Date Formatting

Outputting a lot of dates to the browser, and want to format them nicely? The following code adds a customFormat() method to the Date class which allows you to have a Date object turn into a string formatted just the way you like. Simply include this small block of code (in a separate client-side library file, directly in your JS ASP page...whatever) and all date objects will suddenly be able to cleanly format themselves.

Date.prototype.customFormat=function(formatString){

  var YYYY,YY,MMMM,MMM,MM,M,DDDD,DDD,DD,D,hhh,hh,h,mm,m,ss,s,ampm,dMod,th;
  YY = ((YYYY=this.getFullYear())+"").substr(2,2);
  MM = (M=this.getMonth()+1)<10?('0'+M):M;
  MMM = (MMMM=["January","February","March","April","May","June","July","August","September","October","November","December"][M-1]).substr(0,3);
  DD = (D=this.getDate())<10?('0'+D):D;
  DDD = (DDDD=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"][this.getDay()]).substr(0,3);
  th=(D>=10&&D<=20)?'th':((dMod=D%10)==1)?'st':(dMod==2)?'nd':(dMod==3)?'rd':'th';
  formatString = formatString.replace("#YYYY#",YYYY).replace("#YY#",YY).replace("#MMMM#",MMMM).replace("#MMM#",MMM).replace("#MM#",MM).replace("#M#",M).replace("#DDDD#",DDDD).replace("#DDD#",DDD).replace("#DD#",DD).replace("#D#",D).replace("#th#",th);

  h=(hhh=this.getHours());
  if (h==0) h=24;
  if (h>12) h-=12;
  hh = h<10?('0'+h):h;
  ampm=hhh<12?'am':'pm';
  mm=(m=this.getMinutes())<10?('0'+m):m;
  ss=(s=this.getSeconds())<10?('0'+s):s;
  return formatString.replace("#hhh#",hhh).replace("#hh#",hh).replace("#h#",h).replace("#mm#",mm).replace("#m#",m).replace("#ss#",ss).replace("#s#",s).replace("#ampm#",ampm);

}
var now=new Date();
alert("Today is "+now.customFormat('#DDDD#, #MMMM# #D##th#')+"\nThe time is "+now.customFormat('#h#:#mm##ampm#')+".");

Example 3 — Number Formatting

This example extends the Number class to support a few methods for attractive formatting. Notice how it extends the String class with a new method for inserting a substring.

Number.prototype.toCurrency=function(noFractions,currencySymbol,decimalSeparator,thousandsSeparator){

  var n,startAt,intLen;
  if (currencySymbol==null) currencySymbol="$";
  if (decimalSeparator==null) decimalSeparator=".";
  if (thousandsSeparator==null) thousandsSeparator=",";
  n = this.round(noFractions?0:2,true,decimalSeparator);
  intLen=n.length-(noFractions?0:3);
  if ((startAt=intLen%3)==0) startAt=3;
  for (var i=0,len=Math.ceil(intLen/3)-1;i<len;i++)n=n.insertAt(i*4+startAt,thousandsSeparator);
  return currencySymbol+n;

}
Number.prototype.toInteger=function(thousandsSeparator){
  var n,startAt,intLen;
  if (thousandsSeparator==null) thousandsSeparator=",";
  n = this.round(0,true);
  intLen=n.length;
  if ((startAt=intLen%3)==0) startAt=3;
  for (var i=0,len=Math.ceil(intLen/3)-1;i<len;i++)n=n.insertAt(i*4+startAt,thousandsSeparator);
  return n;

}
Number.prototype.round=function(decimals,returnAsString,decimalSeparator){
  //Supports 'negative' decimals, e.g. myNumber.round(-3) rounds to the nearest thousand

  var n,factor,breakPoint,whole,frac;
  if (!decimals) decimals=0;
  factor=Math.pow(10,decimals);
  n=(this.valueOf()+"");         //To get the internal value of an Object, use the valueOf() method

  if (!returnAsString) return Math.round(n*factor)/factor;
  if (!decimalSeparator) decimalSeparator=".";
  if (n==0) return "0."+((factor+"").substr(1));
  breakPoint=(n=Math.round(n*factor)+"").length-decimals;
  whole = n.substr(0,breakPoint);
  if (decimals>0){

     frac = n.substr(breakPoint);
     if (frac.length<decimals) frac=(Math.pow(10,decimals-frac.length)+"").substr(1)+frac;
     return whole+decimalSeparator+frac;
  }else return whole+((Math.pow(10,-decimals)+"").substr(1));

}

String.prototype.insertAt=function(loc,strChunk){
  return (this.valueOf().substr(0,loc))+strChunk+(this.valueOf().substr(loc))

}

var quantity=1056;
var costPer=3.9;
var totalCost=quantity*costPer;
alert(quantity.toInteger()+" items at "+costPer.toCurrency()+" per item amounts to a total of "+totalCost.toCurrency());

//Yields "1,056 items at $3.90 per item amounts to a total of $4,118.40"

For a far terser, faster, and general purpose number formatter, see Number.prototype.format.js.

Example 4 — Boolean XOR

JavaScript provides a boolean AND operator (&&), a boolean OR operator (||), and a boolean NOT operator (!). But it is missing a boolean XOR operation. (In English, XOR can be stated as "If A is true or B is true, but not if both are true.") The following simple code adds an XOR() method to the Boolean object.

Boolean.prototype.XOR=function(bool2){
  var bool1=this.valueOf();
  return (bool1==true && bool2==false) || (bool2==true && bool1==false);
  //return (bool1 && !bool2) || (bool2 && !bool1);

}
true.XOR(false); //returns a value of true

(The above method requires the passed value to be an actual boolean value to succeed. The second option, commented out, will attempt to cast bool2 to a boolean value for the comparison. If that line were used instead, values of 0, null, '' and undefined would be interpretted as false, and other non-empty values such as 1 or "foo" will be interpretted as a value of true.)

Example 5 — Extending Arrays to Support Set Mathematics

The following code is presented as an example of a non-trivial expansion to the Array object which allows it to support Set Mathematics. It also shows a case where prototype can be used to overwrite existing implementations. (In this case, some browsers have an incorrect version of Array.splice() that doesn't return single-item arrays, but instead returns the item itself.)

//This JavaScript library is copyright 2002 by Gavin Kistner and Refinery, Inc.

//Reuse or modification permitted provided the previous line is included.
//mailto:gavin@refinery.com
//http://www.refinery.com/

/***************************************************************************************************
* JavaScript Array Set Mathematics Library
* version 1.2.1, April 26th, 2002  [IEMac5.1-/IEWin5.0-/OldNS .splice() replacement works properly]

*
* Methods: array1.union( array2 [,compareFunction] )
*          array1.subtract( array2 [,compareFunction] )

*          array1.intersect( array2 [,compareFunction] )
*          array1.exclusion( array2 [,compareFunction] )

*          array1.removeDuplicates( [compareFunction] )
*
*          array1.unsortedUnion( array2 [,compareFunction] )

*          array1.unsortedSubtract( array2 [,compareFunction] )
*          array1.unsortedIntersect( array2 [,compareFunction] )

*          array1.unsortedExclusion( array2 [,compareFunction] )
*          array1.unsortedRemoveDuplicates( [compareFunction] )

*
*
* Notes:   All methods return a 'set' Array where duplicates have been removed.
*
*          The union(), subtract(), intersect(), and removeDuplicates() methods
*          are faster than their 'unsorted' counterparts, but return a sorted set:
*          var a = ['a','e','c'];
*          var b = ['b','c','d'];
*          a.unsortedUnion(b)  -->  'a','e','c','b','d'

*          a.union(b)          -->  'a','b','c','d','e'

*
*          Calling any of the methods on an array whose element pairs cannot all be
*          reliably ordered (objects for which a < b, a > b, and a==b ALL return false)
*          will produce inaccurate results UNLESS the (usually) optional
*          'compareFunction' parameter is passed. This should specify a custom
*          comparison function, as required by the standard Array.sort(myFunc) method
*          For example:
*          var siblings = [ {name:'Dain'} , {name:'Chandra'} , {name:'Baird'} , {name:'Linden'} ];
*          var brothers = [ {name:'Dain'} , {name:'Baird'} ];
*          function compareNames(a,b){ return (a.name < b.name)?-1:(a.name > b.name)?1:0 }

*          var sisters=siblings.unsortedSubtract(brothers, compareNames);
*
***************************************************************************************************/


if (Array.prototype.splice && typeof([0].splice(0))=="number") Array.prototype.splice = null;

if (!Array.prototype.splice) Array.prototype.splice = function(ind,cnt){

  var len = this.length;
  var arglen = arguments.length;
  if (arglen==0) return ind;
  if (typeof(ind)!= "number") ind = 0;
  else if (ind<0) ind = Math.max(0,len+ind);
  if (ind>len){

     if(arglen>2) ind=len;
     else return [];
  }
  if (arglen<2) cnt = len-ind;
  cnt = (typeof(cnt)=="number") ? Math.max(0,cnt) : 0;
  var removeArray = this.slice(ind,ind+cnt);
  var endArray = this.slice(ind+cnt);
  len = this.length = ind;
  for (var i=2;i<arglen;i++) this[len++] = arguments[i];
  for (var i=0,endlen=endArray.length;i<endlen;i++) this[len++] = endArray[i];
  return removeArray;

}

//*** SORTED IMPLEMENTATIONS ***************************************************
Array.prototype.union=function(a2,compareFunction){
  return this.concat(a2?a2:null).removeDuplicates(compareFunction);

}
Array.prototype.subtract=function(a2,compareFunction){
  if (!compareFunction) compareFunction=null;
  var a1=this.removeDuplicates(compareFunction);
  if (!a2) return a1;
  var a2=a2.removeDuplicates(compareFunction);
  var len2=a2.length;
  if (compareFunction){

     for (var i=0;i<a1.length;i++){
        var src=a1[i],found=false,src;
        for (var j=0;j<len2&&compareFunction(src2=a2[j],src)!=1;j++) if (compareFunction(src,src2)==0) { found=true; break; }

        if (found) a1.splice(i--,1);
     }
  }else{

     for (var i=0;i<a1.length;i++){
        var src=a1[i],found=false,src;
        for (var j=0;(j<len2)&&(src>=(src2=a2[j]));j++) if (src2==src) { found=true; break; }

        if (found) a1.splice(i--,1);
     }
  }

  ret

No comments: