Overcoming Asymmetric Prototype Property Access With Proxies In JavaScript
I don't use JavaScript proxies all that often in my day-to-day programming life. Proxies are very cool; but, they solve a set of problems that don't normally present in the business logic of applications. As such, proxies are mostly used by library and framework authors. In fact, much of the "magic" in modern JavaScript frameworks is based on the Proxy object. For example, in Alpine.js, the reactive "scope tree" is managed by proxies. And, as I was looking through the Alpine.js code this morning, I saw that they were using proxies to overcome the natural, asymmetric access of the prototype chain.
Alpine.js doesn't actually store their scope tree in a prototype chain (the way Angular.js did)—they store each scope chain in an array attached to a DOM node. But, the concept is very similar. And, it I thought it would be a fun concept to demo.
In JavaScript, inheritance mechanics are implemented using a prototype chain. Access to the values contained within this prototype chain is asymmetric. Meaning, when you read a value from a given object, JavaScript will walk up the object's prototype chain looking for an entry with the given key. However, when you wrote a value to a given object, JavaScript will write that value to the given object regardless of what exists higher-up in the prototype chain.
This behavior can be demonstrated by manually constructing a prototype chain and then overwriting the inherited values. In the following code, I'm creating a prototype chain composed of three objects: A
, B
, and C
. Each of these objects has a locally-defined property: a
, b
, and c
respectively. Once the prototype chain is constructed, I attempt to overwrite a
, b
, c
, and d
(a new property) and then log each of the respective prototype entries:
// Manually construct the prototype chain with a property set on each object.
var A = Object.create( null );
var B = Object.create( A );
var C = Object.create( B );
A.a = "Set on A";
B.b = "Set on B";
C.c = "Set on C";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
logPrototypeChain();
// Overwrite all values at the bottom of the prototype chain.
C.a = "Set on C";
C.b = "Set on C";
C.c = "Set on C";
C.d = "Set on C";
logPrototypeChain();
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
/**
* I log the individual prototype objects in the manually constructed chain.
*/
function logPrototypeChain() {
console.group( "Prototype Chain" );
console.log( A );
console.log( B );
console.log( C );
console.groupEnd();
}
When we run this JavaScript code and look at the console, we get the following output:
As you can see, the a
, b
, c
, and d
properties were all written directly to C
, which is the last entry in the prototype chain. In other words, we overwrote the properties from C
's perspective; but, left the inherited properties intact.
Generally speaking, this native JavaScript behavior is the desired behavior. It allows properties to be overwritten at a lower-level without destroying the prototype chain (and accidentally corrupting every other object that inherits from the same prototypes).
But, in some edge-cases, this asymmetric access is detrimental. To change the way this works, we can wrap our target (C
) in a Proxy
that intercepts the set
operation and applies it to the prototype chain entry that defined the original property:
// Manually construct the prototype chain with a property set on each object.
var A = Object.create( null );
var B = Object.create( A );
var C = Object.create( B );
A.a = "Set on A";
B.b = "Set on B";
C.c = "Set on C";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
logPrototypeChain();
// Overwrite all values at the bottom of the prototype chain (via a Proxy).
cProxy = WriteProxy( C );
cProxy.a = "Set on C (proxy)";
cProxy.b = "Set on C (proxy)";
cProxy.c = "Set on C (proxy)";
cProxy.d = "Set on C (proxy)";
logPrototypeChain();
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
/**
* I create a proxy for the given target that will apply SET operations to prototype that
* contains the given key. Or, will write to the bottom object if no key exists.
*/
function WriteProxy( target ) {
return new Proxy(
target,
{
set( target, key, value ) {
// Walk up the prototype chain to find the prototype that contains the
// given key as a local property. Will come back NULL if no object in the
// prototype chain contains the given key.
var container = getContainerWithOwnKey( target, key );
// Write to the appropriate prototype (or target if null prototype).
( container || target )[ key ] = value;
}
}
);
/**
* I walk up the prototype chain looking for the container with the given key.
*/
function getContainerWithOwnKey( object, key ) {
var container = object;
while ( container && ! hasOwnKey( container, key ) ) {
container = Object.getPrototypeOf( container );
}
return container;
}
/**
* I determine if the given object has a local property with the given name.
*/
function hasOwnKey( object, key ) {
return Object.prototype.hasOwnProperty.call( object, key );
}
}
/**
* I log the individual prototype objects in the manually constructed chain.
*/
function logPrototypeChain() {
console.group( "Prototype Chain" );
console.log( A );
console.log( B );
console.log( C );
console.groupEnd();
}
In this code, the Proxy
handler defines a single trap for set()
. Then, internally to the proxy, I'm using the Object.getPrototypeOf()
method to walk up the prototype chain looking for the entry that contains the given key. If I find one, that's the container that receives the "set". And, if I don't find one, I apply the "set" to the original target, C
.
And, when we run this JavaScript code, we get the following output:
As you can see, even though all of the overwrite assignments we applied to the cProxy
object, the A
and B
prototype chain objects were modified. This is because the Proxy
handler walked up the prototype chain and found the source of the given property before re-assigning its value.
In traditional JavaScript programming, you would never want to do this. But, I hope this illustrates how powerful the Proxy
object can be for framework and library authors. Being able to step-in and intercept all calls on a given value open up a whole new world of opportunity.
Want to use code from this post? Check out the license.
Reader Comments
Still befuddles me how Proxies are useful. The only use case I can think of is if I wanted to access control set operations or log object access for some reason, neither of which I've ever needed to do. But I really love that Proxies are possible. Super powerful tool, but not every carpenter needs a jackhammer ;)
@Chris,
💯💯💯 they are cool, but this is the second time I've ever even looked at them; and they've been in the language for ?? 8 years ??, something like that. I believe they're one of those things that are really just from framework authors - not for the normal developers.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →