Tabla de Contenidos

Proxies

Módulo perteneciente al curso 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:

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:

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:

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:

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:

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