Extending Objects with JavaScript Getters (original) (raw)
Most browsers are coalescing around a consistent API for defining JavaScript Getters and Setters. I’m not entirely comfortable with custom getters and setters – JavaScript’s clean syntax is now a little murkier, and we have a new pitfall to avoid when iterating and cloning object properties, not to mention a significant risk of involuntary recursion – but still I’ll admit they have their uses.
I’m going to publish a more in depth article on getters and setters in a few weeks, in which I’ll document the risks and workarounds more fully, but today I’m going to demonstrate a positive usage – a lightweight utility that uses JavaScript Getters to endow regular Objects with Array-like capabilities. Lets start with a very brief syntax overview:
The Basics
JavaScript Getters and Setters are functions that get invoked when an object’s property is accessed or updated.
var rectangle = {height:20, width:10};
rectangle .defineGetter("area", function() {
return rectangle.height * rectangle.width;
});
rectangle .defineSetter("area", function(val) {
alert("sorry, you can't update area directly");
});
rectangle.area; //200 rectangle.area = 150; //alerts "sorry..." etc. rectangle.area; //still 200
There’s also an alternative, more declarative syntax that looks prettier but does not allow getters and setters to be assigned dynamically once the object has been created. Moreover I find it less expressive in terms of the JavaScript object model – think function expression vs. function declaration:
var rectangle = {
height:20,
width:10,
get area() {
return rectangle.height * rectangle.width;
},
set area(val) {
alert("sorry, you can't update area directly");
}
}
ECMA 5 defines a similar syntax for defining getters and setters via the Object.defineProperty
function.
var rectangle = { width: 20, height: 10, };
Object.defineProperty(rectangle, "area", { get: function() { return this.width*this.height; }, set: function(val) { alert("no no no"); } });
Finally there’s a couple of methods you’re sure to need. They let us know which properties are represented by getters or setters. They are as fundamental to object recursion as our old friend hasOwnProperty
:
rectangle.lookupGetter("area"); //area Getter function rectangle.lookupSetter("area"); //area Setter function rectangle.lookupGetter("width"); //undefined rectangle.lookupSetter("width"); //undefined
Oh, I should mention this syntax is not supported for IE<9. Ok, now for the fun part:
Use Case: Making Objects work with Array.prototype functions
Much of the ECMAScript 5 API is designed to be generic. If your object supplies certain requisite properties, JavaScript will at least attempt to invoke the function. Most functions defined by Array.prototype are generic. Any regular object that defines properties for the relevant indexes and length
gets a crack at the Array API (note that an object is, by definition, unordered so that even if we get to make it work like and array, indexing consistency is not guaranteed)
The brute force approach
First lets see what happens when we try to simply add these properties directly:
//Bad example - apply array properties directly var myObj = { a: 123, b: 345, c: 546, }
//iterate properties and assign each value to indexed property
var index = 0;
for (var prop in myObj) {
myObj[index] = myObj[prop];
index++;
}
myObj.length = //??????
Whoops there’s at least two problems here. First we are adding properties even as we iterate, risking an infinite loop. Second we just doubled the number of properties. Does that mean length is now 6? That’s not want we wanted at all. The indexed properties should be virtual not physical – they should merely be alternate views over the original properties. A perfect job for…
The Getter approach
This seems more promising. We can easily assign a getter for the array-like properties:
function extendAsArray(obj) { var index = 0; for (var prop in obj) { (function(thisIndex, thisProp) { obj.defineGetter(thisIndex, function() {return obj[thisProp]}); })(index, prop) index++; }; obj.defineGetter("length", function() {return index}); return obj; }
Lets try it out…
var myObj = { a: 123, b: 345, c: 546, }
extendAsArray(myObj);
myObj[1]; //345 myObj.length; //3 myObj[2] == myObj.c; //true
OK much better – now dare we try a function from Array.prototype?
[].slice.call(myObj,1); //[345, 546]
It worked!, but wait…
re-running the extend function
Our new properties are only accurate so long as our object’s state does not change. If we update the object’s properties we will need to run our extend function again:
myObj.d = 764; extendAsArray(myObj); myObj.length; 8!!??
Why did the length suddenly double? Because our function is iterating every property and second time around that includes our shiny new getters. We need to modify the function so that the iteration skips getters. We can do this with the built-in __lookupGetter__
function:
function extendAsArray(obj) { var index = 0; for (var prop in obj) { if(!obj.lookupGetter(prop)) { (function(thisIndex, thisProp) { obj.defineGetter(thisIndex, function() {return obj[thisProp]}); })(index, prop) index++; } }; obj.defineGetter("length", function() {return index}); return obj; }
objects that already define the length
property
Turns out there’s still one more problem. What if we try running a function (which is, after all an object) through our extend function?
extendAsArray(alert); //TypeError: redeclaration of const length
Functions (and arrays) are two types of object that already define a length
property and they won’t take kindly to you trying to redeclare it. In any case we don’t want (or need) to extend these types of objects. Moreover some regular objects may also have been initially defined with a length
property – we should leave these alone too. In fact the only time its ok for our function to overwrite an existing length property is when that property is a getter.
finally!
Here is our function updated accordingly:
function extendAsArray(obj) { if (obj.length === undefined || obj.lookupGetter('length')) { var index = 0; for (var prop in obj) { if(!obj.lookupGetter(prop)) { (function(thisIndex, thisProp) { obj.defineGetter(thisIndex, function() {return obj[thisProp]}); })(index, prop) index++; } }; obj.defineGetter("length", function() {return index}); } return obj; }
OK, lets put it through its paces…
Practical applications of extendAsArray
general showcase
Consider an object that positions and sizes a lightbox, or similar:
var myObj = { left:50, top:20, width:10, height:10 }
Let’s extend this object and subject it to a broad swathe of the array prototype. We’ll cache an array instance to cut down on object creation.
extendAsArray(myObj);
var arr = []; arr.join.call(myObj, ', '); //"50 ,20 ,10, 10" arr.slice.call(myObj, 2); [10,10] arr.map.call(myObj,function(s){return s+' px'}).join(', '); //"50px ,20px ,10px, 10px" arr.every.call(myObj,function(s){return !(s%10)}); //true (all values divisible by 10) arr.forEach.call(myObj,function(s){window.console && console.log(s)}); //(logs all values)
By the way, array’s toString
is also supposed to be generic as of ECMA 5 but does not work generically in any of my browsers.
summarizing numerical data
Now this looks like your latest expense account:
var expenses = { hotel: 147.16, dinner: 43.00, drinks: 15.20, taxi: 13.00, others: 12.15 }
…using extendAsArray
we can concisely obtain the biggest expense and also sum the expenses:
extendAsArray(expenses); var biggestExpense = Math.max.apply(null, [].slice.call(expenses)); //147.16 var totalExpenses = [].reduce.call(expenses, function(t,s){return t+s}); //230.51
prototype overview
Prototypes are regular objects too. So, for example, we can easily return an array containing all functions in JQuery’s fx
prototype:
var fxP = extendAsArray(jQuery.fx.prototype); //make an array of all functions in jQuery.fx.prototype [].filter.call(fxP, function(s){ return typeof s == "function" }); //(6 functions)
what about setters?
It would be handy to also define setters for array’s must-have properties. We could automatically update the array-like properties every time state is added, and we would also be able to support array’s writeable API (e.g. push, shift etc.). Unfortunately, since it’s not possible to anticipate which index properties the user will attempt to update, using current browser implementations we would have to write a setter for every index from 1 to infinity! As I understand it, mozilla has discussed an upcoming feature that would allow object creators to intercept all property updates with a default function – but not sure how far along that got.
etc.
And that about wraps it up. There are hundreds more uses for such array-compliant objects. Those of you familiar with JQuery no doubt already take advantage of a similar construct, but I hope this ultra-compact version serves to demonstrate that for all the headaches, JavaScript Getters may yet bring us a little joy too. More about those headaches, and a more in depth analysis of getters and setters coming in a future article.
Further reading
MDC – Defining Getters and Setters
ECMA-262 5th Edition 15.2.3.6 Object.defineProperty