March 31, 2009

Subclassing and Extending Array

Lately I'm spending some time coding some libraries in JS to achieve a solid & cross-browser framework. Not every browser out there supports the latest Javascript version, so a fallback solution is often needed. Some examples are the iterative methods in Array, as introduced in JS 1.7: every, filter, forEach, map, some, indexOf, lastIndexOf.

So, as extending a built-in is often a bad idea, the direction taken was to create a subclass of Array and extend this one. No wonder it's a hard path, but we can walk it on our own.

Subclassing is the key

A simple and easy solution would be something on these lines:

var ArrayExt = function() { }
ArrayExt.prototype = new Array;

But as JS gurú Dean Edwards said in his post, it seems easy doesn’t it?

Yes, in fact it was so easy because it's the wrong solution.

var v = new ArrayExt;
v.push(100);
alert(v.length); // => 1 in **MOST** browsers

Yeah, in MOST browsers. What does this mean? Well... You know what? It doesn't work in IE, hehehe. Quite funny, isn't it? No, really, please act as if this were the one and only IE bug... IE shows an alert with a 0 in it, so we need another solution. And Dean Edwards found a great one in 2006 using an iframe as a sandbox.

IFrame to the rescue

The idea is basically that the fresh new iframe will have a copy of the Array object on it. But it will be a different object than the original one on the main document. Now we can get this object and extend it instead of subclassing.

Verbatim Dean solution:

// create an iframe
var iframe = document.createElement("iframe");
iframe.style.display = "none";
document.body.appendChild(iframe);

// write a script into the iframe and steal its Array object
frames[frames.length - 1].document.write(
  "<script>parent.Array2 = Array;<\/script>"
);

Minor final changes

Now we have an object called Array2 that we can extend by changing its prototype and Array will not be modified. Isn't that neat? Well, yeah. I made some modifications and generalize it a little:

function clone_built_in(object_name) {
  var iframe = document.createElement('iframe');
  iframe.style.display = 'none';
  document.body.appendChild(iframe);
  var frame = frames[frames.length - 1];
  !frame.eval && frame.execScript && frame.execScript('null');
  var ret = frame.eval(object_name);
  document.body.removeChild(iframe);
  return ret
}

// Now you can get an Array clone
Array2 = clone_built_in('Array');
// And extend it
Array2.prototype.forEach = function() ...

execScript is an IE extension needed to call frame.eval, and now, the script works in every browser. But I'm not in the mood to force all browsers to make this kind of thing. Only nasty browsers should be punished in this way, so here comes some cross-browser object detection feature "ala jQuery.support":

var Array2 = (function(){
  var array_class = function(){ }
  array_class.prototype = new Array;
  var obj = new array_class;
  obj.push(0);
  return obj.length === 1 ? array_class : clone_built_in('Array')
})();

Voila! It's a kind of magic, isn't it?

I hope you've found something useful in this entry!

Iterative Methods from JS 1.6

And now as an extra bonus, some JS 1.6 standard functions for those nasty browsers living in the old days:

!Array2.prototype.every && (Array2.prototype.every = function(fn) {
  for(var i = 0, len = this.length; i < len; ++i) {
    if(!fn(this[i], i, this)) { return false }
  }
  return true
});
!Array2.prototype.forEach && (Array2.prototype.forEach = function(fn) {
  for(var i = 0, len = this.length; i < len; ++i) {
    fn(this[i], i, this)
  }
});
!Array2.prototype.filter && (Array2.prototype.filter = function(fn) {
  var ret = [];
  for(var i = 0, len = this.length; i < len; ++i) {
    fn(this[i], i, this) && (ret[ret.length] = this[i])
  }
  return ret
});
!Array2.prototype.indexOf && (Array2.prototype.indexOf = function(value, startIndex) {
  for(var i = startIndex || 0, len = this.length; i < len; ++i) {
    if(this[i] == value) { return i }
  }
  return -1
});
!Array2.prototype.lastIndexOf && (Array2.prototype.lastIndexOf = function(value, startIndex) {
  for(var i = startIndex || this.length - 1; i >= 0; --i) {
    if(this[i] == value) { return i }
  }
  return -1
});
!Array2.prototype.map && (Array2.prototype.map = function(fn) {
  var ret = [];
  for(var i = 0, len = this.length; i < len; ++i) {
    ret[i] = fn(this[i], i, this)
  }
  return ret
});
!Array2.prototype.some && (Array2.prototype.some = function(fn) {
  for(var i = 0, len = this.length; i < len; ++i) {
    if(fn(this[i], i, this)) { return true }
  }
  return false
});