Este post está basado en una mezcla de la traducción de los post del blog de Arbaz Siddiqui, y el blog de Eslam Hefnawy, javascrip.info
y del post en español de TodoJS y por su puesto de la documentación oficial en MDN
Introducción a Proxy
En términos de programación, proxy es cualquier entidad que actúa en nombre de alguna otra entidad. Un servidor proxy se encuentra entre un cliente y un servidor y actúa como cliente para el servidor y viceversa. El trabajo de cualquier proxy es interceptar las solicitudes / llamadas entrantes y reenviarlas en sentido ascendente. Esta intercepción permite que el proxy agregue lógica y cambie el comportamiento de las solicitudes entrantes y salientes.
El proxy Javascript es algo muy similar, se encuentra entre su objeto real y el código que intenta acceder a este objeto.
Nos permite hacer metaprogramación dinámica (metaprogramación: programas que escriben o manipulan otros programas).
Va a permitirnos interceptar las operaciones con objetos y sus propiedades de tal forma que podemos redefinir el comportamiento para cada una de estas acciones
De acuerdo con los documentos web de MDN:
El objeto Proxy se usa para definir comportamientos personalizados para operaciones fundamentales (por ejemplo, búsqueda de propiedades, asignación, enumeración, invocación de funciones, etc.).
Terminologías
Hay tres términos que necesitamos saber antes de poder implementar un proxy:
Objetivo (target)
Target es el objeto real que nuestro proxy interceptará. Este puede ser cualquier objeto de JavaScript.
Manejador (handler)
El manejador es un objeto donde viven todas las trampas.
Cada elemento de este objeto tiene como valor una función que implementa una trampa.
Trampas (trap)
Las trampas son métodos que interceptarán la llamada al destino cuando se llama a una propiedad o método. Hay muchas trampas definidas que se pueden implementar.
Los métodos que se pueden usar como trampas son:
Object methods:
- getPrototypeOf()
- setPrototypeOf()
- isExtensible()
- preventExtensions()
- getOwnPropertyDescriptor()
- ownKeys()
Property getters/setters:
- has()
- get()
- set()
- deleteProperty()
Function methods:
- apply()
- construct()
Ejemplo básico:
//movie is a target
const movie = {
name: "Pulp Fiction",
director: "Quentin Tarantino"
};
//this is a handler
const handler = {
//get is a trap
get: (target, prop) => {
if (prop === 'director') {
return 'God'
}
return target[prop]
},
set: function (target, prop, value) {
if (prop === 'actor') {
target[prop] = 'John Travolta'
} else {
target[prop] = value
}
}
};
const movieProxy = new Proxy(movie, handler);
console.log(movieProxy.director); //God
movieProxy.actor = "Tim Roth";
movieProxy.actress = "Uma Thurman";
console.log(movieProxy.actor); //John Travolta
console.log(movieProxy.actress); //Uma Thurman
El resultado de la ejecución del código anterior será:
God
John Travolta
Uma Thurman
En este ejemplo, nuestro objeto target era movie, implementamos un manejador con dos trampas: un get y un set.
Agregamos la lógica de que si estamos accediendo a la clave director, deberíamos devolver la cadena en God en lugar del valor real.
Del mismo modo, agregamos una trampa en el método set que interceptará todas las escrituras en el objeto de destino y cambiará el valor a John Travolta si la clave es actor.
Las posibilidades son infinitas
Casos de uso del mundo real
Aunque no es tan conocido como otras características de ES2015, Proxy tiene muchos usos.
Veremos escenarios del mundo real donde podemos usar proxies.
Validaciones
Como podemos interceptar las escrituras en un objeto, podemos hacer una validación del valor que estamos tratando de establecer en el objeto.
Por ejemplo:
const handler = {
set: function (target, prop, value) {
const houses = ['Stark', 'Lannister'];
if (prop === 'house' && !(houses.includes(value))) {
throw new Error(`House ${value} does not belong to allowed ${houses}`)
}
target[prop] = value
}
};
const gotCharacter = new Proxy({}, handler);
gotCharacter.name = "Jamie";
gotCharacter.house = "Lannister";
console.log(gotCharacter);
gotCharacter.name = "Oberyn";
gotCharacter.house = "Martell";
La ejecución del código anterior dará como resultado lo siguiente:
{ name: 'Jamie', house: 'Lannister' }
Error: House Martell does not belong to allowed Stark,Lannister
En este ejemplo, restringimos que el valor permitido para la propiedad house solo pueda ser una de las casas permitidas. Incluso podemos usar este enfoque para crear objetos de solo lectura, todo lo que necesitamos hacer es lanzarlo dentro de la trampa set.
Efectos secundarios
Podemos usar proxies para crear efectos secundarios en una propiedad de lectura / escritura. La idea es activar alguna función si se accede o se escribe una propiedad en particular.
Por ejemplo:
const sendEmail = () => {
console.log("sending email after task completion")
};
const handler = {
set: function (target, prop, value) {
if (prop === 'status' && value === 'complete') {
sendEmail()
}
target[prop] = value
}
};
const tasks = new Proxy({}, handler);
// ...otras tareas que al final llevan a poner un estado...
tasks.status = "complete";
La ejecución del código anterior dará como resultado el siguiente resultado:
sending email after task completion
Aquí estamos interceptando la escritura de la propiedad 'status' y si status está completa, estamos ejecutando una función de efectos secundarios.
Almacenamiento en caché
Como podemos interceptar el acceso a las propiedades del objeto, podemos construir en cachés de memoria para devolver solo valores de un objeto si no ha caducado.
Por ejemplo :
const cacheTarget = (target, ttl = 60) => {
const CREATED_AT = Date.now();
const isExpired = () => (Date.now() - CREATED_AT) > (ttl * 1000);
const handler = {
get: (target, prop) => isExpired() ? undefined : target[prop]
};
return new Proxy(target, handler)
};
const cache = cacheTarget({age: 25}, 5);
console.log(cache.age);
setTimeout(() => {
console.log(cache.age)
}, 4 * 1000);
setTimeout(() => {
console.log(cache.age)
}, 6 * 1000);
La ejecución del código anterior dará como resultado el siguiente resultado:
25
25 // a los 4 segundos
undefined // a los 6 segundos
Aquí hemos creado una función que devuelve un Proxy. El manejador de ese Proxy primero comprueba si el objeto ha caducado o no. Podemos ampliar esta funcionalidad para tener TTL basados en cada clave.
Otra aproximación puede ser usar ese TTL para pedir a una API (fetch) los datos una vez caducado.
Observar cambios en objetos
Como podemos interceptar el acceso a las propiedades del objeto, podemos crear nuevos elementos en DOM y renderizarlos cuando detectamos que una propiedad cambia o si se añade una nueva.
// NOTA IMPORTANTE!!
// En el DOM tenemos un div con id=“salida”
//
function render(prop) {
const salida = document.getElementById("salida");
if (!document.getElementById(prop)) {
const div = document.createElement("div");
div.id = prop;
salida.appendChild(div);
}
document.getElementById(prop).innerText = observables[prop];
}
handlerObservables = {
set: function (observable, prop, value) {
Reflect.set(observable, prop, value);
render(prop);
return true;
},
deleteProperty(observable, prop) {
const elem = document.getElementById(prop);
elem.parentNode.removeChild(elem);
Reflect.deleteProperty(observable, prop);
return true;
}
};
const object = {};
const observables = new Proxy(object, handlerObservables);
observables["prueba"] = "valor";
// observables['otro'] = 'otro valor';
// delete observables['prueba'];
Si añadimos al objeto observables nuevos elementos, estos se irán añadiendo en el DOM dentro de la capa con id salida.
Si eliminamos del objeto elementos, estos se irán eliminando también del DOM.
Data binding
El enlace de datos suele ser difícil de lograr debido a su complejidad. El uso de proxies para lograr un enlace de datos bidireccional se puede ver en algunas “librerías” MVC en JavaScript, donde un objeto se modifica cuando el DOM sufre un cambio.
En pocas palabras, el binding de datos es una técnica que une varias fuentes de datos para sincronizarlas.
Supongamos que hay un con el id de username.
<input type = "text" id = "username" />
Digamos que se desea mantener el valor de esta entrada sincronizado con una propiedad de un objeto.
const inputState = { id : 'nombre de usuario' , valor : '' }
Es bastante fácil modificar el valor de inputState cuando el valor del input cambia escuchando el evento ‘change’ del input y luego actualizando el valor de inputState. Sin embargo, lo contrario, actualizar el input cuando el valor de inputState se modifica, es más complicado a priori.
Un Proxy puede ayudar a lograrlo.
const input = document.querySelector('#username');
const handler = {
set: function(target, key, value) {
if (target.id && key === 'username') {
Reflect.set(target, value);
document.querySelector(`#${target.id}`)
.value = value;
return true;
}
return false;
}
}
const proxy = new Proxy(inputState, handler)
proxy.value = 'John Doe'
console.log(proxy.value, input.value)
// 'John Doe' will be printed for both
De esta manera, cuando haya cambio en inputState, el input reflejará el cambio que se ha realizado.
Combinado con escuchar el evento ‘change’, esto producirá doble data binding simple de input e inputState.
Si bien este es un caso de uso válido, generalmente no se recomienda por performance.
Convertir un array de objetos en agrupable
Este quizás sea el ejemplo más complejo, donde se anidan dos Proxy para poder agrupar por el campo que indiquemos.
Partimos del siguiente json con datos sobre características de procesadores:
const procesadores2020 = [
{
"procesador": "Athlon 200GE",
"nucleos": "2",
"hilos": "4",
"frecuencia-min": "3.2GHz",
"frecuencia-max": "3.2GHz",
"precio": "66.18 €"
},
{
"procesador": "Core i3-9100F",
"nucleos": "4",
"hilos": "4",
"frecuencia-min": "3.6 Ghz",
"frecuencia-max": "4.2 Ghz",
"precio": "67.99 €"
},
{
"procesador": "Ryzen 3 3100",
"nucleos": "4",
"hilos": "8",
"frecuencia-min": "3.6 Ghz",
"frecuencia-max": "3.9 Ghz",
"precio": "105.58 €"
},
{
"procesador": "Ryzen 5 2600X",
"nucleos": "6",
"hilos": "12",
"frecuencia-min": "3.6 Ghz",
"frecuencia-max": "4.2 Ghz",
"precio": "136.35 €"
},
{
"procesador": "Core i5-10400F",
"nucleos": "6",
"hilos": "12",
"frecuencia-min": "2.9 Ghz",
"frecuencia-max": "4.3 Ghz",
"precio": "149.89 €"
},
{
"procesador": "Ryzen 5 3600",
"nucleos": "6",
"hilos": "12",
"frecuencia-min": "3.6 Ghz",
"frecuencia-max": "4.2 Ghz",
"precio": "200.80 €"
},
{
"procesador": "Ryzen 7 2700X",
"nucleos": "8",
"hilos": "16",
"frecuencia-min": "3.7 Ghz",
"frecuencia-max": "4.3 Ghz",
"precio": "207.59 €"
},
{
"procesador": "Core i7-10700K",
"nucleos": "8",
"hilos": "16",
"frecuencia-min": "3.8 Ghz",
"frecuencia-max": "5.1 Ghz",
"precio": "384.90 €"
},
{
"procesador": "Ryzen 7 3700X",
"nucleos": "8",
"hilos": "16",
"frecuencia-min": "3.6 Ghz",
"frecuencia-max": "4.4 Ghz",
"precio": "309.95 €"
},
{
"procesador": "Core i9-10850K",
"nucleos": "10",
"hilos": "20",
"frecuencia-min": "3.6 Ghz",
"frecuencia-max": "5.2 Ghz",
"precio": "486.00 €"
},
{
"procesador": "Ryzen 9 3900X",
"nucleos": "12",
"hilos": "24",
"frecuencia-min": "3.8 Ghz",
"frecuencia-max": "4.6 Ghz",
"precio": "443.90 €"
},
{
"procesador": "Ryzen 9 3950X",
"nucleos": "16",
"hilos": "32",
"frecuencia-min": "3.5 Ghz",
"frecuencia-max": "4.7 Ghz",
"precio": "758.87 €"
},
{
"procesador": "Ryzen Threadripper 3970X",
"nucleos": "32",
"hilos": "64",
"frecuencia-min": "3.7 Ghz",
"frecuencia-max": "4.5 Ghz",
"precio": "2099.00 €"
}
];
Si queremos poder agrupar por los campos de los objetos del array este sería el código.
const groupable = (collection) => {
// Comprueba que la colección sea un array
if (!(collection instanceof Array)) {
throw new TypeError("The input collection is not an Array");
}
let grouped = {};
Object.defineProperty(collection, "groupBy", {
configurable: true,
enumerable: false,
writable: false,
value: {}
});
return new Proxy(collection, {
get(target, property, receiver) {
if (property === "groupBy") {
return new Proxy(target[property], {
get(target, property, receiver) {
// si la propiedad a agrupar no existe devolver []
if (!collection[0].hasOwnProperty(property)) {
console.log('no encontrado')
return [];
}
// caso contrario, agrupar por la propiedad
const output = {};
collection.groupBy[property] = {};
grouped[property] = {};
collection.reduce(function(acc, cur) {
if (!Array.isArray(acc[cur[property]])) {
acc[cur[property]] = [];
}
acc[cur[property]].push(cur);
return acc;
}, output);
grouped[property] = {...output};
return grouped;
}
});
}
return Reflect.get(target, property, receiver);
}
});
};
const datasource = groupable(procesadores2020);
console.log(datasource.groupBy['hilos']);
console.log(datasource.groupBy['frecuencia-max']);
Cuando declaramos el array como “groupable” llamando a la función con el mismo nombre y pasandole el array, lo primero que hace es crear una nueva propiedad llamada “groupBy”, convirtiendo el array y devuelve un Proxy que intercepta el get del array.
Podemos decir que hemos ampliado los métodos del array.
De esta manera si llamamos a cualquier propiedad 0, 1, 2… devolverá el objeto correspondiente a esa posición.
Si llamamos a groupBy nos devuelve otro Proxy que tiene otra trampa en get de manera que a partir del campo que recibe, recorre el array y los agrupa por el campo pasado y devuelve el array agrupado.
Esta manera es la manera de añadir funcionalidad a objetos de javascript sin tener que tocar su 'prototipo'.
Por ejemplo, en String tenemos el método toLowercase() y toUppercase() pero no tenemos el método capitalize(). Si queremos añadir a String el método capitalize podemos hacerlo modificando la cadena protypica del tipo primitivo string:
String.prototype.capitalize = function() {
const str = [...this];
str[0] = str[0].toUpperCase();
return str.join('');
}
console.log('buenos días'.capitalize()); // Buenos días
De esta manera, todos los String que se creen a partir de esa declaración tendrán un nuevo método 'capitalize' que convierte la primera letra en mayúscula.
Si esto lo hacemos mediante Proxy, al no ser String un objeto sino un tipo primitivo, tenemos que convertir el String en Object, con lo que perdemos los métodos de String:
const addCapitalize = function(value) {
const arrStr = [...value];
arrStr.capitalize = function() {
arrStr[0] = arrStr[0].toUpperCase();
return arrStr.join('');
}
return new Proxy(arrStr, {
get(target, property, receiver) {
let value = new String(arrStr.join(''));
if (property === 'capitalize') {
value = target[property];
}
return value;
}
});
}
const saludo = addCapitalize('buenos días');
console.log(saludo.capitalize());
console.log(saludo.toUpperCase()); // ERROR: perdemos el resto de métodos de String...
Con lo que está no parece ser la manera de ampliar métodos en tipos primitivos.
Inconvenientes de los Proxies
Si bien los proxies son bastante “mágicos”, hay algunos inconvenientes con ellos por que debemos tener cuidado.
El rendimiento puede tener un impacto drástico cuando se usan muchos proxies y, por lo tanto, debe evitarse al escribir un código donde el rendimiento sea crítico.
Dado un objeto, no hay forma de saber si se trata de un objeto Proxy u objetivo.
Por último, los servidores Proxy no necesariamente conducen a un código muy limpio y fácil de entender.
Conclusión
Los proxies son increíblemente poderosos y se pueden usar y abusar para una amplia gama de cosas.
Top comments (3)
Muy muy informativo
Excelente artículo!
Gracias Pablo!