Today, we're going to look at JS Proxies: what they are, how they work, and why you might want to use them.
Let's dig in!
What's a Proxy?
The Proxy
object lets you detect when someone interacts with a property off an array or object, and run code in response.
You can use the new Proxy()
constructor to create a new Proxy
object. Pass in an array ([]
) or object ({}
) to create a Proxy
from as an argument, along with a handler object that defines how to handle interactions (more on that shortly).
In this example, we're creating a Proxy
from the wizards
object, and passing in an empty object as our handler.
let wizard = { name: 'Merlin', tool: 'Wand' }; // Create a Proxy object let wizardProxy = new Proxy(wizard, {});
The handler object and traps
The handler object tells the Proxy
how to respond to interactions with the object or array properties. You can assign a handful of functions—called traps—that run as callbacks for different types of interactions.
There are over a dozen different trap methods, but the three most common are the get()
, set()
, and deleteProperty()
methods. These run whenever someone gets, sets, or deletes a property on the array or object, respectively.
In this example, we get, set, and delete our object properties as normal. But, we'll also log some information into the console so you can see what happens when we make changes to our array or object. For simplicity, we can use the object property shorthand syntax.
// Create a Proxy object let wizardProxy = new Proxy(wizard, { /** * Runs when a property value is retrieved * @param {Object|Array} obj The object or array the Proxy is handling * @param {String|Integer} key The property key or index */ get (obj, key) { console.log('get', obj, key, obj[key]); return obj[key]; }, /** * Runs when a property is defined or updated * @param {Object|Array} obj The object or array the Proxy is handling * @param {String|Integer} key The property key or index * @param {*} value The value to assign to the property */ set (obj, key, value) { console.log('set', obj, key, value); // Update the property obj[key] = value; // Indicate success // This is required return true; }, /** * Runs when a property is deleted * @param {Object|Array} obj The object or array the Proxy is handling * @param {String|Integer} key The property key or index */ deleteProperty (obj, key) { console.log('delete', obj, key, obj[key]); // Delete the property delete obj[key]; // Indicate success // This is required return true; } });
Now, we can modify our Proxy
object just like we would a plain object, and the Proxy
handler trap methods will run our code in response.
// Runs the get() trap and logs... // "get" {name: 'Merlin', tool: 'Wand'} "name" "Merlin" let name = wizardProxy.name; // Runs the set() trap and logs... // "set" {name: 'Merlin', tool: 'Wand'} "age" 172 wizardProxy.age = 172; // Runs the deleteProperty() trap and logs... // "delete" {name: 'Merlin', tool: 'Wand'} "tool" "Wand" delete wizardProxy.tool;
Here's a demo.
If you're enjoying this tutorial, it's one of the many in-depth reference guides you can find over at the Lean Web Club.
Proxies and nesting
One challenge with Proxies is that they only detect changes to first-level properties of the object or array. Properties that are nested objects and arrays of the Proxy
object aren't Proxies themselves, and aren't detected.
Here, we have a wizard
object with a nested array of spells
. We also have a handler
object with get()
and set()
methods. They both log a message in the console, but otherwise maintain the default behavior.
We create a Proxy
object with the wizard
and handler
objects, and assign it to the wizardProxy
variable.
// An object with a nested array let wizard = { name: 'Merlin', tool: 'wand', spells: ['Abbracadabra', 'Disappear'] }; // A handler object let handler = { get (obj, key) { console.log('get', key); return obj[key]; }, set (obj, key, value) { console.log('set', key); obj[key] = value; return true; } }; // Create a new Proxy let wizardProxy = new Proxy(wizard, handler);
If we add a property to the wizardProxy
, or get a property value from it, a message logs to the console just like you'd expect.
// logs "get" "name" and "set" "age" respectively let name = wizardProxy.name; wizardProxy.age = 172;
But, if we get a property from the wizardProxy.spells
array, the handler.get()
method runs when we retrieve the spells
array, but not any specific properties from it.
But, if we use the Array.prototype.push()
method to add a property to the wizardProxy.spells
array, the handler.get()
method runs when we retrieve the spells
array, but the handler.set()
method never runs.
// Update the nested array // logs "get" "spells" wizardProxy.spells.push('Heal');
How to handle nested arrays and objects in a Proxy object
To detect nested arrays and objects inside a Proxy
object, we first need to move the handler
object into a function that returns the object.
function handler () { return { get (obj, key) { console.log('get', key); return obj[key]; }, set (obj, key, value) { console.log('set', key); obj[key] = value; return true; } }; } // Create a new Proxy let wizardProxy = new Proxy(wizard, handler());
Inside the handler.get()
method, we need to check if the value of the property (the obj[key]
) is an array or object.
If it is, we'll pass it into the new Proxy()
constructor and return that, recursively passing in the handler()
function. If not, we'll return it as-is.
The typeof
operator returns object
for all sorts of things that aren't plain objects ({}
), so we'll use a different approach to figure that out. We can use the call()
method on the Object.prototype.toString()
method, and pass in the item we want to check. This will return the prototype name.
// returns [object Array] Object.prototype.toString.call([]); // [object Object] Object.prototype.toString.call({});
We'll create an array with [object Object]
and [object Array]
in it, then use the Array.prototype.includes()
method to check if the string returned by Object.prototype.toString.call(obj[key])
is one of those two values.
If it is, we'll return new Proxy()
, passing the obj[key]
and handler()
in as arguments.
function handler () { return { get (obj, key) { console.log('get', key); // If the item is an object or array, return a proxy let nested = ['[object Object]', '[object Array]']; let type = Object.prototype.toString.call(obj[key]); if (nested.includes(type)) { return new Proxy(obj[key], handler()); } return obj[key]; }, set (obj, key, value) { console.log('set', key); obj[key] = value; return true; } }; }
Now, when we Array.prototype.push()
an item in the wizardProxy.spells
array, our handler.set()
method actually runs.
wizardProxy.spells.push('Heal');
How to avoid creating a Proxy of a Proxy
Proxies are opaque. There's no native property you can look at to determine if an object is already a Proxy
or not.
With our current code, it's possible to create a Proxy
from a Proxy
, which results in the handler()
function running on the same array or object multiple times. If this happens a few times over, the browser can lag or even crash.
let data = new Proxy({ wizards: { list: ['Gandalf', 'Radagast', 'Merlin'] }, witches: { list: ['Ursula', 'Wicked Witch Of The West', 'Malificent'] } }, handler()); /** * Reverse the witches and wizards * After a few dozen swaps, the browser will lag or crash */ function swapMagic () { let tempCache = data.wizards.list; data.wizards.list = data.witches.list; data.witches.list = tempCache; }
While there isn't a browser-native way to check if an array or object is already a Proxy
, we can add one using the handler
object.
In the handler.get()
method, we'll first check if the key
being retrieved is _isProxy
. If so, we'll return true
.
This isn't an actual property of the object. It's an internal dummy property that only returns true
if the handler.get()
method is being run. If that happens, we know that the property is already a Proxy
object.
// A handler object function handler () { return { get (obj, key) { // If the key is "_isProxy", return true // This will only happen if the property is already a Proxy if (key === '_isProxy') return true; // ... }, // ... }; }
If the property is an array or object, we'll check if the _isProxy
property returns true
.
If it does, the array or object is already being managed by the handler
object and is already Proxy
, so we can return it as-is. If not, it's a plain array or object, and we can safely return
a new Proxy()
.
// A handler object function handler () { return { get (obj, key) { // If the key is "_isProxy", return true // This will only happen if the property is already a Proxy if (key === '_isProxy') return true; // If the item is an object or array and not already a Proxy, return a new Proxy let nested = ['[object Object]', '[object Array]']; let type = Object.prototype.toString.call(obj[key]); if (nested.includes(type) && !obj[key]._isProxy) { return new Proxy(obj[key], handler()); } // Otherwise, return the property return obj[key]; }, // ... }; }
With these two little additions, we avoid nesting arrays and objects in multiple Proxy handlers, and the performance issues that come along with it.
Join the Lean Web Club! Coaching. Courses. Coding resources. Get the skills, confidence, and support you need to learn front-end web development and achieve long-term success.
Cheers,
Chris
Want to share this with others or read it later? View it in a browser.
0 Komentar untuk "[Go Make Things] A primer on JavaScript Proxies"