====== Proxies ======
Módulo perteneciente al curso [[informatica:programacion:cursos:programacion_avanzada_javascript|Programación avanzada con JavaScript y ECMAScript]].
===== Introducción =====
Una de las características nuevas más desconocidas para muchos desarrolladores que incorpora ECMAScript 2015 son los //proxies//.
Un //proxy// es un objeto que permite **interceptar las operaciones** (tales como llamar a un método o consultar una propiedad) que se realizan sobre un objeto y **realizar determinadas acciones**.
Los //proxies// permiten escenarios avanzados, imposibles en ES5, ya que pueden interceptar llamadas a métodos o propiedades realizados contra otros objetos, de forma totalmente transparente para quien realiza esas operaciones.
Las principales aplicaciones de los proxies son todas avanzadas y no se usarán a menudo, pero son una herramienta muy útil para conseguir, entre otras cuestiones avanzadas:
* La intercepción de llamadas a propiedades y métodos de objetos para modificar su comportamiento.
* El control y autorización de llamadas para seguridad y gestión del uso de objetos y funciones.
* Traza y log para seguimiento de llamadas de código.
* Creación de contratos para uso de código.
* La virtualización de objetos de modo que podamos crear objetos virtuales que utilicen a otros objetos por debajo pero modificando su comportamiento o actuando sobre varios objetos o propiedades al mismo tiempo.
En este módulo vamos cómo podemos crear los proxies y qué escenarios nos permiten solucionar.
Los proxies **requieren soporte del motor de JavaScript** por lo que **no se pueden transpilar ni usar ningún //polyfill// para simularlos**.
===== Creación de proxies =====
Crear un proxy no es muy complicado. Lo primero es tener presente que, dado que un proxy intercepta operaciones sobre un objeto, vamos a necesitar lógicamente un objeto al que interceptar. Este objeto no tiene que tener nada especial:
let obj = {
get value() { return 42;}
}
Hemos definido un objeto ''obj'' con un //getter// (''value'') que devuelve el valor ''42''. Vamos ahora a crear un proxy que intercepte las llamadas a este //getter// y que imprima por consola que alguien ha llamado al //getter//:
let proxy = new Proxy(obj,
{
get(target, key, receiver) {
console.log(`Prop ${key} was called`);
return target[key];
}
});
let v = proxy.value;
console.log(v);
Vamos a diseccionar el código. **Para crear un proxy debemos crear un objeto del tipo ''Proxy''**. Para ello **debemos pasar dos parámetros**:
* **El objeto cuyas operaciones queremos interceptar**
* **Un objeto que indica qué acciones queremos interceptar** y qué queremos hacer cuando las interceptemos.
En este caso el objeto pasado (que se suele denominar //handler//) indica que queremos interceptar todas las operaciones que impliquen llamar a un //getter//.
Luego en la intercepción nos limitamos a imprimir por la consola cuál es la propiedad accedida y devolvemos el valor correcto de dicha propiedad (podríamos en efecto devolver cualquier otra cosa si quisiéramos).
Finalmente llamamos al //getter// ''value'' del proxy. Observa como el proxy no tiene definido dicho getter, pero se intercepta la operación y se ejecuta el código del proxy, el cual puede (si quiere) terminar llamando al objeto final.
Es importante observar que el //getter// (''value'') se llama sobre el proxy, no sobre el objeto real.
Enseguida lo vamos a ver en la práctica y se entenderá mucho mejor. Pero antes analicemos las posibles intercepciones en los proxies.
===== Intercepciones básicas =====
Vamos a ver qué podemos interceptar con un proxy. Las intercepciones básicas son llamadas a //getters// de propiedad, a //setters// y a métodos.
==== Getters de propiedad ====
Ya lo hemos visto en la lección anterior: basta con definir un método ''get'' en el //handler// del proxy. Este método recibe tres parámetros:
* ''target'': es el propio objeto interceptado por el proxy
* ''key'': el nombre de la propiedad
* ''receiver'': el valor de la propiedad en el objeto
Así p. ej. podríamos crear un proxy que crease una propiedad "fantasma" ''json'' que devolviese una representación en json del objeto:
let obj = {v: 42};
let proxy = new Proxy(obj, {
get (target, key, receiver) {
if (key === "json") {
return JSON.stringify(target);
}
else {
return target[key];
}
}
});
let str = proxy.json; // "{"v":42}"
==== Setters de propiedad ====
Para interceptar un //setter// de una propiedad el //handler// debe definir un método ''set''. Los parámetros de este método set son los mismos que el del método ''get'' usado para interceptar un //getter//, con la excepción de que ''receiver'' ahora es el nuevo valor que se quiere asignar a la propiedad (no el que tiene actualmente asignado el objeto en esa propiedad, como antes).
Vamos a crear un proxy que obligue a que todas las propiedades de un objeto tengan un valor de tipo cadena:
let obj = {v: "una cadena"};
let proxy = new Proxy(obj, {
set (target, key, receiver) {
if (typeof(receiver) === "string") {
target[key] = receiver;
}
}
});
proxy.v = 42;
proxy.v; // Sigue teniendo el valor original, "una cadena", pues no se puede asignar un número
proxy.v = "ahora sí";
proxy.v; // "ahora sí"
El uso de proxies es muy sencillo y nos da una potencia brutal, permitiéndonos realizar tareas que sin ellos serían mucho más difíciles.
==== Llamadas a métodos ====
Para llamar a métodos se usa también el método ''get'' del //handler//. De hecho JavaScript no distingue entre //getters// de propiedades y llamadas a métodos.
Cuando tenemos un código tipo ''o.f()'', primero se llama al //getter// de la propiedad ''f'' del objeto ''o'' y si el valor devuelto es una función se invoca a dicha función (en caso contrario el motor de JavaScript da un error). Así, lo que debemos tener claro es que en el //handler// debemos devolver una función, porque sino, éste recibirá un error:
var obj = {
v: 42,
inc(value) {this.v += value; return this.v; }
}
let proxy = new Proxy(obj, {
get (target, key, receiver) {
var prop = target[key];
if (typeof(prop) === "function") {
return function(...args) {
let result = prop.apply(this, args);
console.log(`${key}(${JSON.stringify(args)}) -> ${result != undefined ? JSON.stringify(result) : undefined}`);
return result;
}
}
else return target[key];
}
});
Este código intercepta todas las llamadas a todos métodos y las imprime por la consola. Observa el uso de ''...args'' en la función devuelta por el proxy. Al tener un solo parámetro rest, eso lo que hace es que ''args'' sea un array con los valores pasados a la función.
De este modo cuando hacemos ''proxy.inc(10)'' sucede lo siguiente:
* Se llama al método ''get'' del //handler//
* Este método obtiene el valor de la propiedad indicada (''inc'')
* Si la propiedad es una función (en este caso lo es), devuelve otra función que llama a la función del objeto original (usando ''apply'' y luego imprime la llamada por la consola (y finalmente devuelve el resultado de haber llamado a la función original). La idea es que llamar a ''proxy.inc(10)'' termina llamando realmente a la función interna (que tiene el parámetro //rest// ''args'') con el valor de ''10''. Por lo tanto dentro de esa función el valor de ''args'' es un array con un elemento: ''10''. Así podemos usar fácilmente ''apply'' para obtener el valor real y luego hacer lo que queramos.
* Si la propiedad llamada no fuese una función (p. ej. hiciéramos ''proxy.v'') pues directamente se devuelve el valor de invocar a dicha propiedad en el objeto real.
===== DEMO: Interceptando un getter a propiedad =====
Lo importante de los //proxies// es entender que envuelven un objeto y luego nos permiten interceptar llamadas a las propiedades y métodos del objeto y terminar delegando esta llamada a la propiedad real del objeto o devolver cualquier otra cosa a quien hace la llamada.
Partimos de un objeto sencillo:
var obj = {
prop: 42
}
Ahora podemos tener una función así:
function foo(p) {
console.log(p.prop)
}
Al usarla:
foo(obj); // 42
Vamos a crear un proxy para envolver el objeto:
let proxy = new Proxy(obj, {
get(target, key, receiver) {
console.log(target, key, receiver);
}
})
Ahora a la función debemos pasarle el proxy, no el objeto original:
foo(proxy); // Object { prop: 42 } prop Object { prop: 42 }
Modificamos el proxy:
let proxy = new Proxy(obj, {
get(target, key, receiver) {
if (key == "prop") {
return target[key];
} else {
return NaN;
}
}
})
foo(proxy); // 42
Si modificamos la función ''foo'' tal que así:
function foo(p) {
console.log(p.prop)
console.log(p.cualquierCosa);
}
Y ejecutamos:
foo(proxy); // 42
// NaN
Hemos visto cómo el proxy nos ha permitido envolver de manera sencilla un objeto y expandir su comportamiento sin necesidad de tocar el objeto original.
===== Ejercicio propuesto =====
Se propone el siguiente ejercicio: Usa proxies para ocultar automáticamente de un objeto todas aquellas propiedades que empiecen por un subrayado. Es decir, dado un objeto:
let obj = {
_pr: 10,
pu: 20
};
Se quiere que la propiedad ''_pr'' no sea accesible haciendo ''obj._pr''. Intentar acceder a la propiedad para leerla debe devolver ''undefined'' y acceder para modificar su valor debe ser ignorado.
Tienes la solución en el siguiente apartado y en el fichero ''privateHidder.js'' pero inténtalo antes por tu cuenta.
==== DEMO: Solución al ejercicio de ocultar variables privadas ====
Partamos de:
var obj = {
_v: 10,
set v(value) {
// Solo para valores positivos
if (value > 0) {
this._v = value;
}
}
}
Probamos:
obj.v = 1200;
obj._v = -10;
console.log(obj._v); // -10
Ahora, vamos a usar un proxy para ocultar una variable privada y que no podamos ejecutar lo anterior.
const hidePrivates = function(obj) {
const handler = {
get (target, key, receiver) {
if (key.substr(0, 1) === "_") {
return undefined;
} else {
return target[key];
}
},
set (target, key, receiver) {
if (key.substr(0, 1) !== "_") {
target[key] = receiver;
}
}
}
return new Proxy(obj, handler);
}
Vamos a usarlo:
let test = hidePrivates(obj);
console.log(test._v); // undefined
test.v = 1200;
test._v = 100;
console.log(obj._v); // 1200
===== Proxies a objetos de tipo función =====
Ya hemos visto cómo con el método ''get'' podemos interceptar una llamada a una función del objeto envuelto por el proxy. Pero además existe otra posible intercepción y es cuando creamos un **proxy a una función**.
Y es que, en JavaScript, las funciones son un tipo más de objeto, así que crear un proxy a una función no debe resultarte sorprendente.
Con un proxy a una función podemos interceptar todas las llamadas a dicha función y ejecutar código antes y/o después de ejecutar la función envuelta por el proxy (aunque también podríamos optar por ni tan siquiera ejecutarla y devolver cualquier otro valor).
Para crear un proxy a función en el handler se define el método apply. Este método recibe los siguientes parámetros:
* ''target'': Como en los otros casos contiene el objeto envuelto por el proxy
* ''thisArg'': El valor de ''this'' usado
* ''argumentsList'': Un array con los argumentos. Si no se pasan argumentos es un array vacío (no vale nunca ''undefined'').
Veamos un ejemplo:
let foo = function(...values) {
let result = 0;
values.forEach(s => result += s);
console.log('Sum of values is ' + result);
}
let proxy = new Proxy(foo, {
apply(target, thisArg, argumentsList) {
let args = argumentsList.map(arg => typeof(arg) === "number" ? arg : 0);
return target.apply(thisArg, args);
}
});
proxy(10,20,"30");
El proxy intercepta todas las llamadas a ''foo'' y sustituye por 0 todos los parámetros no numéricos. Es por ello que la llamada ''proxy(10, 20, "30")'' devuelve 30 (mientras que ''foo(10, 20, "30")'' devuelve "3030".
==== Interceptando call, apply y bind ====
El uso de ''call'' y ''apply'' se intercepta también de forma automática. Dado el mismo código anterior una llamada a ''proxy.call'' o ''proxy.apply'' es interceptada correctamente (y en este caso el parámetro ''thisArg'' toma el valor correspondiente):
proxy.apply([10], [10, 20, "30"]);
proxy.call([20], 10, 20, "30");
En ambos casos se intercepta la llamada (se ejecuta el método ''apply'' del //handler//). En el primer caso el valor de ''thisArg'' es ''[10]'' y en el segundo es ''[20]''. En ambos casos ''argumentsList'' es ''[10, 20, "30"]'' (un array).
Del mismo modo dado un proxy a una función, si usamos ''bind'' para obtener otra función atada permanentemente a otro valor de ''this'', las llamadas a esa otra función también son capturadas:
let proxy2 = proxy.bind("otherhis");
proxy(10, 20, "30"); // Se captura la llamada, thisArg vale "otherthis" (y devuelve 30 y no "3030").
En resumen, con los proxies podemos interceptar llamadas a funciones de forma sencilla.
Las posibilidades son muy amplias: validación de precondiciones y/o postcondiciones, auditorías de llamadas a funciones, enrutamiento dinámico de funciones... son escenarios que con ES5 ya se podían realizar, pero que requerían mucho más código y no eran, ni de lejos, tan transparentes ni sencillos como con los proxies.
Vamos a ver a continuación un caso práctico de uso.
===== DEMO: Creando un "congelador" de objetos - Parte 1 =====
Vamos a tener un objeto que nos permitirá generar proxies de objetos y poder congelar el objeto, que las llamadas a los //setters// no sean válidas.
Empezaremos creando una clase que utilizaremos para obtener los proxies de un determinado objeto:
// Utilizaremos un WeakMap para guardar las variables privadas
// de los objetos de tipo Freezer
const _privates = new WeakMap();
class Freezer {
constructor(obj) {
let pr = {};
pr.frozen = false; // el objeto está congelado?
_privates.set(this, pr)
pr.proxy = new Proxy(obj, {});
}
// Método para "congelar" o "descongelar" el objeto
frozen(value) {
let pr = _privates.get(this);
pr.frozen = value ? true : false;
}
get value() {
let pr = _privates.get(this);
return pr.proxy;
}
}
Vamos a utilizarla:
var obj = {v: 42};
var freezer = new Freezer(obj);
var proxy = freezer.value();
obj.v = 100;
proxy; // Object { v: 100 }
Ahora tendremos que añadir la funcionalidad para congelar el objeto.
===== DEMO: Creando un "congelador" de objetos - Parte 2 =====
// Utilizaremos un WeakMap para guardar las variables privadas
// de los objetos de tipo Freezer
const _privates = new WeakMap();
const createConfig = function(pr) {
return {
set (target, key, receiver) {
var prop = target[key];
if (pr.frozen) {
return prop.
} else {
target[key] = receiver;
return receiver;
}
}
}
}
class Freezer {
constructor(obj) {
let pr = {};
pr.frozen = false; // el objeto está congelado?
_privates.set(this, pr)
pr.proxy = new Proxy(obj, {});
}
// Método para "congelar" o "descongelar" el objeto
frozen(value) {
let pr = _privates.get(this);
pr.frozen = value ? true : false;
}
get value() {
let pr = _privates.get(this);
return pr.proxy;
}
}
Probamos:
var obj = {v:42};
var freezer = new Freezer(obj);
var proxy = freezer.value;
proxy; // Object { v: 42 }
// Congelamos el objeto:
freezer.frozen(true);
// Comprobemos que realmente está congelado:
prop.v = 100;
proxy.v; // 42
// Para descongelarlo:
freezer.frozen(false);
prop.v = 200;
proxy.v; // 200
===== DEMO: Creando un "congelador" de objetos - Parte 3 =====
Después de lo anterior, vamos a agregar un método ficticio al proxy que llamándolo nos permita congelar el propio objeto.
Queremos lograr hacer algo como ''proxy[Freezer.freeze](true);''.
Vamos a ello:
// Utilizaremos un WeakMap para guardar las variables privadas
// de los objetos de tipo Freezer
const _privates = new WeakMap();
// Para asegurarnos de que el nombre del método que crearemos
// en el proxy es único y no tenga conflictos, utilizaremos un símbolo
const _freeze = Symbol("freeze");
const createConfig = function(pr) {
return {
set (target, key, receiver) {
var prop = target[key];
if (pr.frozen) {
return prop.
} else {
target[key] = receiver;
return receiver;
}
},
get (target, key, receiver) {
if (key === _freeze) {
return function(v) {
pr.frozen = v ? true : false;
return pr.frozen;
}
} else {
return target[key];
}
}
}
}
class Freezer {
constructor(obj) {
let pr = {};
pr.frozen = false; // el objeto está congelado?
_privates.set(this, pr)
pr.proxy = new Proxy(obj, {});
}
// Método para "congelar" o "descongelar" el objeto
frozen(value) {
let pr = _privates.get(this);
pr.frozen = value ? true : false;
}
get value() {
let pr = _privates.get(this);
return pr.proxy;
}
// getter para obtener el símbolo
static get freeze() {
return _freeze;
}
}
Probamos:
var obj = {v:42};
var freezer = new Freezer(obj);
var proxy = freezer.value;
// Congelamos:
proxy[Freezer.freeze](true); // true
proxy.v = 100;
proxy,.v; // 42
obj.v; // 42
// Descongelamos:
freezer.frozen(false);
proxy.v = 100;
proxy.v; // 100
obj.v; // 100
===== Recursos =====
* [[https://developer.chrome.com/blog/es2015-proxies| Introducing ES2015 proxies]]
* [[https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Proxy#Extending_constructor|Extendiendo un constructor con un Proxy]] (MDN)
* [[https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Proxy#manipulando_nodos_del_dom|Manipulando nodos del DOM]] (MDN)