Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Properly building Javascript proxy set handlers for arrays

What is the correct way to build Javascript proxies for arrays so that 'set' handlers do not get invoked multiple times for a single change to the array?

Here is what I mean:

I want to wrap a simple array in a Proxy object. I want a 'set' handler to run when I wish to push() a new value to this Proxy object.

The trouble is that proxy handlers like 'set' get called multiple times for one operation to an array. In some cases it seems fairly easy to deal with the problem but in other cases one call to modify an array wrapped in a Proxy object cases the set handler to be called at least one time for every element.

Suppose I created the simplest Proxy handler object and Proxy like this:

let proxyHandlerObj = {
  set:function(target,property,value,receiver) {
    console.log("Set handler is invoked on property:",property,"with value:",value);
    /*
     * Important and interesting things done here
     */
    return (target[property] = value);
  }
};

let proxyArray = new Proxy(["zero","one","two"],proxyHandlerObj);

This proxy is just intercepting 'set-like' calls to my proxied array and writing a message to console. Now, when I add a new element to the end of my proxyArray object:

proxyArray.push("three")

I'll get something like this:

Set handler is invoked on property: 3 with value: three
Set handler is invoked on property: length with value: 4

I see that the set handler got called twice: once for the creation of a new element in the array and once more for setting the new length property of the array.

Ok, this issue can be handled by checking for the property being manipulated. I've seen set properties done something like this:

set:function(target,property,value,receiver) {
  if(property!="length") {
    console.log("Set handler is invoked on property:",property,"with value:",value);
    /*
     * Important and interesting things done here
     */
  }
  return (target[property] = value);
}

The same call to proxyArray.push("three") will perform the important things on all but the length property. This is because I'm checking if the length property is being set. This seems ok to me.

But, suppose I want to simply splice() something out of my array?:

proxyArray.splice(0,1);

That produces one 'set' invocation for every element in the array:

Set handler is invoked on property: 0 with value: one
Set handler is invoked on property: 1 with value: two
Set handler is invoked on property: 2 with value: three

This is certainly not what I wanted. I wanted my set handler to run once on the 'splice()', not three times.

What is more, there's a very nasty side-effect of having 'set' methods triggered multiple times for the same splice() operation. Looking at the contents of the array by changing the 'set' handler to this:

set:function(target,property,value,receiver) {
  if(property!="length") {
    console.log("Set handler is invoked on property:",property,"with value:",value);
    /*
     * Important and interesting things done here
     */
  }
  let result = (target[property] = value);
  console.log(JSON.stringify(target));
  return result;
}

Will yield this:

Set handler is invoked on property: 0 with value: one
["one","one","two","three"]
Set handler is invoked on property: 1 with value: two
["one","two","two","three"]
Set handler is invoked on property: 2 with value: three
["one","two","three","three"]
["one","two","three"]

So, Javascript is shifting each value down the array, one at a time, then popping off the last, duplicate element as the last step. Your set handler would have to be built to handle that sort of intermediate duplication.

That would seem to yield nasty and complicated 'set' handlers.

So, what is the proper way to build Javascript proxies wrapped around arrays so that 'set' handlers do not get invoked an unwanted multiple of times and the target object is reliable in the process?

like image 338
TColbert Avatar asked Aug 06 '17 04:08

TColbert


1 Answers

The ultimate problem with what's going on is that the proxy is explicitly handling the .splice() call (meaning, all the operations that involve pulling an element out of the array, reindexing that array, and modifying the length property will all go through the proxy--which is intended behavior if you call proxyArray.splice() because the context of splice will be the proxy and not the underlying array).

The solution to the problem is to allow splice to "fall through" to the underlying array, thereby bypassing the proxy. In order to do this we need to add a get trap to the proxy and listen for when we are calling splice so that we can make sure it happens on the array itself.

const proxyHandlerObj = {
    set(tgt, prop, val, rcvr) {
        if (prop !== 'length') {
            /*
             * Important and interesting things done here
             */
        }
        return (tgt[prop] = val);
    },

    get(tgt, prop, rcvr) {
        if (prop === 'splice') {
            const origMethod = tgt[prop];

            return function (...args) {
                /*
                 * Do anything special here
                 */
                origMethod.apply(tgt, args);
            }
        }
        return tgt[prop];
    }
};

So a couple things about this:

  • calling proxyArray.splice() with whatever your arguments are will return a function that calls the method which we plucked off the original array (we have to bind it to the array because we are calling this method through the proxy meaning the this will be pointing to that proxy and our behavior will be different) with all the arguments passed through
  • I recommend you place here any of the special logic you wanted your set trap to do; you could technically do something to manually invoke the set trap and trigger your logic, but it would be better to call that logic here to keep code less convoluted and more modular
  • this will completely bypass the set trap, but the proxy will reflect all your changes to the underlying array
  • you can change prop === 'splice' with a check for any of the properties you want to "bleed" through to the original object, so push()/shift()/etc. can be handled in this same manner
like image 169
Ryan Dabler Avatar answered Oct 23 '22 13:10

Ryan Dabler