JavaScript Proxy
or why you may not need any framework
complete description of proxy can be found on mdn
but in general the simplest example will be:
const data = {
foo: 'bar',
acme: 42
}
const proxied = new Proxy(data, {
get: function (obj, prop) {
console.log({proxy: 'get', obj, prop})
return obj[prop]
},
set: function (obj, prop, value) {
console.log({proxy: 'set', obj, prop, value})
obj[prop] = value
return true
}
})
the most painful and important thing here is that you should not mutate your objects
aka if you are proxying an array and removing items from it like items = items.filter(...)
you are loosing propxy, thats why it will work only once
the same is true for inner objects
for demo i will be using previous note about createElement to reduce amount of UI creation code
we will be doing shoping cart demo which will list render of items and allow to modify them, aka:
const items = [
{id: 1, name: 'product 1', quantity: 1, price: 9.99},
{id: 2, name: 'product 2', quantity: 2, price: 19.99},
]
function render() {
// ...
}
the problem here is that whenever we made changes we gonna need to call render function manualy
so why not wrap everything into proxy and whenever something changed call it, aka:
const proxied = new Proxy(items, {
set: function (obj, prop, value) {
obj[prop] = value
render()
return true
}
})
but we also need to do the same with actual items, otherwise we wont detect changes to quantity so we ended up with:
const handler = {
set: function (obj, prop, value) {
obj[prop] = value
render()
return true
}
}
const proxied = new Proxy(items.map(item => new Proxy(item, handler)), handler)
the only last piece is to not forget that whenever we adding something to list we should also wrap it in proxy (technically we may override push method but somehow it behaves really weird)
so at the very end we have something like:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo</title>
</head>
<body>
<div id="root"></div>
<script>
HTMLElement.prototype.withAttribute = function (qualifiedName, value) {
if (typeof value === 'string') {
this.setAttribute(qualifiedName, value)
} else {
this[qualifiedName] = value
}
return this
}
HTMLElement.prototype.withAttributes = function (props) {
for (const [key, value] of Object.entries(props)) {
this.withAttribute(key, value)
}
return this
}
HTMLElement.prototype.withChild = function () {
for(const child of arguments) {
if (Array.isArray(child)) {
for(const item of child) {
this.append(item)
}
} else {
this.append(child)
}
}
return this
}
HTMLElement.prototype.withStyle = function (style, value) {
this.style[style] = value
return this
}
HTMLElement.prototype.withStyles = function (styles) {
for (const [key, value] of Object.entries(styles)) {
this.withStyle(key, value)
}
return this
}
HTMLElement.prototype.clear = function () {
while (this.firstChild) {
this.removeChild(this.lastChild);
}
return this
}
</script>
<script>
const catalog = [
{id: 1, name: 'product 1', price: 9.99},
{id: 2, name: 'product 2', price: 9.99},
{id: 3, name: 'product 3', price: 99.99},
]
// let items = [
// {id: 1, name: 'product 1', quantity: 1, price: 9.99},
// {id: 2, name: 'product 2', quantity: 2, price: 9.99},
// ]
const handler = {
// get: function (obj, prop) {
// console.log({proxy: 'get', obj, prop})
// return obj[prop]
// },
set: function (obj, prop, value) {
console.log({proxy: 'set', obj, prop, value})
obj[prop] = value
render()
return true
}
}
const data = [
{id: 1, name: 'product 1', quantity: 1, price: 9.99},
{id: 2, name: 'product 2', quantity: 2, price: 9.99},
]
// // hack to automatically wrap new items into proxy
// // fixme: instead use arguments keyword
// data.push = function (item) {
// console.log('push', item)
// return Array.prototype.push.apply(this, new Proxy(item, handler));
// }
// proxy all the things
const items = new Proxy(data.map(item => new Proxy(item, handler)), handler)
function handleFormSubmit(event) {
event.preventDefault()
const data = new FormData(event.target)
const id = parseInt(data.get('id')) // "2"
const found = catalog.find(i => i.id === id)
if (!found) {
console.warn(`${id} not found in catalog`)
return
}
const alreadyAdded = items.find(i => i.id === id)
if (alreadyAdded) {
alreadyAdded.quantity += 1
} else {
items.push(new Proxy({
...found,
quantity: 1
}, handler))
}
}
function handleDecrement(item) {
item.quantity -= 1
if (item.quantity < 1) {
items.splice(items.indexOf(item), 1)
}
}
function render() {
document.getElementById('root')
.clear()
.append(
document.createElement('H3').withChild('Shopping cart'),
items.length ? document.createElement('TABLE').withAttribute('border', '1').withChild(
document.createElement('THEAD').withChild(
document.createElement('TR').withChild(
document.createElement('TH').withChild('name'),
document.createElement('TH').withChild('quantity'),
document.createElement('TH').withChild('price'),
document.createElement('TH').withChild('total'),
document.createElement('TH'),
),
),
document.createElement('TBODY').withChild(
items.map(item => document.createElement('TR').withChild(
document.createElement('TD').withChild(item.name),
document.createElement('TD').withChild(item.quantity),
document.createElement('TD').withChild(item.price),
document.createElement('TD').withChild(item.price * item.quantity),
document.createElement('TD').withChild(
document.createElement('BUTTON').withChild('inc').withAttribute('onclick', () => item.quantity += 1),
document.createElement('BUTTON').withChild('dec').withAttribute('onclick', () => handleDecrement(item)),
document.createElement('BUTTON').withChild('x').withAttribute('onclick', () => items.splice(items.indexOf(item), 1)),
),
)),
),
) : document.createElement('P').withChild('Nothing here'),
document.createElement('FORM').withAttribute('onsubmit', handleFormSubmit).withChild(
document.createElement('P').withChild(
document.createElement('SELECT').withAttribute('name', 'id').withChild(
catalog.map(item => document.createElement('OPTION').withAttribute('value', item.id).withChild(item.name))
),
document.createElement('INPUT').withAttribute('type', 'submit').withAttribute('value', 'add'),
),
),
)
}
render()
</script>
</body>
</html>