Motla Motla - 2 months ago 4
Javascript Question

Getters/setters for Object properties in an Array

I have an

Array of Objects
:

var boxes = [
{ left: 10, top: 20 }, // boxes[0]
{ left: 100, top: 200 } // boxes[1]
];

boxes[0].left = 20; // example of 'set' action


I want to be able to define generic getters or setters for every property of
boxes
object items, even if further items are added, like this:
boxes.push({ left: 30, top: 40 });


In this way, the final goal is to implicitely append an
addListener
function to every object item, which binds a listener function to the setter of the property. Something like this:

boxes[0].addListener("left", function(new_value){
console.log("left has been changed to "+new_value);
});

boxes[2].addListener("top", ...


Assuming every
boxes[i]
item would be populated like this:

{
left: 10,
top: 20,
set left(val){ /* would trigger every left listener */ },
set top(val){ /* would trigger every top listener */ },
addListener: function(prop, func){ /* ... */ }
}


Of course those listeners/getters/setters have to be implemented dynamically (i.e. compatible with the Array.push() function).

And "icing on the cake" is that the
boxes
array should stay parsable with JSON.stringify(), so:

JSON.stringify(boxes); // would return [{"left":10,"top":20},{"left":100,"top":200}]


I guess it should be possible with some combination of Proxies and Object.defineProperty() or something but I can't make it...

Any ideas from a JS guru?

Answer

Finally I found an elegant solution after long trials and errors.

1. First we override Object.prototype (general functions to every Object/Array) with some generic addListener(prop, listener) and removeListener(prop, listener) functions that basically register the given listener to an "invisible" __listeners__ array:

Object.defineProperty(Object.prototype, "addListener", {
    value: function(prop, listener){
        if(!("__listeners__" in this)){
            Object.defineProperty(this, "__listeners__", {value: {}, writable: true});
        }
        if(!(prop in this.__listeners__)) this.__listeners__[prop] = [];
        if(this.__listeners__[prop].indexOf(listener) == -1){
            this.__listeners__[prop].push(listener);
            return true;
        } else return false;
    }
});
Object.defineProperty(Object.prototype, "removeListener", {
    value: function(prop, listener){
        var index = this.__listeners__[prop].indexOf(listener);
        if(index > -1){
            this.__listeners__[prop].splice(index, 1);
            return true;
        } else return false;
    }
});

2. Now we want to detect any "set" action of any property/sub-property of the boxes object. To achieve this, we need to set-up a recursive use of Proxies.

Basically, a Proxy is an image of a given Object/Array. By default, every action made on the Proxy is also made on the Object/Array, and vice-versa:

var boxes = [
    { left: 10, top: 20 }, // boxes[0]
    { left: 100, top: 200 } // boxes[1]
];

var boxes_proxy = new Proxy(boxes);

boxes_proxy[0].left = 20;

console.log(boxes_proxy[0].left); // 20
console.log(boxes[0].left); // 20

Now the difference is that every action made on a Proxy can be detected and overridden.
In the case of boxes_proxy[0].left = 20;, the detected action is divided in two parts :

  1. boxes_proxy[0].: the detected action on boxes by its Proxy is "get property 0", which returns boxes[0] as the default "get" handler.

  2. .left = 20; the left property of boxes[0] is set to 20 but it's not detected as it's not the same Object. To detect it, we have to create another Proxy on boxes[0].

There comes the interesting part. To solve this issue for any sub-level of object property (for example boxes[0].foo.bar[2].font.color), we can create recursiveGetter and genericSetter functions, in a way that the recursiveGetter returns a Proxy instead of its corresponding Object/Array and binds any "set" action to the genericSetter function which triggers the listeners:

var recursiveGetter = function(real_object, prop){
    if(typeof real_object[prop] == "object"){
        var prop_proxy = new Proxy(real_object[prop], {
            get: recursiveGetter,
            set: genericSetter
        });
        return prop_proxy;
    } else return real_object[prop];
}

var genericSetter = function(real_object, prop, value){
    real_object[prop] = value;
    if("__listeners__" in real_object && prop in real_object.__listeners__){
        real_object.__listeners__[prop].forEach(function(fct){ fct(value); });
    }
    return true;
}

So now we just have to apply these getter/setter on a new Proxy(boxes) and they will be applied recursively:

var boxes_proxy = new Proxy(boxes, { get: recursiveGetter, set: genericSetter });

boxes_proxy[0].addListener("left", function(new_value){
    console.log("left has been changed to "+new_value);
});

boxes_proxy[0].left = 20; // "left has been changed to 20"

JSON.stringify(boxes); // [{"left":20,"top":20},{"left":100,"top":200}]

Since the added Objects/Functions are not enumerable, JSON.stringify is still applicable.

Comments