In the last week I've been exploring the additions to Properties that ECMAScript 5 provides. There are tons of articles around about this, like this one by John Resign, and this time I don't feel like copy pasting here what others properly explain. The MDN documentation about defineProperty, getOwnPropertyDescriptor and so on is an essential reference.
One basic idea that I'd like to highlight is that as JavaScript objects behave like dictionaries we tend to think of them as pairs formed by a string key and a reference to the value. Well, even in EcmaScript 3, this is not that simple. We don't have a reference to the value, but a reference to an object that in turn contains a reference to that value and some additional fields acting as metadata [[enumerable]]... EcmaScript 5 gives us (read/write) access to those metadata, and adds getters/setters to the mix (bear in mind that we can have 2 types of properties: data properties (have a value), and accesor properties (have a getter-setter pair of functions)
When reading the defineProperty documentation and realizing that we can redefine an already existing property, a useful and obvious application occurred to me, property interception. We could redefine an already existing property (either a data or an accessor one) replacing it with an accessor property that should somehow have access to the old property. The most interesting of this is that this can be applied to the object itself, not needing the creation of a new proxy object as we do in Java or .Net with dynamic proxies(or with the proxies proposed for ECMAScript 6).
The implementation below defines closures for the getters and setters, trapping the old property descriptor if it already was an accessor property, or the store for the value if it was a data property.
//@obj: object being intercepted //@key: string name of the property being intercepted //@beforeGet, afterGet; functions with this signature: function(key, safeDesc) //@beforeSet, afterSet; functions with this signature: function(key, newValue, safeDesc) //these 4 interception functions expect to be launched with "this" pointing to the object's this //warning, you can't do a this[key] inside those functions, we would get a stack overflow... //that's the reason why we're passing that safeDesc object //the beforeGet function could want to modify the returned value, if it returns something, we just return it and skip the normal get and afterGet //the beforeSet function could want to prevent the set action, if it returns true, it means it want to prevent the assignation //We're providing the afterGet functionality though indeed I can't think of any useful application for it function interceptProperty(obj, key, beforeGet, afterGet, beforeSet, afterSet){ var emptyFunc = function(){}; beforeGet = beforeGet || emptyFunc; afterGet = afterGet || emptyFunc; beforeSet = beforeSet || emptyFunc; afterSet = afterSet || emptyFunc; //skip functions from interception if (obj[key] === undefined || typeof obj[key] == "function"){ return; } var desc = Object.getOwnPropertyDescriptor(obj, key); if (desc.get || desc.set){ console.log("intercepting accessor property: " + key); Object.defineProperty(obj, key, { get : desc.get ? function(){ //if the beforeGet function returns something, it's that value what we want to return //well, in fact we could apply that logic of changing the value to return in the afterGet function better than in the beforeGet var result = beforeGet.call(this, key, desc); result = result || desc.get.call(this); afterGet.call(this, key, desc); return result; }: undefined, set : desc.set ? function(newValue){ //if the beforeSet function returns something, use it instead of newValue newValue = beforeSet.call(this, key, newValue, desc) || newValue; desc.set.call(this, newValue); afterSet.call(this, key, newValue, desc); }: undefined, enumerable : true, configurable : true }); } else{ console.log("intercepting data property: " + key); var _value = obj[key]; desc = { get : function(){ return _value; }, set : function(newValue){ _value = newValue; } }; Object.defineProperty(obj, key, { get : function(){ _value = beforeGet.call(this, key, desc) || _value; afterGet.call(this, key, desc); return _value; }, set : function(newValue){ _value = beforeSet.call(this, key, newValue, desc) || newValue; afterSet.call(this, key, newValue, desc); }, enumerable : true, configurable : true }); } }
You'll use it this way (we're intercepting the property setting, so that we can modify some of the values that are going to be set:
var _beforeSet = function _beforeSet(key, newValue, safeDesc){ if(newValue == "julian"){ console.log("rewriting value to assign"); return "iyán"; } }; interceptProperty(p1, "name", null, null, _beforeSet);
So we have 4 interceptor functions: beforeGet, afterGet, beforeSet and afterSet. I can't think of any useful application of afterGet, but well, I prefer to provide the option just in case. beforeGet and afterGet will be called with the object where the property is being accessed as "this", and 2 parameters, a string representing the key, and a descriptor like object, with a get and set method that give us access to the value stored in the property, bypassing the get-set (this is essential, as if we were doing this[key] we would get a stack overflow (the beforeGet would call the getter and the getter the beforeGet and so on). beforeSet and afterSet add one more parameter, the new value being set. Returning a value from beforeGet or beforeSet tell our getter or setter that we want to change its behaviour, either return that returned value instead of the stored value, or setting that value rather than the new value provided to the setter.
You can grab the code here and here
Update The version above, with the beforeGet-afterGet, beforeSet-afterSet function pairs, resulted in a rather unnatural usage pattern. I've rewritten it to use a single getInterceptor, setInterceptor function. The code is now in github, and also provides method interception
A very important point to notice with Properties is that contrary to most of the JavaScript 5 additions (Function.bind, Array.forEach...) there's no way to simulate them for older engines (the code in this es5 shim makes a nice read), so you'll have to think it twice before adding them to your code.
Another important point to note is that we can't add custom attributes to properties, we can only just the defined ones (value, get, set, writable, enumerable, configurable). So forget about adding a "description" attribute or similar, you won't get an error, but it won't be added. Furthermore, each time we invoke getOwnPropertyDescriptor, it returns a new object representing the descriptor, not the descriptor itself stored in the Object. I guess the reason for that is that a property descriptor is not stored as a normal object, but as some sort of optimized structure, so when asked for it, ECMAScript has to map it to a conventional object. This means that this expression is false:
Object.getOwnPropertyDescriptor(o, "sayBye") == Object.getOwnPropertyDescriptor(o, "sayBye");
It's quite a pity, cause I had started to envision how elegant it could be to use custom property attributes to simulate advanced Object Oriented enums. That would have been a revision of what I wrote some months ago.
No comments:
Post a Comment