The Power of Proxy Pattern in JavaScript
jsmanifest
Posted on April 5, 2022
One of the more interesting patterns I learned on a later stage of my career is the Proxy
.
When you look for examples of the Proxy pattern you might often see different variations of implementations. That's because the Proxy is not limited to one use case. One proxy may act as a validator while the other may be more interested in improving performance, etc.
The idea is that by utilizing a proxy we wrap existing objects that functions the same as the original where its methods (or even properties) are exactly identical until we add additional logic inside the wrapped methods before the wrapped function is called. This is is a process completely hidden to the outside world, and this call will always appear the same to the caller.
In other words the proxy sits right in between the client of an object and the actual object itself. This is where it can choose to act as a "protector" or add custom logic such as caching without the caller ever knowing this. Because of this it can sometimes be referred to as the Mediator. Some may also categorize it as another form of a Decorator pattern, but there are some differences.
In this post we will be going over the power of the Proxy Design Pattern in JavaScript and go over several examples of how beneficial it can become for your next application.
Since JavaScript natively added a Proxy
class that implements the pattern, we will be directly using the Proxy
class instead to demonstrate the pattern after a couple vanilla implementations.
Difference between Decorator vs Proxy
In the decorator pattern, the decorator's main responsibility is to enhance the object it is wrapping (or "decorating"), whereas the proxy has more accessibility and controls the object.
The proxy may choose to enhance the object it is wrapping or control it in other ways such as restricting access from the outside world, but a decorator instead informs and applies enhancements.
The difference responsibility-wise is clear. Engineers commonly use decorators to add new behavior or as a form of an adapter for old or legacy classes where they return an enhanced interface that the client may know about but doesn't care about at the same time. The proxy is usually intended to be returning the same interface where the client may assume it is working with the same object untouched.
Validator/Helper
The first implementation of a Proxy pattern I will be showing here will be a validator.
This example shows the pattern being implemented as a way to help validate input and protect properties from being set the wrong data types. Remember that the caller must always assume that it is working with the original object so the Proxy must not change the signature or interface of the object it is wrapping:
class Pop {
constructor(...items) {
this.id = 1
}
}
const withValidator = (obj, field, validate) => {
let value = obj[field]
Object.defineProperty(obj, field, {
get() {
return value
},
set(newValue) {
const errMsg = validate(newValue)
if (errMsg) throw new Error(errMsg)
value = newValue
},
})
return obj
}
let mello = new Pop(1, 2, 3)
mello = withValidator(mello, 'id', (newId) => {
if (typeof newId !== 'number') {
return `The id ${newId} is not a number. Received ${typeof newId} instead`
}
})
mello.id = '3'
This example shows a simple helper that validates fields of an object, throwing a TypeError
exception when the validation fails.
The Proxy takes ownership of the getter
and setter
of the id
property and chooses to allow or reject values that are attempted to be set.
In the Proxy
class it can be implemented with something like this:
const withValidator = (obj, field, validate) => {
return new Proxy(obj, {
set(target, prop, newValue) {
if (prop === field) {
const errMsg = validate(newValue)
if (errMsg) throw new TypeError(errMsg)
target[prop] = newValue
}
},
})
}
let mello = new Pop(1, 2, 3)
mello = withValidator(mello, 'id', (newId) => {
if (typeof newId !== 'number') {
return `The id ${newId} is not a number. Received ${typeof newId} instead`
}
})
mello.id = '3'
The validator works perfectly:
TypeError: The id 3 is not a number. Received string instead
Clipboard Polyfill
This section will go over using the Proxy as a way to support older browsers when copying selections of text into the users clipboard by ensuring that the browser supports the Navigator.clipboard
API. If it doesn't, then it will fall back to using execCommand
to copy the selection.
Again, the client will always assume that the object it is calling methods on is the original object and only knows that it's calling the said method:
const withClipboardPolyfill = (obj, prop, cond, copyFnIfCond) => {
const copyToClipboard = (str) => {
if (cond()) {
copyFnIfCond()
} else {
const textarea = document.createElement('textarea')
textarea.value = str
textarea.style.visibility = 'hidden'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
}
obj[prop] = copyToClipboard
return obj
}
const api = (function () {
const o = {
copyToClipboard(str) {
return navigator.clipboard.writeText(str)
},
}
return o
})()
let copyBtn = document.createElement('button')
copyBtn.id = 'copy-to-clipboard'
document.body.appendChild(copyBtn)
copyBtn.onclick = api.copyToClipboard
copyBtn = withClipboardPolyfill(
copyBtn,
'onclick',
() => 'clipboard' in navigator,
api.copyToClipboard,
)
copyBtn.click()
You might ask what is the point of applying the proxy in situations like this instead of directly hardcoding the implementation inside the actual copyToClipboard
function. If we utilize a proxy we can reuse it as a standalone and freely change the implementation via inversion of control.
Another benefit from using this strategy is that we don't modify the original function.
Cacher (Enhancing performance)
Caching can take in many different forms in many different scenarios. For example there is a Stale While Revalidate for http requests, nginx content caching, cpu caching, lazy loading caching, memoization. etc.
In JavaScript we can also achieve caching with the help of a Proxy.
To implement the proxy pattern without directly using the Proxy
class we can do something like this:
const simpleHash = (str) =>
str.split('').reduce((acc, str) => (acc += str.charCodeAt(0)), '')
const withMemoization = (obj, prop) => {
const origFn = obj[prop]
const cache = {}
const fn = (...args) => {
const hash = simpleHash(args.map((arg) => String(arg)).join(''))
if (!cache[hash]) cache[hash] = origFn(...args)
return cache[hash]
}
Object.defineProperty(obj, prop, {
get() {
return fn
},
})
return obj
}
const sayHelloFns = {
prefixWithHello(str) {
return `[hello] ${str}`
},
}
const enhancedApi = withMemoization(sayHelloFns, 'prefixWithHello')
enhancedApi.prefixWithHello('mike')
enhancedApi.prefixWithHello('sally')
enhancedApi.prefixWithHello('mike the giant')
enhancedApi.prefixWithHello('sally the little')
enhancedApi.prefixWithHello('lord of the rings')
enhancedApi.prefixWithHello('lord of the rings')
enhancedApi.prefixWithHello('lord of the rings')
enhancedApi.prefixWithHello('lord of the rings')
enhancedApi.prefixWithHello('lord of the rings')
Cache:
{
"109105107101": "[hello] mike",
"11597108108121": "[hello] sally",
"109105107101321161041013210310597110116": "[hello] mike the giant",
"115971081081213211610410132108105116116108101": "[hello] sally the little",
"108111114100321111023211610410132114105110103115": "[hello] lord of the rings"
}
Implementing this directly in a Proxy
class is straight forward:
const withMemoization = (obj, prop) => {
const origFn = obj[prop]
const cache = {}
const fn = (...args) => {
const hash = simpleHash(args.map((arg) => String(arg)).join(''))
if (!cache[hash]) cache[hash] = origFn(...args)
return cache[hash]
}
return new Proxy(obj, {
get(target, key) {
if (key === prop) {
return fn
}
return target[key]
},
})
}
The Proxy
class
We've seen a persistent pattern in a couple of barebones Proxy pattern implementation vs directly using the Proxy
class. Since JavaScript directly provides Proxy
as an object into the language, the rest of this post will be using this as a convenience.
All remaining examples can be achieved without the Proxy
, but we will be focusing on the class syntax instead because it is more concise and easier to work with especially for the sake of this post.
Proxy to Singleton
If you've never heard of a Singleton, it's another design pattern that ensures that an object of interest will be returned and reused if it's already instantiated throughout the lifetime of an application. In practice you will most likely see this being used as some global variable.
For example, if we were coding an MMORPG game and we had three classes Equipment
, Person
, and Warrior
where there can only be one Warrior
in existence, we can use the construct
handler method inside the second argument when instantiating a Proxy
on the Warrior
class:
class Equipment {
constructor(equipmentName, type, props) {
this.id = `_${Math.random().toString(36).substring(2, 16)}`
this.name = equipmentName
this.type = type
this.props = props
}
}
class Person {
constructor(name) {
this.hp = 100
this.name = name
this.equipments = {
defense: {},
offense: {},
}
}
attack(target) {
target.hp -= 5
const weapons = Object.values(this.equipments.offense)
if (weapons.length) {
for (const weapon of weapons) {
console.log({ weapon })
target.hp -= weapon.props.damage
}
}
}
equip(equipment) {
this.equipments[equipment.type][equipment.id] = equipment
}
}
class Warrior extends Person {
constructor() {
super(...arguments)
}
bash(target) {
target.hp -= 15
}
}
function useSingleton(_Constructor) {
let _warrior
return new Proxy(_Constructor, {
construct(target, args, newTarget) {
if (!_warrior) _warrior = new Warrior(...args)
return _warrior
},
})
}
const WarriorSingleton = useSingleton(Warrior)
If we try to create multiple instances of Warrior
we are ensured that only the first one created is used every time:
const mike = new WarriorSingleton('mike')
const bob = new WarriorSingleton('bob')
const sally = new WarriorSingleton('sally')
console.log(mike)
console.log(bob)
console.log(sally)
Result:
Warrior {
hp: 100,
name: 'mike',
equipments: { defense: {}, offense: {} }
}
Warrior {
hp: 100,
name: 'mike',
equipments: { defense: {}, offense: {} }
}
Warrior {
hp: 100,
name: 'mike',
equipments: { defense: {}, offense: {} }
}
Cookie Stealer
In this section we will demonstrate an example using a Proxy
to prevent mutations from a list of cookies. This will prevent the original object from being mutated and the mutator (the CookieStealer
) will assume that their evil operation was a success.
Lets take a look at this example:
class Food {
constructor(name, points) {
this.name = name
this.points = points
}
}
class Cookie extends Food {
constructor() {
super(...arguments)
}
setFlavor(flavor) {
this.flavor = flavor
}
}
class Human {
constructor() {
this.foods = []
}
saveFood(food) {
this.foods.push(food)
}
eat(food) {
if (this.foods.includes(food)) {
const foodToEat = this.foods.splice(this.foods.indexOf(food), 1)[0]
this.hp += foodToEat.points
}
}
}
const apple = new Food('apple', 2)
const banana = new Food('banana', 2)
const chocolateChipCookie = new Cookie('cookie', 2)
const sugarCookie = new Cookie('cookie', 2)
const butterCookie = new Cookie('cookie', 3)
const bakingSodaCookie = new Cookie('cookie', 3)
const fruityCookie = new Cookie('cookie', 5)
chocolateChipCookie.setFlavor('chocolateChip')
sugarCookie.setFlavor('sugar')
butterCookie.setFlavor('butter')
bakingSodaCookie.setFlavor('bakingSoda')
fruityCookie.setFlavor('fruity')
const george = new Human()
george.saveFood(apple)
george.saveFood(banana)
george.saveFood(chocolateChipCookie)
george.saveFood(sugarCookie)
george.saveFood(butterCookie)
george.saveFood(bakingSodaCookie)
george.saveFood(fruityCookie)
console.log(george)
George's food:
{
foods: [
Food { name: 'apple', points: 2 },
Food { name: 'banana', points: 2 },
Cookie { name: 'cookie', points: 2, flavor: 'chocolateChip' },
Cookie { name: 'cookie', points: 2, flavor: 'sugar' },
Cookie { name: 'cookie', points: 3, flavor: 'butter' },
Cookie { name: 'cookie', points: 3, flavor: 'bakingSoda' },
Cookie { name: 'cookie', points: 5, flavor: 'fruity' }
]
}
We instantiated george
using the Human
class and we added 7 items of food to its storage. George is happy he is about to eat his fruits and cookies. He is especially excited about his cookies because he's gotten his favorite flavors all at the same time, soon to be gobbling on them to satisfy his cravings for cookies.
However, there is an issue:
const CookieStealer = (function () {
const myCookiesMuahahaha = []
return {
get cookies() {
return myCookiesMuahahaha
},
isCookie(obj) {
return obj instanceof Cookie
},
stealCookies(person) {
let indexOfCookie = person.foods.findIndex(this.isCookie)
while (indexOfCookie !== -1) {
const food = person.foods[indexOfCookie]
if (this.isCookie(food)) {
const stolenCookie = person.foods.splice(indexOfCookie, 1)[0]
myCookiesMuahahaha.push(stolenCookie)
}
indexOfCookie = person.foods.findIndex(this.isCookie)
}
},
}
})()
CookieStealer.stealCookies(george)
The CookieStealer
comes out of the blue to steal his cookies. The CookieStealer
now has the 5 cookies in his storage:
[
Cookie { name: 'cookie', points: 2, flavor: 'chocolateChip' },
Cookie { name: 'cookie', points: 2, flavor: 'sugar' },
Cookie { name: 'cookie', points: 3, flavor: 'butter' },
Cookie { name: 'cookie', points: 3, flavor: 'bakingSoda' },
Cookie { name: 'cookie', points: 5, flavor: 'fruity' }
]
George:
Human {
foods: [
Food { name: 'apple', points: 2 },
Food { name: 'banana', points: 2 }
]
}
If we were to rewind back and introduce our savior Superman
to apply one of his methods that implement the Proxy
pattern to prevent the CookieStealer
from his evil acts it would solve our issue:
class Superman {
protectFromCookieStealers(obj, key) {
let realFoods = obj[key]
let fakeFoods = [...realFoods]
return new Proxy(obj, {
get(target, prop) {
if (key === prop) {
fakeFoods = [...fakeFoods]
Object.defineProperty(fakeFoods, 'splice', {
get() {
return function fakeSplice(...[index, removeCount]) {
fakeFoods = [...fakeFoods]
return fakeFoods.splice(index, removeCount)
}
},
})
return fakeFoods
}
return target[prop]
},
})
}
}
const superman = new Superman()
const slickGeorge = superman.protectFromCookieStealers(george, 'foods')
Our friend superman
luckily happens to have a protectFromCookieStealers
using the power of the Proxy
to fake a list of cookies! He keeps the real collection of foods that contain george's cookies hidden away from the CookieStealer
. CookieStealer
proceeds with his evil plans and is seemingly tricked into thinking he got away with the cookies:
CookieStealer.stealCookies(slickGeorge)
console.log(CookieStealer.cookies)
The CookieStealer
walks away with cookies in his storage still and thinks he got away with it:
[
Cookie { name: 'cookie', points: 2, flavor: 'chocolateChip' },
Cookie { name: 'cookie', points: 2, flavor: 'sugar' },
Cookie { name: 'cookie', points: 3, flavor: 'butter' },
Cookie { name: 'cookie', points: 3, flavor: 'bakingSoda' },
Cookie { name: 'cookie', points: 5, flavor: 'fruity' }
]
Little does he know that he was tricked by superman and those were fake cookies! george
still has his cookies untouched thanks to the power of Proxy
saving him from the darkness of evil:
console.log(slickGeorge)
Human {
foods: [
Food { name: 'apple', points: 2 },
Food { name: 'banana', points: 2 },
Cookie { name: 'cookie', points: 2, flavor: 'chocolateChip' },
Cookie { name: 'cookie', points: 2, flavor: 'sugar' },
Cookie { name: 'cookie', points: 3, flavor: 'butter' },
Cookie { name: 'cookie', points: 3, flavor: 'bakingSoda' },
Cookie { name: 'cookie', points: 5, flavor: 'fruity' }
]
}
Conclusion
I hope this helped shed some light on the Proxy pattern and how to take advantage of this concept using the now built-in Proxy
class in JavaScript.
That concludes the end of this post :) I hope you found this article helpful to you, and make sure to follow me on medium for future posts!
Find me on medium
Posted on April 5, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 5, 2024
February 6, 2024