An Intro to JavaScript Proxy
Publikováno: 15.9.2021
Have you ever been in a situation where you wish you could have some control over the values in an object or array? Maybe you wanted to prevent certain types of data or even validate the data before storing it …
The post An Intro to JavaScript Proxy appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.
Have you ever been in a situation where you wish you could have some control over the values in an object or array? Maybe you wanted to prevent certain types of data or even validate the data before storing it in the object. Suppose you wanted to react to the incoming data in some way, or even the outgoing data? For example, maybe you wanted to update the DOM by displaying results or swap classes for styling changes as data changes. Ever wanted to work on a simple idea or section of page that needed some of the features of a framework, like Vue or React, but didn’t want to start up a new app?
Then JavaScript Proxy might be what you’re looking for!
A brief introduction
I’ll say up front: when it comes to front-end technologies, I’m more of a UI developer; much like described non-JavaScript-focused side of The Great Divide. I’m happy just creating nice-looking projects that are consistent in browsers and all the quirks that go with that. So when it comes to more pure JavaScript features, I tend not to go too deep.
Yet I still like to do research and I’m always looking for something to add to that list of new things to learn. Turns out JavaScript proxies are an interesting subject because just going over the basics opens up many possible ideas of how to leverage this feature. Despite that, at first glance, the code can get heavy quick. Of course, that all depends on what you need.
The concept of the proxy object has been with us for quite some time now. I could find references to it in my research going back several years. Yet it was not high on my list because it has never had support in Internet Explorer. In comparison, it has had excellent support across all the other browsers for years. This is one reason why Vue 3 isn’t compatible with Internet Explorer 11, because of the use of the proxy within the newest Vue project.
So, what is the proxy object exactly?
The Proxy
object
MDN describes the Proxy
object as something that:
[…] enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.
The general idea is that you can create an object that has functionality that lets you take control of typical operations that happen while using an object. The two most common would be getting and setting values stored in the object.
const myObj = {
mykey: 'value'
}
console.log(myObj.mykey); // "gets" value of the key, outputs 'value'
myObj.mykey = 'updated'; // "sets" value of the key, makes it 'updated'
So, in our proxy object we would create “traps” to intercept these operations and perform whatever functionality we might wish to accomplish. There are up to thirteen of these traps available. I’m not necessarily going to cover all these traps as not all of them are necessary for my simple examples that follow. Again, this depends on what you’re needing for the particular context of what you’re trying to create. Trust me, you can go a long way with just the basics.
To expand on our example above to create a proxy, we would do something like this:
const myObj = {
mykey: 'value'
}
const handler = {
get: function (target, prop) {
return target[prop];
},
set: function (target, prop, value) {
target[prop] = value;
return true;
}
}
const proxy = new Proxy(myObj, handler);
console.log(proxy.mykey); // "gets" value of the key, outputs 'value'
proxy.mykey = 'updated'; // "sets" value of the key, makes it 'updated'
First we start with our standard object. Then we create a handler object that holds the handler functions, often called traps. These represent the operations that can be done on a traditional object which, in this case, are the get
and set
that just pass things along with no changes. After that, we create our proxy using the constructor with our target object and the handler object. At that point, we can reference the proxy object in getting and setting values which will be a proxy to the original target object, myObj
.
Note return true
at the end of the set trap. That’s intended to inform the proxy that setting the value should be considered successful. In some situations where you wish to prevent a value being set (think of a validation error), you would return false
instead. This would also cause a console error with a TypeError
being outputted.
Now one thing to keep in mind with this pattern is that the original target object is still available. That means you could bypass the proxy and alter values of the object without the proxy. In my reading about using the Proxy
object, I found useful patterns that can help with that.
let myObj = {
mykey: 'value'
}
const handler = {
get: function (target, prop) {
return target[prop];
},
set: function (target, prop, value) {
target[prop] = value;
return true;
}
}
myObj = new Proxy(myObj, handler);
console.log(myObj.mykey); // "gets" value of the key, outputs 'value'
myObj.mykey = 'updated'; // "sets" value of the key, makes it 'updated'
In this pattern, we’re using the target object as the proxy object while referencing the target object within the proxy constructor. Yeah, that happened. This works, but I found it somewhat easy to get confused over what’s happening. So let’s create the target object inside the proxy constructor instead:
const handler = {
get: function (target, prop) {
return target[prop];
},
set: function (target, prop, value) {
target[prop] = value;
return true;
}
}
const proxy = new Proxy({
mykey: 'value'
}, handler);
console.log(proxy.mykey); // "gets" value of the key, outputs 'value'
proxy.mykey = 'updated'; // "sets" value of the key, makes it 'updated'
For that matter, we could create both the target and handler objects inside the constructor if we prefer:
const proxy = new Proxy({
mykey: 'value'
}, {
get: function (target, prop) {
return target[prop];
},
set: function (target, prop, value) {
target[prop] = value;
return true;
}
});
console.log(proxy.mykey); // "gets" value of the key, outputs 'value'
proxy.mykey = 'updated'; // "sets" value of the key, makes it 'updated'
In fact, this is the most common pattern I use in my examples below. Thankfully, there is flexibility in how to create a proxy object. Just use whatever patterns suits you.
The following are some examples covering usage of the JavaScript Proxy from basic data validation up to updating form data with a fetch. Keep in mind these examples really do cover the basics of JavaScript Proxy; it can go deeper quick if you wish. In some cases they are just about creating regular JavaScript code doing regular JavaScript things within the proxy object. Look at them as ways to extend some common JavaScript tasks with more control over data.
A simple example for a simple question
My first example covers what I’ve always felt was a rather simplistic and strange coding interview question: reverse a string. I’ve never been a fan and never ask it when conducting an interview. Being someone that likes to go against the grain in this kind of thing, I played with outside-the-box solutions. You know, just to throw it out there sometimes for fun and one of these solutions is a good bit of front end fun. It also makes for a simple example showing a proxy in use.
If you type into the input you will see whatever is typed is printed out below, but reversed. Obviously, any of the many ways to reverse a string could be used here. Yet, let’s go over my strange way to do the reversal.
const reverse = new Proxy(
{
value: ''
},
{
set: function (target, prop, value) {
target[prop] = value;
document.querySelectorAll('[data-reverse]').forEach(item => {
let el = document.createElement('div');
el.innerHTML = '' + value;
item.innerText = el.innerHTML;
});
return true;
}
}
)
document.querySelector('input').addEventListener('input', e => {
reverse.value = e.target.value;
});
First, we create our new proxy and the target object is a single key value
that holds whatever is typed into the input. The get
trap isn’t there since we would just need a simple pass-through as we don’t have any real functionality tied to it. There’s no need to do anything in that case. We’ll get to that later.
For the set
trap we do have a small bit of functionality to perform. There is still a simple pass-through where the value is set to the value
key in the target object like normal. Then there is a querySelectorAll
that finds all elements with a data-reverse
data attribute on the page. This allows us to target multiple elements on the page and update them all in one go. This gives us our framework-like binding action that everybody likes to see. This could also be updated to target inputs to allow for a proper two-way binding type of situation.
This is where my little fun oddball way of reversing a string kicks in. A div is created in memory and then the innerHTML
of the element is updated with a string. The first part of the string uses a special Unicode decimal code that actually reverses everything after, making it right-to-left. The innerText
of the actual element on the page is then given the innerHTML
of the div in memory. This runs each time something is entered into the input; therefore, all elements with the data-reverse
attribute is updated.
Lastly, we set up an event listener on the input that sets the value
key in our target object by the input’s value that is the target of the event.
In the end, a very simple example of performing a side effect on the page’s DOM through setting a value to the object.
Live-formatting an input value
A common UI pattern is to format the value of an input into a more exact sequence than just a string of letters and numbers. An example of this is an telephone input. Sometimes it just looks and feels better if the phone number being typed actually looks like a phone number. The trick though is that, when we format the input’s value, we probably still want an unformatted version of the data.
This is an easy task for a JavaScript Proxy.
As you type numbers into the input, they’re formatted into a standard U.S. phone number (e.g. (123) 456-7890
). Notice, too, that the phone number is displayed in plain text underneath the input just like the reverse string example above. The button outputs both the formatted and unformatted versions of the data to the console.
So here’s the code for the proxy:
const phone = new Proxy(
{
_clean: '',
number: '',
get clean() {
return this._clean;
}
},
{
get: function (target, prop) {
if (!prop.startsWith('_')) {
return target[prop];
} else {
return 'entry not found!'
}
},
set: function (target, prop, value) {
if (!prop.startsWith('_')) {
target._clean = value.replace(/\D/g, '').substring(0, 10);
const sections = {
area: target._clean.substring(0, 3),
prefix: target._clean.substring(3, 6),
line: target._clean.substring(6, 10)
}
target.number =
target._clean.length > 6 ? `(${sections.area}) ${sections.prefix}-${sections.line}` :
target._clean.length > 3 ? `(${sections.area}) ${sections.prefix}` :
target._clean.length > 0 ? `(${sections.area}` : '';
document.querySelectorAll('[data-phone_number]').forEach(item => {
if (item.tagName === 'INPUT') {
item.value = target.number;
} else {
item.innerText = target.number;
}
});
return true;
} else {
return false;
}
}
}
);
There’s more code in this example, so let’s break it down. The first part is the target object that we are initializing inside the proxy itself. It has three things happening.
{
_clean: '',
number: '',
get clean() {
return this._clean;
}
},
The first key, _clean
, is our variable that holds the unformatted version of our data. It starts with the underscore with a traditional variable naming pattern of considering it “private.” We would like to make this unavailable under normal circumstances. There will be more to this as we go.
The second key, number
, simply holds the formatted phone number value.
The third "key"
is a get
function using the name clean
. This returns the value of our private _clean
variable. In this case, we’re simply returning the value, but this provides the opportunity to do other things with it if we wish. This is like a proxy getter for the get
function of the proxy. It seems strange but it makes for an easy way to control our data. Depending on your specific needs, this might be a rather simplistic way to handle this situation. It works for our simple example here but there could be other steps to take.
Now for the get
trap of the proxy.
get: function (target, prop) {
if (!prop.startsWith('_')) {
return target[prop];
} else {
return 'entry not found!'
}
},
First, we check for the incoming prop, or object key, to determine if it does not start with an underscore. If it does not start with an underscore, we simply return it. If it does, then we return a string saying the entry was not found. This type of negative return could be handled different ways depending on what is needed. Return a string, return an error, or run code with different side effects. It all depends on the situation.
One thing to note in my example is that I’m not handling other proxy traps that may come into play with what would be considered a private variable in the proxy. For a more complete protection of this data, you would have to consider other traps, such as [defineProperty]
(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/defineProperty), deleteProperty
, or ownKeys
— typically anything about manipulating or referring to object keys. Whether you go this far could depend on who would be making use of the proxy. If it’s for you, then you know how you are using the proxy. But if it’s someone else, you may want to consider locking things down as much as possible.
Now for where most of the magic happens for this example — the set
trap:
set: function (target, prop, value) {
if (!prop.startsWith('_')) {
target._clean = value.replace(/\D/g, '').substring(0, 10);
const sections = {
area: target._clean.substring(0, 3),
prefix: target._clean.substring(3, 6),
line: target._clean.substring(6, 10)
}
target.number =
target._clean.length > 6 ? `(${sections.area}) ${sections.prefix}-${sections.line}` :
target._clean.length > 3 ? `(${sections.area}) ${sections.prefix}` :
target._clean.length > 0 ? `(${sections.area}` : '';
document.querySelectorAll('[data-phone_number]').forEach(item => {
if (item.tagName === 'INPUT') {
item.value = target.number;
} else {
item.innerText = target.number;
}
});
return true;
} else {
return false;
}
}
First, the same check against the private variable we have in the proxy. I don’t really test for other types of props, but you might consider doing that here. I’m assuming only that the number key in the proxy target object will be adjusted.
The incoming value, the input’s value, is stripped of everything but number characters and saved to the _clean
key. This value is then used throughout to rebuild into the formatted value. Basically, every time you type, the entire string is being rebuilt into the expected format, live. The substring method keeps the number locked down to ten digits.
Then a sections
object is created to hold the different sections of our phone number based on the breakdown of a U.S. phone number. As the _clean
variable increases in length, we update number
to a formatting pattern we wish to see at that point in time.
A querySelectorAll
is looking for any element that has the data-phone_number
data attribute and run them through a forEach
loop. If the element is an input where the value is updated, the innerText
of anything else is updated. This is how the text appears underneath the input. If we were to place another input element with that data attribute, we would see its value updated in real time. This is a way to create one-way or two-way binding, depending on the requirements.
In the end, true
is returned to let the proxy know everything went well. If the incoming prop, or key, starts with an underscore, then false
is returned instead.
Finally, the event listeners that makes this work:
document.querySelectorAll('input[data-phone_number]').forEach(item => {
item.addEventListener('input', (e) => {
phone.number = e.target.value;
});
});
document.querySelector('#get_data').addEventListener('click', (e) => {
console.log(phone.number); // (123) 456-7890
console.log(phone.clean); // 1234567890
});
The first set finds all the inputs with our specific data attribute and adds an event listener to them. For each input event, the proxy’s number key value is updated with the current input’s value. Since we’re formatting the value of the input that gets sent along each time, we strip out any characters that are not numbers.
The second set finds the button that outputs both sets of data, as requested, to the console. This shows how we could write code that requests the data that is needed at any time. Hopefully it is clear that phone.clean
is referring to our get
proxy function that’s in the target object that returns the _clean
variable in the object. Notice that it isn’t invoked as a function, like phone.clean()
, since it behaves as a get
proxy in our proxy.
Storing numbers in an array
Instead of an object you could use an array as the target “object” in the proxy. Since it would be an array there are some things to consider. Features of an array such as push()
would be treated certain ways in the setter trap of the proxy. Plus, creating a custom function inside the target object concept doesn’t really work in this case. Yet, there are some useful things to be done with having an array as the target.
Sure, storing numbers in an array isn’t a new thing. Obviously. Yet I’m going to attach a few rules to this number-storing array, such as no repeating values and allowing only numbers. I’ll also provide some outputting options, such sort, sum, average, and clearing the values. Then update a small user interface that controls it all.
Here’s the proxy object:
const numbers = new Proxy([],
{
get: function (target, prop) {
message.classList.remove('error');
if (prop === 'sort') return [...target].sort((a, b) => a - b);
if (prop === 'sum') return [...target].reduce((a, b) => a + b);
if (prop === 'average') return [...target].reduce((a, b) => a + b) / target.length;
if (prop === 'clear') {
message.innerText = `${target.length} number${target.length === 1 ? '' : 's'} cleared!`;
target.splice(0, target.length);
collection.innerText = target;
}
return target[prop];
},
set: function (target, prop, value) {
if (prop === 'length') return true;
dataInput.value = '';
message.classList.remove('error');
if (!Number.isInteger(value)) {
console.error('Data provided is not a number!');
message.innerText = 'Data provided is not a number!';
message.classList.add('error');
return false;
}
if (target.includes(value)) {
console.error(`Number ${value} has already been submitted!`);
message.innerText = `Number ${value} has already been submitted!`;
message.classList.add('error');
return false;
}
target[prop] = value;
collection.innerText = target;
message.innerText = `Number ${value} added!`;
return true;
}
});
With this example, I’ll start with the setter trap.
First thing to do is to check against the length
property being set to the array. It just returns true
so that it would happen the normal way. It could always have code in place in case reacting to the length being set if we needed.
The next two lines of code refer to two HTML elements on the page stored with a querySelector
. The dataInput
is the input element and we wish to clear it on every entry. The message
is the element that holds responses to changes to the array. Since it has the concept of an error state, we make sure it is not in that state on every entry.
The first if
checks to see if the entry is in fact a number. If it is not, then it does several things. It emits a console error stating the problem. The message element gets the same statement. Then the message is placed into an error state via a CSS class. Finally, it returns false
which also causes the proxy to emit its own error to the console.
The second if
checks to see if the entry already exists within the array; remember we do not want repeats. If there is a repeat, then the same messaging happens as in the first if
. The messaging is a bit different as it’s a template literal so we can see the repeated value.
The last section assumes everything has gone well and things can proceed. The value is set as usual and then we update the collection
list. The collection
is referring to another element on the page that shows us the current collection of numbers in the array. Again, the message is updated with the entry that was added. Finally, we return true
to let the proxy know all is well.
Now, the get trap is a bit different than the previous examples.
get: function (target, prop) {
message.classList.remove('error');
if (prop === 'sort') return [...target].sort((a, b) => a - b);
if (prop === 'sum') return [...target].reduce((a, b) => a + b);
if (prop === 'average') return [...target].reduce((a, b) => a + b) / target.length;
if (prop === 'clear') {
message.innerText = `${target.length} number${target.length === 1 ? '' : 's'} cleared!`;
target.splice(0, target.length);
collection.innerText = target;
}
return target[prop];
},
What’s going on here is taking advantage of a “prop” that’s not a normal array method; it gets passed along to the get
trap as the prop. Take for instance the first “prop” is triggered by this event listener:
dataSort.addEventListener('click', () => {
message.innerText = numbers.sort;
});
So when the sort button is clicked, the message element’s innerText
is updated with whatever numbers.sort
returns. It acts as a getter that the proxy intercepts and returns something other than typical array-related results.
After removing the potential error state of the message element, we then figure out if something other than a standard array get operation is expected to happen. Each one returns a manipulation of the original array data without altering the original array. This is done by using the spread operator on the target to create a new array and then standard array methods are used. Each name should suggest what it does: sort, sum, average, and clear. Well, OK, clear isn’t exactly a standard array method, but it sounds good. Since the entries can be in any order, we can have it give us the sorted list or do math functions on the entries. Clearing simply wipes out the array as you might expect.
Here are the other event listeners used for the buttons:
dataForm.addEventListener('submit', (e) => {
e.preventDefault();
numbers.push(Number.parseInt(dataInput.value));
});
dataSubmit.addEventListener('click', () => {
numbers.push(Number.parseInt(dataInput.value));
});
dataSort.addEventListener('click', () => {
message.innerText = numbers.sort;
});
dataSum.addEventListener('click', () => {
message.innerText = numbers.sum;
});
dataAverage.addEventListener('click', () => {
message.innerText = numbers.average;
});
dataClear.addEventListener('click', () => {
numbers.clear;
});
There are many ways we could extend and add features to an array. I’ve seen examples of an array that allows selecting an entry with a negative index that counts from the end. Finding an entry in an array of objects based on a property value within an object. Have a message returned on trying to get a nonexistent value within the array instead of undefined
. There are lots of ideas that can be leveraged and explored with a proxy on an array.
Interactive address form
An address form is a fairly standard thing to have on a web page. Let’s add a bit of interactivity to it for fun (and non-standard) confirmation. It can also act as a data collection of the values of the form within a single object that can be requested on demand.
Here’s the proxy object:
const model = new Proxy(
{
name: '',
address1: '',
address2: '',
city: '',
state: '',
zip: '',
getData() {
return {
name: this.name || 'no entry!',
address1: this.address1 || 'no entry!',
address2: this.address2 || 'no entry!',
city: this.city || 'no entry!',
state: this.state || 'no entry!',
zip: this.zip || 'no entry!'
};
}
},
{
get: function (target, prop) {
return target[prop];
},
set: function (target, prop, value) {
target[prop] = value;
if (prop === 'zip' && value.length === 5) {
fetch(`https://api.zippopotam.us/us/${value}`)
.then(response => response.json())
.then(data => {
model.city = data.places[0]['place name'];
document.querySelector('[data-model="city"]').value = target.city;
model.state = data.places[0]['state abbreviation'];
document.querySelector('[data-model="state"]').value = target.state;
});
}
document.querySelectorAll(`[data-model="${prop}"]`).forEach(item => {
if (item.tagName === 'INPUT' || item.tagName === 'SELECT') {
item.value = value;
} else {
item.innerText = value;
}
})
return true;
}
}
);
The target object is quite simple; the entries for each input in the form. The getData
function will return the object but if a property has an empty string for a value it will change to “no entry!” This is optional but the function gives a cleaner object than what we would get by just getting the state of the proxy object.
The getter function simply passes things along as usual. You could probably do without that, but I like to include it for completeness.
The setter function sets the value to the prop. The if
, however, checks to see if the prop being set happens to be the zip code. If it is, then we check to see if the length of the value is five. When the evaluation is true
, we perform a fetch that hits an address finder API using the zip code. Any values that are returned are inserted into the object properties, the city input, and selects the state in the select element. This an example of a handy shortcut to let people skip having to type those values. The values can be changed manually, if needed.
For the next section, let’s look at an example of an input element:
<input class="in__input" id="name" data-model="name" placeholder="name" />
The proxy has a querySelectorAll
that looks for any elements that have a matching data attribute. This is the same as the reverse string example we saw earlier. If it finds a match, it updates either the input’s value or element’s innerText
. This is how the rotated card is updated in real-time to show what the completed address will look like.
One thing to note is the data-model
attribute on the inputs. The value of that data attribute actually informs the proxy what key to latch onto during its operations. The proxy finds the elements involved based on that key involves. The event listener does much the same by letting the proxy know which key is in play. Here’s what that looks like:
document.querySelector('main').addEventListener('input', (e) => {
model[e.target.dataset.model] = e.target.value;
});
So, all the inputs within the main element are targeted and, when the input event is fired, the proxy is updated. The value of the data-model
attribute is used to determine what key to target in the proxy. In effect, we have a model-like system in play. Think of ways such a thing could be leveraged even further.
As for the “get data” button? It’s a simple console log of the getData
function…
getDataBtn.addEventListener('click', () => {
console.log(model.getData());
});
This was a fun example to build and use to explore the concept. This is the kind of example that gets me thinking about what I could build with the JavaScript Proxy. Sometimes, you just want a small widget that has some data collection/protection and ability to manipulate the DOM just by interacting with data. Yes, you could go with Vue or React, but sometimes even they can be too much for such a simple thing.
That’s all, for now
“For now” meaning that could depend on each of you and whether you’ll dig a bit deeper into the JavaScript Proxy. Like I said at the beginning of this article, I only cover the basics of this feature. There is a great deal more it can offer and it can go bigger than the examples I’ve provided. In some cases it could provide the basis of a small helper for a niche solution. It’s obvious that the examples could easily be created with basic functions doing much the same functionality. Even most of my example code is regular JavaScript mixed with the proxy object.
The point though is to offer examples of using the proxy to show how one could react to interactions to data — even control how to react to those interactions to protect data, validate data, manipulate the DOM, and fetch new data — all based on someone trying to save or get the data. In the long run, this can be very powerful and allow for simple apps that may not warrant a larger library or framework.
So, if you’re a front-end developer that focuses more on the UI side of things, like myself, you can explore a bit of the basics to see if there are smaller projects that could benefit from JavaScript Proxy. If you’re more of a JavaScript developer, then you can start digging deeper into the proxy for larger projects. Maybe a new framework or library?
Just a thought…
The post An Intro to JavaScript Proxy appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.