Understanding JavaScript Proxies by Examining on-change Library

March 31, 2018 0 Comments

Understanding JavaScript Proxies by Examining on-change Library

 

 

Photo by Pankaj Patel on Unsplash

Javascript Proxies are a new addition in ES6. It’s a powerful feature that can be used for solving various problems elegantly. We are going to examine and re-create a small utility library by Sindre Sorhus called on-change. The aim is to conceptually understand JavaScript Proxies, and in the process, build something so that the concepts are reinforced.

I have tried to keep things as simple as possible.However, a little familiarity with JavaScript language is expected.

So what does on-change do? It’s a small utility that watches an object or array for changes. Let’s see a code sample to make things clear —

Demo Code from on-change GitHub repo

A few things to note —

  • onChange is a function that takes two parameters: the object to watch, and the function to run when a change is encountered in the said object. It returns an object.
  • On line 17, when we set foo to true, the logger function is called.
  • On line 20, when we set an object that is deeply nested, even within arrays, logger is called. That is, it works recursively, so it will even detect if we modify a deep property like watchedObject.a.b[0].c = true.

So let’s see how we can use JavaScript Proxies to re-create this utility! But before that, let’s study about proxies.

Consider this short code snippet —

const someObject = { prop1: 'Awesome' };
console.log(someObject.prop1);  // Awesome
console.log(someObject.prop2);  // undefined

If we do someObject.prop1 we are going to get Awesome. But if we do someObject.prop2 we are going to get undefined because prop2 does not exist on someObject.

Let’s say we want to return a default value every time a non-existent property is accessed. That is, someObject.prop2 should give Oops! This property does not exist instead of undefined. How can we achieve this without modifying or adding new properties to someObject?

Welcome proxies! The Oxford English Dictionary defines a proxy as the authority to represent someone else. That is exactly what a Proxy in JavaScript is. Proxies are part of ES6, and they enable us to intercept operations(such as setting a value or deleting a property) performed on objects. While accessing an object’s property, this is what happens —

Normal Execution of Program

When using a Proxy, things change a little bit.

With Proxy

As you can see from the above diagram, Proxy sits between the object and the program, mediating the exchange of values. The Proxy can check the object for a property key, and if it does not exist, it can send its own response too.

Having shown how awesome proxies are, let’s see how we can create them in JavaScript. But before that, a few terms that you should be familiar with —

  • target — The object for which we will be making the proxy.
  • traps — a fancy term for the operations that we will intercept. For example, accessing a property is called get trap. Setting a value to a property is called a set trap. Deleting a property from an object is called deleteProperty trap. There are many traps. You can see all of them here.
  • handler — The object which contains all the traps, along with their descriptions.

The first thing we need is an object for which we are creating the proxy. Let it be this —

const originalObject = { firstName: 'Arfat', lastName: 'Salman' };

Now, we need to think of what traps we are going to intercept. Right now, we are going to intercept the get trap. The trap will live in the handler. So let’s create it.

const handler = {
get(target, property, receiver) {
console.log(GET ${property});
return target[property];
}
};

A few things to note —

  • The handler is a normal object.
  • The traps are functions (or methods) which are part of the handler. The names of the traps are fixed and predefined.
  • The get trap receives three parameters: target, property, receiver.
  • The target is the original object for which we created the proxy.
  • The property is the name of the property that is being accessed.
  • The receiver is either the proxy or an object that inherits from the proxy.

Now, we need to combine the handler and the originalObject. We do this by using the Proxy constructor.

const proxiedObject = new Proxy(originalObject, handler);

The entire code should look like —

Code Demo for proxy

You can run it in the browser’s console, or keep it in a file and run it using node (version ≥ 7 ). Here’s a sample run —

Now, if you log the firstName property of the proxiedObject

console.log(proxiedObject.firstName);
//=> GET firstName
//=> Arfat

We are going to get two logs. If you get same outputs as above, that means everything was set up correctly.

Now, let’s modify the handler to handle non-existent properties.

const newHandler = {
get(target, property, receiver) {
console.log(GET ${property});
  if (property in target) {
return target[property];
}
return 'Oops! This property does not exist.';
}
};

Focus on the bold parts. We check whether the property exists on the target. If it exists, we return its value. Otherwise, we return Oops! This property does not exist..

Now, if you do —

console.log(proxiedObject.thisPropertDoesNotExist);
// => GET thisPropertyDoesNotExist
// => Oops! This property does not exist.

you will not get undefined but the a custom response string.

It should be noted that for operations which do not have any traps defined, they are passed to the target normally as if the proxy did not exist.

With the understanding of how proxies work, we are set to recreate the on-change library. As discussed above, onChange is a function that takes two parameters: the object to watch, and the function which will be executed on every change in the object. Let’s make a function, then —

const onChange = (objToWatch, onChangeFunction) => { };

It doesn’t do anything much right now.

Let’s state the problem again: We want to run onChangeFunction whenever objToWatch is changed, that is, either a property is accessed/retrieved, or a new property is added, or a property is deleted.

It seems clear that we are going to use proxies to intercept operations on the object. So let’s return a proxy in the onChange function with an empty handler. Since the handler does not specify any traps, all operations are transparently passed to the target object, that is, objToWatch.

const onChange = (objToWatch, onChangeFunction) => { 
const handler = {};
  return new Proxy(objToWatch, handler);
};

Let’s focus on “when a property is accessed/retrieved” since we understood the get trap above. So, if in the get trap, we call the onChangeFunction, before returning the property’s value, we should be able to do achieve partly what on-change library does. Let’s code it and see —

const onChange = (objToWatch, onChangeFunction) => { 
const handler = {
get(target, property, receiver) {
onChangeFunction(); //
Calling our function
return target[property];
}

};
return new Proxy(objToWatch, handler);
};

This seems about right. Let’s run it before proceeding further —

Yayy! It’s working! 🔥

So we have accomplished one part of the statement. Let’s now focus on when “a new property is added, or a property is deleted”. Since we’ve already laid the groundwork, we just need to add more traps to accomplish the remaining functionality. The trap for setting a property or modifying its value is set. Let’s add that in the handle

const onChange = (objToWatch, onChangeFunction) => { 
const handler = {
get(target, property, receiver) {
onChangeFunction();
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
onChangeFunction();
return Reflect.set(target, property, value);
}

};
return new Proxy(objToWatch, handler);
};

set receives 4 parameters: the extra one is the value that is being set. We are using Reflect because it gives us a programmatic way of manipulating an object. It’s not that different from obj.name = 'Arfat' type of property setting. You can read here why it’s better to use Reflect API. You can read more about Reflect API here.

Since we are using Reflect API, I’m going to replace target[property] with its equivalent Reflect function in the get trap as well.

In a similar way, if we want to intercept deletion of a property, we can do so by the deleteProperty trap. Let’s add that to the handler as well —

const onChange = (objToWatch, onChangeFunction) => { 
const handler = {
get(target, property, receiver) {
onChangeFunction();
return Reflect.get(target, property, receiver);
},
set(target, property, value) {
onChangeFunction();
return Reflect.set(target, property, value);
},
deleteProperty(target, property) {
onChangeFunction();
return Reflect.deleteProperty(target, property);
}

};
return new Proxy(objToWatch, handler);
};

Well done. If you now run this code —

const logger = () => console.log('I was called');
const obj = { a: 'a' };
const proxy = onChange(obj, logger);
console.log(proxy.a); // logger called here in get trap
proxy.b = 'b'; // logger called here as well in set trap
delete proxy.a; // logger called here in deleteProperty trap

You are going to see I was called 3 times. That means, we’ve successfully re-created the on-change library.

There is one thing that we haven’t account for though. If you have nested objects in an array, they won’t trigger the logger function. For example, if the array is [1, 2, {a: false}] and you set array[2].a = true, the logger function will not be called.

It’s easy to rectify this bug. Instead of returning the value in the get trap, we will return another Proxy of the value if the value is an object so that the chain of proxies is never broken on objects.

Let’s add that logic to the get trap —

get(target, property, receiver) {
onChangeFunction();
const value = Reflect.get(target, property, receiver);
if (typeof value === 'object') {
return new Proxy(value, handler);
}
return value;

}

Now, it will work even with nested objects inside arrays and objects.

Some things that on-change does differently than our implementation are—

  • It does not call onChangeFunction in the get trap.
  • Instead of set trap, on-change intercepts defineProperty trap.

With the understanding of proxies, and knowledge of traps, these two would be a trivial addition/modifications. So, I’m leaving them for the reader to add themselves. You can read the source of on-change here, for reference.

One other issue that plagues on-change is this — if you have an array, and you do proxiedArray.sort() or any other function that heavily modifies the array, the logger function is going to be executed multiple times. For example, sorting the array [2,3,4,5,6,7,1] executes logger 12 times. This can be a desired functionality, or not. It depends on the developer.

There is another bug in the on-change library. If you read this issue, you will notice that the get trap violates something called an “Invariant”. Invariants are constraint put on proxy objects by the Proxy API. These constraints dis-allow illegal operations on objects whose descriptors are set a certain way.

The issue lists a potential solution as well. You can read the references below to gain a deeper understanding of proxies and invariants. And if you have never contributed to open-source before, this could be a great beginning for you to make a pull request, correcting the behaviour. 🙂

There are many other features and caveats of proxies. Read the references to understand them better.

I write about JavaScript, Web Development, and Computer Science. Follow me for weekly articles. Share this article if you like it.

Reach out to me on @ Facebook @ Linkedin @ Twitter.


Tag cloud