In Part 2, we have seen few advantages about using descriptors to define classes, let's see the example again:
// basic ES5 class definition // the constructor function Rectangle(width, height) { this.width = width; this.height = height; } Object.defineProperties( // enriching instead of replacing Rectangle.prototype, { // a generic method toString: { value: function () { return '[object Rectangle]'; } }, // a generic getter area: { get: function () { return this.width * this.height; } } }); // quick example var ret = new Rectangle(3, 2); '' + ret; // [object Rectangle] ret.area; // 6So far so good, right? Aren't we missing something? Uh right ...
Extending Via Descriptors
In an ideal world, which is hopefully coming soon thanks to ES6, we could simply relink a genericprototype
object instead of replacing it, something like:
// basic ES6 class extend // the constructor function Square(size) { Rectangle.call(this, size, size); } Object.defineProperties( // swap inheritance instead of // loosing the original prototype Object.setPrototypeOf( Square.prototype, Rectangle.prototype ), { // extra properties/overrides if needed toString: { value: function () { return '[object Square]'; } } }); // quick example var sqr = new Square(3); '' + sqr; // [object Square] sqr.area; // 9Unfortunately there is no
Object.setPrototypeOf
in ES5, while __proto__
does not appear even once in the specifications.This is why 5.1 compatible engines like duktape, as example, will not work using the dirty
__proto__
and cannot then hot-swap prototypes at runtime at all ... this was ES5.1
Extending the ES5 Way
In the previous post, Matías Quezada commented pointing out a couple of things, where the first one is about the need to reassign theprototype
when it comes to extend.However, what comes natural with ES5 is to redefine the original constructor as not enumerable, which is at least the closest behavior we would expect from a class.
// basic ES5 class extend // the constructor function Square(size) { Rectangle.call(this, size, size); } // the prototype redefined Square.prototype = Object.create( Rectangle.prototype, { // but with the right constructor // and in a non enumerable way constructor: { value: Square }, // extra properties/overrides if needed toString: { value: function () { return '[object Square]'; } } });Understanding the difference between these patterns is essential, but as developers, we also would like to simplify the task and here I am.
A Basic ES5 Class Utility
The most simple and basic utility we could think of will probably look like the following one:// simplifying the repeated pattern - v1 function Class(proto, descriptors) {'use strict'; var extending = descriptors != null, d = extending ? descriptors : proto, constructor = ( d.hasOwnProperty('constructor') ? d.constructor : d.constructor = {value: function Class(){}} ).value; return (extending ? (constructor.prototype = Object.create( typeof proto === 'function' ? proto.prototype : proto, d)) : Object.defineProperties(constructor.prototype, d) ).constructor; }Above code is quite compact and it provides the ability to rewrite our two example classes in this way:
var Rectangle = Class({ constructor: { value: function (width, height) { this.width = width; this.height = height; } }, toString: { value: function () { return '[object Rectangle]'; } }, area: { get: function () { return this.width * this.height; } } }); // two arguments to extend var Square = Class( Rectangle, { constructor: { value: function Square(size) { Rectangle.call(this, size, size); } }, toString: { value: function () { return '[object Square]'; } } });At least now we have a single way to define classes through descriptors ... what else could we do?
Descriptors VS Readability
The second point that Matías Quezada made in his comment was indeed about descriptor verbosity, which I believe goes back to the very first part of these posts:If we believe these are good assumptions:@WebReflection @antirez property descriptors are terrible :( ...
— TJ Holowaychuk (@tjholowaychuk) March 20, 2014
- we still want the ability to define getters and setters
- properties, if specified, are there to be marked as immutable, like constants, rather than defaults
- we want to use code that is less ambiguous as possible
- accordingly, properties with a shared default needs to be explicitly flagged as writable
- but function, for a class, will always be considered method of the class
var Rectangle = Class({ constructor: function (width, height) { this.width = width; this.height = height; }, toString: function () { return '[object Rectangle]'; }, area: { get: function () { return this.width * this.height; } } }); var Square = Class( Rectangle, { constructor: function (size) { Rectangle.call(this, size, size); }, toString: function () { return '[object Square]'; } });In order to reach above improved state, the original
Class
required some make up:
// simplifying the repeated pattern - v2 var Class = (function(){'use strict'; function descriptify(d, k) { return typeof d[k] === 'function' && (d[k] = {value: d[k]}), d; } function Class(proto, descriptors) { var d, extending = descriptors != null, d = Object.keys( d = extending ? descriptors : proto ).reduce(descriptify, d), constructor = ( d.hasOwnProperty('constructor') ? d.constructor : d.constructor = {value: function Class(){}} ).value; return (extending ? (constructor.prototype = Object.create( typeof proto === 'function' ? proto.prototype : proto, d)) : Object.defineProperties(constructor.prototype, d) ).constructor; } return Class; }());We are still around 238 bytes minzipped so it's not a big deal and probably the simplest
Class
you can play around defining methods or, when it's necessary and understanding how, shared properties through descriptors.If this is not enough, and we'd like to have a complete solution entirely based on ES5 and with extra patterns described in these posts too such the lazy reassignment, redefine.js is a good playground too, and you can see few examples and compare with above proposal which once again is very minimalistic, not so fancy in features, but surely fast, reliable and efficient, most likely all we need for our projects.
About Non Standard Behaviors
Warning
From now on, everything we'll explore will NOT be what we need to write on daily basis. Actually, most of the following alchemies are patterns that we'll never need in our life as coders.
However, being these post about telling you all things that books forgot to mention, I could not help myself going deeper in most dark and obscure details, proposing patters yu probably never cared about as the lazy assignment could be ... take the rest of this post as extra details, and keep thinking about what ES5 offers remembering this snippet
var Person = Class({ name: { // as default value: 'anonymous' } }); var me = new Person(); me.name = 'ag'; me.name; // anonymousWhat I haven't told you yet, and this is still an active discussion/concern in es-discuss, is that in V8 and current node.js, constructors have super powers
var Person = Class({ constructor: function (name) { // here it's writable anyway this.name = name; }, name: { // as default value: 'anonymous' } }); var me = new Person('ag'); me.name; // agWhy we might think that's cool and ideal, not only this behavior is not adopted by other JS engines, is also kinda pointless since usually properties set in the constructor don't need a default, these will be replaced in there anyway so ... how about we just don't specify the default if we set it during initialization anyway?
var Person = Class({ constructor: function (name) { this.name = name || 'anonymous'; } }); var me = new Person('ag'); me.name; // agIf we really need a default, the better thing we can do is to specify the property as writable.
var Person = Class({ constructor: function (name) { if (name) { this.name = name; } }, name: { writable: true, value: 'anonymous' } }); var me = new Person('ag'); me.name; // ag
The Dirty V8 Behavior
When I've talked about super powers, I didn't mention the super shenanigans too. As soon as something external "touches the freshly baked instance", the magical[[Set]]
behavior breaks again.
var WTF1 = Class({ constructor: function (tf) { this.what = tf; }, what: {value:'nope'} }); var WTF2 = Class({ constructor: function (tf) { this.what = tf; // so far, so good, right ? ... now // this is a perfectly legit operation Object.getOwnPropertyDescriptor(this, 'what'); }, what: {value:'nope'} }); var wtf1 = new WTF1('WTF'), wtf2 = new WTF2('WTF'); wtf1.what; // WTF wtf2.what; // nopeI do hope that this madness will be standardized somehow ... I mean, even just passing the instance to
Object
will break as well ... anyway ...
Getters And Setters Bug
Forget V8 and most modern node.js, this time it's Androind 2.x and webOS time!While the latter one is basically disappeared from the web scene, and I personally think it's a pity since my Pre 2 is almost fully ES5.1 spec compliant, better than any IE < 10, the first one is still widely used around the world, also quite cheap so usually a preferred choice in emerging markets.
As we can see in the dashboard, 20% or more is still a big amount of mobile Android platform users, and here the bug they'll be dealing with in ES5:
var hasConfigurableBug = !!function(O,d){ try { O.create(O[d]({},d,{get:function(){ O[d](this,d,{value:d}) }}))[d]; } catch(e) { return true; } }(Object, 'defineProperty');If
hasConfigurableBug
is false
, it means that a lazily assigned property can be reconfigured without problems:
var Unconfigurable = Class({ lazy: { get: function () { // here do amazing things // then reconfigure once the property // this.lazy = value; won't work // because we are inheriting get/set behavior // the only option is this one: Object.defineProperty(this, 'lazy', {value: // the assigned once property Math.random() }); return this.lazy; } } }); var rand = new Unconfigurable; rand.lazy; // 0.5108735030516982 rand.lazy; // still same value: // 0.5108735030516982However, if
hasConfigurableBug
is true
, that operation will throw an error saying that is not possible to configure a property that has a getter or setter.In few words, the bug is about
defineProperty
, for some reason unable to reconfigure what has been inherited, if this contains either a getter or a setter, or both.Unfortunately, this bug has been a widely adopted in old mobile WebKit, so back to the initial feature detection, here is how we could obtain the same behavior in these browsers too:
var Unconfigurable = Class({ lazy: { // needs eventually to be deleted later on configurable: hasConfigurableBug, get: function () { if (hasConfigurableBug) { var descriptor = Object.getOwnPropertyDescriptor( Unconfigurable.prototype, 'lazy'); // remove it ... delete Unconfigurable.prototype.lazy; } // ... so that this won't fail Object.defineProperty(this, 'lazy', {value:Math.random()}); if (hasConfigurableBug) { // "first time ever var makes sense" ^_^ Object.defineProperty( Unconfigurable.prototype, 'lazy', descriptor); } return this.lazy; } } }); var rand = new Unconfigurable; rand.lazy; // 0.5108735030516982 rand.lazy; // still same value: // 0.5108735030516982I know, this one is tough to digest, and that's why I've mentioned before redefine.js, however it's clear now how eventually solve the problem in a tiny feature detection that won't compromise performance.
There is still something we might want to do, partially to make latest snippet portable without accessing manually to a known prototype, partially to complete this 3rd post on descriptors ...
getPropertyDescriptor
Usually inheritance examples comes with 2 basic levels, but what if we inherited a behavior 3 or 4 levels up?Here a very simple utility that will return undefined, or an ancestor descriptor with a
object
property that will point to the ancestor itself, property borrowed from Object.observe
current proposal.
function getPropertyDescriptor(object, key) { do { // get the decriptor, if any var descriptor = Object.getOwnPropertyDescriptor( object, key); // otherwise if there is inheritance ... try again } while(!descriptor && ( object = Object.getPrototypeOf(object) )); if (descriptor) { // set the target object descriptor.object = object; } return descriptor; }With this utility, the previous snippet of code would look slightly better:
var Unconfigurable = Class({ lazy: { configurable: hasConfigurableBug, get: function () { if (hasConfigurableBug) { var descriptor = getPropertyDescriptor( this, 'lazy'); } Object.defineProperty(this, 'lazy', {value:Math.random()}); if (hasConfigurableBug) { Object.defineProperty( descriptor.object, 'lazy', descriptor); } return this.lazy; } } });To be brutally honest, in case we have multiple getters behavior redefined up the chain, above code won't solve much since there could be another descriptor up there ... oh well, I hope we'll never find ourself redefining lazy properties, instead of simple getters, more than once per inheritance chain.
In any case, for completeness, here the plural version of the function, where all descriptors are returned at once with all
object
references.
function getPropertyDescriptors(object) { var descriptors = {}, has = descriptors.hasOwnProperty; function assign(name) { if (!has.call(descriptors, name)) { descriptors[name] = getPropertyDescriptor(object, name); } } do { (Object.getOwnPropertyNames || Object.keys)(object).forEach(assign); } while(object = Object.getPrototypeOf(object)); return descriptors; }
2 comments:
I have read all 3 parts and to tell you the truth it does look bad.
What you came up at the end of part 3 (the Class function) is really really not what I have been expecting from JS. Looks exactly like the hacks from MooTools time (dead now, thank God).
I know you are strongly opinionated and do not give s*** about other's opinions, but just looking at that code you have used as example gives headache to everyone I show it to, including JS developers, let alone devs with other languages.
I have been using JS for the last 5 years exclusively. However for the last 3 months I had to write PHP (bad choice as well) and Dart. I am not looking back. Ever. Which means something coming from a person who deals exclusively with it and is consulting JS for living...
Peter I don't understand what is it that looks bad ... if you are talking about implementing the lazy assignment then a) you have no other way in ES6 to do that via syntax and b) it'a specific pattern not widely adopted that really should never be there if you don't need it.
The lazy assignment is not how you should write any getter or setter, is instead a very specify meant behavior that, if compatibility with old browsers is needed, needs some knowledge about how these behave.
What you should rather focus in here, is descriptors vs readability chapter classes examples, which is very clean and looks closer to what ES6 will come up with.
Last, but not least, if you want to write beautiful ES6 code like via ES5 and without needing to even think about these problems, redefine is just one options out of many.
I will underline that last snippet is absolutely not how we should write code though, it's very sad that's all you have left from these posts.
Post a Comment