Spelunking in Decoratorland

There's a rumour of a suggestion of a thought of a concept about these things called decorators. All being well they'll land in ES7 or ES8, depending on how things pan out. I kind of do hope they pan out, because I think they're rather useful.

  • Last updated: 9 Jul 2015
  • Est. Read Time: 8 min
  • Tagged: #decorators, #esfuture, #yay

Decorator pitch #

If you've not had chance to look into them yet (wouldn't blame you, they're highly experimental and new) they're a way to annotate a function. The annotation is then called as a function, the parameters for which describe the function you annotated. This, in turn, gives you a way to silently wrap and supercharge the original function:

class Person {

@deprecated
getName () {
// ...
}
}

function deprecated (target, name, descriptor) {
// Supercharge the getName function.
}

The key is that @deprecated decorator there. In magical Decoratorland the deprecated function below would be called and passed getName and we would have the chance to wrap it so if a developer called it it would perhaps fire off a console.warn.

That's one example, but there are more. As it happens, Addy Osmani has you covered with a detailed breakdown of various kinds, and I'd definitely suggest checking out Yehuda Katz's explainer, since he, you know, created the whole thing. Both well worth your time.

Elevator pitch #

Okay, let's blast past the other stuff, assuming you have the general gist of decorators. What if we had a decorator that tries to protect you from Layout Thrashing? Let's say you have something like this:

class SampleController {

constructor () {
this.writeSomeStuff();
this.readSomeStuff();
this.writeSomeStuff();
this.readSomeStuff();
}

readSomeStuff () {
console.log('read some DOM properties');
}

writeSomeStuff () {
console.log('write some stuff to the DOM; change styles');
}
}

In Traditionalsville that's going to come out like write, read, write, read, which, if those were DOM operations, would be the dictionary definition of Layout Thrashing.

Instead, let's annotate functions that read layout properties of DOM elements (like offsetWidth) with a @read annotation, and those that mutate the DOM in some way with @write. What would that look like?

// Import the decorators.
import { read, write } from '../libs/ReadWrite/ReadWrite';

class SampleController {

constructor () {
this.writeSomeStuff();
this.readSomeStuff();
this.writeSomeStuff();
this.readSomeStuff();
}

@read
readSomeStuff () {
console.log('read some DOM properties');
}

@write
writeSomeStuff () {
console.log('write some stuff to the DOM; change styles');
}
}

With these decorators attached we'd expect to get read, read, write, write, and your expectations would be entirely justified!

This all may sound entirely like Wilson Page's excellent FastDOM library, and it should: it's a very similar concept. There's also a Google library that does the same thing, so it's all in good company.

I think they all raise an important question, though: doesn't they change execution order, and isn't that unpredictable? Yes, they do, and that makes it more difficult to debug and understand your code. The annotation is no worse than FastDOM or any other library doing the same thing, but if function A relies on B then you'd have to be super careful. Ideally speaking you would structure your code such that reads happen first, then writes, and then use the annotation to act as your guarantor.

For the morbidly curious, here's a rough snapshot of what the decorator code looks like for @read:

export function read (target, name, descriptor) {

// Take a copy of the original function that would have
// been called. Bind it to the target.
let readerOriginal = descriptor.value;
let reader = readerOriginal.bind(target);

// Now redefine the function.
descriptor.value = function (...args) {

// If the context has changed since the initial setup
// update the reference to reader.
if (this !== target) {
reader = readerOriginal.bind(this);
target = this;
}

// Push the job onto an array, and schedule rAF.
jobLists.read.push({
reader, args
});

// The rAF callback will process all
// read functions, then writes.
scheduleJobRAFIfNeeded();
}
}

If the function in question were supposed to return something we may have a problem here, because it will be set to run in the next frame. You could work around this by returning a Promise which the original function could resolve, but it definitely changes the mechanics of the code.

Supercharging the decorator #

Even though you've rearranged reads and writes to their appropriate places, what happens if someone accidentally slips an offsetTop query into a @write function? That breaks the neatness of the whole "don't read when writing" thing. Rearranging the call order gets us only so far!

Well, we can solve this dilemma by hijacking Element's prototype to warn when you call one of those "dangerous" properties when you shouldn't:

// Just imagine this being done for every
// "dangerous" property.
let descriptor = Object.getOwnPropertyDescriptor(
Element.prototype, 'offsetTop');

if (!descriptor)
return;

let fn = descriptor.get;

if (!fn)
return;

// Redefine the getter to throw a warning.
descriptor.get = function () {
let warning = `DOM queried (${getter} getter) during write block`;
let e = new Error(warning);
console.warn (e.stack);
return fn.apply(this);
};

// Now update the prototype.
Object.defineProperty(Element.prototype,
'offsetTop', descriptor);

If you call one of the "dangerous" properties the code will throw a warning, which I grant you could be a full-on error if I was feeling mean. (I'm not.) This concept came from Dimitri Glazkov, who created nope.js, the very essence of which is to throw errors if you write when you should read and vice-versa. Standing on the shoulders of giants here, folks.

Equally, when @reading, we want to avoid all DOM mutations, so we can add a MutationObserver for that specific case:

// Create a mutation observer and watch everything.
// I mean everything.
var updateObserver = new MutationObserver(_ => null);
var updateObserverConfig = {
attributes: true,
childList: true,
characterData: true,
subtree: true
};

// Really, everything.
updateObserver.observe(document.body,
updateObserverConfig);

The slight snag here is that mutations come in asynchronously, so if you've moved onto something else (like finished doing your @reads) and disconnected the MutationObserver you may lose pending mutations. But lo, there is a function that can save us: takeRecords()! Call that sucker on the MutationObserver and it will hand you any mutations it hasn't had chance to tell you about. Yay!

#ifdefs are your friend #

Finally, before wrapping this up, I wanted to share something I discovered, which others may already know, but I did not.

What I wanted to do was to only throw the read / write warnings when you're in development, not production. In something like C++ you'd have #ifdef which, okay, it's a bit bleh, but it does the job. Here I wanted something similar: if you're in production I want to bail on the code that would throw the warnings and hijack Element's prototype because nobody wants that in production.

The solution I chose was to use envify, which will do a transformation on your code in a similar way to Babel. Here's the code from the gulpfile:

// Envify -> Babelify -> Browserify -> WINNIFY!!!
return browserify({
entries: [url],
debug: isDevelopmentVersion
})
.transform(babelify.configure({
optional: ["es7.decorators"]
}))
.transform(envify({
IS_PROD: !isDevelopmentVersion
}));

The IS_PROD call is going to be found inside the code that Babelify has transformed, and it will get baked down to whatever value you've passed through. In this case I just use a variable that indicates whether this is prod or dev.

// Return early if we're in production.
if (process.env.IS_PROD)
return;

What's next? #

Short answer: I don't know.

I'd like to ultimately figure out a way of merging it with FastDOM, because it has an existing developer base, and Wilson has done an excellent job with it. But for now this works. I'm also pondering whether or not the behavior of rescheduling should be separated from the warning mechanism, so you can have warnings without any change to execution order, but it's not as simple to set up as the current decorators, so I guess we'll see...

In any case, please do tell me what you think of the whole idea!

Some thanks #

Always standing on the shoulders of giants, me. <3'z