10

I've been looking into JavaScript frameworks such as Angular and Meteor lately, and I was wondering how they know when an object property has changed so that they could update the DOM.

I was a bit surprised that Angular used plain old JS objects rather than requiring you to call some kind of getter/setter so that it could hook in and do the necessary updates. My understanding is that they just poll the objects regularly for changes.

But with the advent of getters and setters in JS 1.8.5, we can do better than that, can't we?

As a little proof-of-concept, I put together this script:

(Edit: updated code to add dependent-property/method support)

function dependentProperty(callback, deps) {
    callback.__dependencies__ = deps;
    return callback;
}

var person = {
    firstName: 'Ryan',
    lastName: 'Gosling',
    fullName: dependentProperty(function() {
        return person.firstName + ' ' + person.lastName;
    }, ['firstName','lastName'])
};

function observable(obj) {
    if (!obj.__properties__) Object.defineProperty(obj, '__properties__', {
        __proto__: null,
        configurable: false,
        enumerable: false,
        value: {},
        writable: false
    });
    for (var prop in obj) {
        if (obj.hasOwnProperty(prop)) {
            if(!obj.__properties__[prop]) obj.__properties__[prop] = {
                value: null,
                dependents: {},
                listeners: []
            };
            if(obj[prop].__dependencies__) {
                for(var i=0; i<obj[prop].__dependencies__.length; ++i) {
                    obj.__properties__[obj[prop].__dependencies__[i]].dependents[prop] = true;
                }
                delete obj[prop].__dependencies__;
            }
            obj.__properties__[prop].value = obj[prop];
            delete obj[prop];
            (function (prop) {
                Object.defineProperty(obj, prop, {
                    get: function () {
                        return obj.__properties__[prop].value;
                    },
                    set: function (newValue) {
                        var oldValue = obj.__properties__[prop].value;
                        if(oldValue !== newValue) {
                            var oldDepValues = {};
                            for(var dep in obj.__properties__[prop].dependents) {
                                if(obj.__properties__[prop].dependents.hasOwnProperty(dep)) {
                                    oldDepValues[dep] = obj.__properties__[dep].value();
                                }
                            }
                            obj.__properties__[prop].value = newValue;
                            for(var i=0; i<obj.__properties__[prop].listeners.length; ++i) {
                                obj.__properties__[prop].listeners[i](oldValue, newValue);
                            }
                            for(dep in obj.__properties__[prop].dependents) {
                                if(obj.__properties__[prop].dependents.hasOwnProperty(dep)) {
                                    var newDepValue = obj.__properties__[dep].value();
                                    for(i=0; i<obj.__properties__[dep].listeners.length; ++i) {
                                        obj.__properties__[dep].listeners[i](oldDepValues[dep], newDepValue);
                                    }
                                }
                            }
                        }
                    }
                });
            })(prop);
        }
    }
    return obj;
}

function listen(obj, prop, callback) {
    if(!obj.__properties__) throw 'object is not observable';
    obj.__properties__[prop].listeners.push(callback);
}

observable(person);

listen(person, 'fullName', function(oldValue, newValue) {
    console.log('Name changed from "'+oldValue+'" to "'+newValue+'"');
});

person.lastName = 'Reynolds';

Which logs:

Name changed from "Ryan Gosling" to "Ryan Reynolds"

The only problem I see is with defining methods such as fullName() on the person object which would depend on the other two properties. This requires a little extra markup on the object to allow developers to specify the dependency.

Other than that, are there any downsides to this approach?

JsFiddle

mpen
  • 272,448
  • 266
  • 850
  • 1,236
  • The downside would be that you have to write this. Why would you do that when there are libraries that do it for you? – Ash Burlaczenko Feb 10 '13 at 09:16
  • 1
    @AshBurlaczenko: Call it a learning exercise. – mpen Feb 10 '13 at 09:22
  • On a side note, [KnockoutJS](http://knockoutjs.com/) works similarly, only it doesn't utilize the JS 185 features AFAIK. – Jeroen Feb 10 '13 at 09:48
  • 1
    I would argue that this was simply not a design goal of JS. Allowing this type of publish/subscribe model is certainly powerful, but also would lead to plenty of badly architected code. If you want to act on a certain property changing, it is easy enough to make a method that changes that property and calls a callback without baking that functionality into constantly polling set of timers. Handling this behavior case-by-case using an event-driven model seems to me like it would force you to write more efficient code where only the proper cases are handled. – Cecchi Feb 10 '13 at 10:24
  • 1
    Sorry for the long-winded comment, but after rereading your question it looks like my suggestion is exactly what you've done. I don't think from an architectural standpoint you can improve too much. +1 – Cecchi Feb 10 '13 at 10:30
  • 1
    If you haven't done so yet, I suggest you take a look at this detailed explanation of AngularJS' dirty checking approach: http://stackoverflow.com/a/9693933/1126869 – Chasseur Feb 10 '13 at 12:28
  • 1
    @Chasseur: I did. Issues #2 and #3 seem to the same, and I think they can easily be solved in the exact same way Angular does it -- just delay calling the handlers until the event has completed. Just because you've been notified of the change, doesn't mean you have to update the DOM immediately. The first point I don't even understand. My "person" object is nearly a POJO with the exception of dependent properties which need a tiny bit of extra markup. – mpen Feb 10 '13 at 17:45
  • @Mark: I did not really get the dependent method problem. Do your methods have listeners which get notified if the *result* of your method changes? Also, please simplify your code by using descriptive shortcut variables instead of repeated, longish property lookups. Maybe I could then understand the `dependents` thing even without a further explanation. – Bergi Feb 10 '13 at 19:14
  • @Bergi: I'll look into making it a bit more readable; each of those long variables is only used once or twice though. And yes, the example I gave shows exactly that -- I put a listener on `fullName` which is a method, then changed `lastName` and the `fullName` listener was called. Essentially, if you use that `dependentProperty` function then if any of the properties listed in there are modified, then the listeners for the method will be called too. It doesn't actually evaluate if the method result has changed, it kind of just assumes it has if the dependents do. But that's easily fixed. – mpen Feb 10 '13 at 19:26
  • @Mark: Ah, OK, I wouldn't say that `fullname` is a method - it's rather another property with a function that describes the dependency. On the long lines: I can see `obj.__properties__[prop]` used nine times in one function :-) It would certainly help to shorten that. – Bergi Feb 10 '13 at 19:46
  • @Bergi: True... I was referring the whole thing, but yes, I guess I could have shortened the beginning part :-) – mpen Feb 10 '13 at 19:47

1 Answers1

1

advent of getters and setters in JS 1.8.5 - are there any downsides to this approach?

  • You don't capture any property changes apart from the observed ones. Sure, this is enough for modeled entity objects, and for anything else we could use Proxies.
  • It's limited to browsers that support getters/setters, and maybe even proxies. But hey, who does care about outdated browsers? :-) And in restricted environments (Node.js) this doesn't hold at all.
  • Accessor properties (with getter and setter) are much slower than real get/set methods. Of course I don't expect them to be used in critical sections, and they can make code looking much fancier. Yet you need to keep that in the back of your mind. Also, the fancy-looking code can lead to misconceptions - normally you would expect property assignment/accessing to be a short (O(1)) operation, while with getters/setters there might be a lot of more happening. You will need to care not forgetting that, and the use of actual methods could help.

So if we know what we are doing, yes, we can do better.

Still, there is one huge point we need to remember: the synchronity/asynchronity (also have a look at this excellent answer). Angular's dirty checking allows you to change a bunch of properties at once, before the event fires in the next event loop turn. This helps to avoid (the propagation of) semantically invalid states.

Yet I see the synchronous getters/setters as a chance as well. They do allow us to declare the dependencies between properties and define the valid states by this. It will automatically ensure the correctness of the model, while we only have to change one property at a time (instead of changing firstName and fullName all the time, firstName is enough). Nevertheless, during dependency resolving that might not hold true so we need to care about it.

So, the listeners that are not related to the dependencies management should be fired asynchronous. Just setImmediate their loop.

Community
  • 1
  • 1
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • (1) "Angular's dirty checking allows you to change a bunch of properties at once, before the event fires in the next event loop turn" -- I addressed this a couple times already. There's no need to fire the event handlers immediately, we can just flag the property as dirty and fire all the handlers at the end, the same as Angular does it but without the need to check every property. (2) For the most part we should know exactly which properties need to be observed because we have to parse the template to set up the watches on those variables anyway. There might be a couple we missed due to... – mpen Feb 10 '13 at 20:31
  • ...non-standard usage, but that's more or less the same as Angular again. You have to manually add a watch/listener in those cases. (3) Why would the setters not be `O(1)`? Assuming we delay execution of the handlers until the end of the event, we just have to push the property into a set of "dirty" properties and then handle it later, which only adds a small constant-time overhead, which should be less than the bigger cost of checking *everything* after every event. – mpen Feb 10 '13 at 20:34
  • 1
    On (2): I'm not familiar with Angular, and don't know its exact workings - I just thought that with polling you could detect other properties as well. My point was not dedicated to templating frameworks, but more general - it's not applicable everywhere of course. (3) Yes, if you delay the execution of the setters and just set dirty flags, you *can* make it a constant overhead. However my point was rather about the risk that the programmer who uses the simple assignment (expecting O(1)) does not exactly know what happens behind the scenes (or even might not know about it at all). – Bergi Feb 10 '13 at 21:26
  • On (1): Your current code seems lack to this delayed execution, the listeners are fired within the setter. Not sure whether you addressed it already, I may have missed that, so I wanted to mention it :-) – Bergi Feb 11 '13 at 12:22
  • Nope, you're right. Current code does execute immediately. Not sure I want to pursue this any further; building a full framework is a bit redonkulous :-) – mpen Feb 11 '13 at 18:23