JavaScript Proxy Image

Introducción

Los Proxies en JavaScript son, sin lugar a dudas, una característica poco utilizada en JavaScript. Al menos eso se desprende de la encuesta anual The State of JS.

La verdad es que es una herramienta muy potente, que en ocasiones nos puede ayudar a tener unas clases más sencillas, abstrayendo funcionalidad extra mediante lo que se conoce como manejador o interceptor.

Su uso principal, puede venir condicionado en el caso de que por cuestiones de diseño, queramos evitar el tener que definir clases para realizar operaciones muy específicas, es más, aunque no es exactamente lo mismo, nos puede servir como aproximación para aplicar una especie de decoradores, mientras éstos llegan realmente a JavaScript.

¿Y que es lo que nos permite hacer un interceptor? Pues bien, con él podemos capturar el instante en que se va a guardar o leer un atributo, interceptar cuando se añade o elimina un atributo, etc.

A los métodos encargados de hacer esas capturas se les llama trap, ya que "atrapan la llamada al objeto destino".

Primer ejemplo: interceptar una operación get

Para no aburriros con tanta teoría, veamos un ejemplo práctico muy sencillo. Vamos a imaginar que queremos evitar que los usuarios y usuarias que no tengan permisos de administración puedan ver la contraseña. Pues con este sencillo código lo tendríamos:

const interceptor = {
  // Intercepta el momento en que se quiere leer un atributo del objeto.
  get(target, prop) {
    // Esta sencilla condición verifica que recibamos cualquier atributo que no sea password, y
    // que en el caso de recibir éste, para devolver el valor se sea adminitrador o administradora.
    if (prop !== "password" || target.isAdmin) {
    	return Reflect.get(target, prop);
    }
    
    return null;
  }
};

const proxy1 = new Proxy({
  name: "perico_palotes",
  isAdmin: true,
  password: "contaseña1"
}, interceptor);

// Como es admin, en consola veremos: "contraseña1"
console.log(proxy1.password);

const userNoAdmin = {
  name: "juanita_banana",
  isAdmin: false,
  password: "contraseña2"
};

const proxy2 = new Proxy(userNoAdmin, interceptor);

// Este atributo no es una contraseña, por lo que en consola veremos: "juanita_banana"
console.log(proxy2.name);
// Como no es admin, en consola veremos: "null"
console.log(proxy2.password);

// Importante, debemos usar el objeto proxy, ya que si usamos el objeto original, no se aplican las restricciones
// Esto mostraría por consola: "contraseña2"
console.log(userNoAdmin.password);

Analicemos poco a poco el código anterior:

  • El interceptor es un objeto que según los métodos que defina atrapará un momento de ejecución del código sobre el objeto sobre el que realizar el proxy. Cabe destacar, que se pueden usar clases, siendo este código de interceptor equivalente al que habéis visto en el ejemplo:
class Interceptor {
  get(target, prop) {
    if (prop !== "password" || target.isAdmin) {
    	return Reflect.get(target, prop);
    }
    
    return null;
  }
}

const interceptor = new Interceptor();
  • El uso de Reflect, es por aportar robustez, ya que podríamos haber escrito return target[prop], teniendo el mismo resultado. En otro artículo si queréis podemos comentar las ventajas de usar Reflect vs Object.
  • Para utilizar un Proxy, se crea una instancia de este a la que se le pasa el objeto para el que queremos atrapar sus operaciones, junto con el objeto del interceptor. La instancia que devuelve es la que deberemos utilizar.
  • A nivel de llamadas al interceptor podéis comprobar como entra en juego la condición que especificamos.
  • Finalmente, se pone como ejemplo, que en el momento de llamar al objeto original, no se tendrán en cuenta las reglas de interceptor. Esto es muy importante, puesto que si terminamos utilizando el objeto erróneo, perderemos ese control intermedio.

Segundo ejemplo: interceptar una operación set

Ahora veamos un ejemplo, de lo que podría ser el inicio de un mini-framework tipo React. En este caso queremos que cuando se cambie un atributo de la clase, ésta se vuelva a renderizar:

class ViewInterceptor {
  set(obj, prop, value) {
    // En el momento en que cambia el valor de una propiedad de la clase, se vuelve a lanzar el render
    if (obj[prop] !== value) {
      Reflect.set(obj, prop, value);
      // En este caso, sabemos que el interceptor va a recibir objetos de un tipo determinado,
      // con ello podríamos llamar a un método bien conocido. Se podrían incluir comprobaciones antes
      // de llegar aquí para saber si realmente hay un método render en el objeto recibido.
      obj.render();
    }

    // Se devuelve true para indicar que el set ha terminado correctamente.
    return true;
  }
}

class BaseView {
  state;
  
  constructor() {
    const self = this;
    const viewInterceptor = new ViewInterceptor();

    // Jugamos con un truco de JavaScript para que al instanciar la clase, se obtenga en realidad la
    // instancia del proxy, en lugar de la instancia de la clase.
    const viewProxy = new Proxy(this, viewInterceptor);
    return viewProxy;
  }
  
  setState(newState) {
    this.state = newState;
    console.log("Cambiado el estado a", this.state);
  }
  
  render = () => {
    console.log("Haciendo un render");
  }
}

const viewWithProxy = new Base View();

viewWithProxy.setState({
  name: "John",
  loading: true
});

// Tras haces este set de estado, veremos lo siguiente por la consola:
// 'Cambiado el estado a' { name: 'John', loading: true }
// 'Haciendo un render'

// Ahora vamos a probar a introducir dos veces el mismo estado...
const newState = {
  name: "Jane",
  loading: false
};

viewWithProxy.setState(newState);

// En la consola tendremos:
// 'Cambiado el estado a' { name: 'Jane', loading: false }
// 'Haciendo un render'

// Ahora ponemos el mismo estado:
viewWithProxy.setState(newState);

// Teniendo por consola:
// 'Cambiado el estado a' { name: 'Jane', loading: false }

Como hemos visto en el ejemplo anterior, interceptamos el momento en el que se intenta establecer el valor de un atributo. Y cuando el valor de este, es el mismo, se evita llamar a render de nuevo.

Tercer ejemplo: definir propiedades “virtuales”

const car = {
  brand: "Renault",
  model: "Megane",
  registration: "1234-ASDF"
}

const carProxy = new Proxy(car, {
  get(obj, prop) {
    if (prop === "information") {
      return `${obj.brand} ${obj.model} ${obj.registration}`;
    }
    
    return Reflect.get(obj, prop);
  },

  set(obj, prop, value) {
    if (prop === "information") {
      const informationParts = value.split(" ");
      obj.brand = informationParts[0];
      obj.model = informationParts[1];
      obj.registration = informationParts[2];
    } else {
      Reflect.set(obj, prop, value);
    }

    return true;
  },
});

console.log(carProxy.information);
// Mostrará por consola: "Renault Megane 1234-ASDF";

carProxy.information = "Ford Focus 4321-FDSA";

console.log(carProxy.brand);
// Mostrará por consola: "Ford";
console.log(carProxy.model);
// Mostrará por consola: "Focus";
console.log(carProxy.registration);
// Mostrará por consola: "4321-FDSA";

En esta ocasión hemos aprovechado para añadir lógica extra como si existiera una propiedad llamada "information" en el objeto Car. De esta forma podemos llamar a dicha propiedad como si realmente estuviera definida.

Algunos métodos trap útiles

apply(obj, method, parameters)

Este trap es un tanto especial pues sirve para interceptar llamadas a funciones.

const interceptor = {
  apply(target, thisInstance, parameters) {
    console.log(`FUNCTION CALL ${target} ${parameters}`);
    return target(parameters[0]);
  }
};

function isPair(value) {
  return !(value % 2);
};

const isPairProxy = new Proxy(isPair, interceptor);

isPairProxy(2);
// En consola:
// 'FUNCTION CALL function isPair(value) {
//    return !(value % 2);
// } 2'

isPairProxy(5);
// En consola:
// 'FUNCTION CALL function isPair(value) {
//    return !(value % 2);
// } 5'

get(obj, prop)

Se ejecuta en el momento en que se quiere leer un atributo de la clase. Como parámetros recibe el objeto interceptado, y el nombre del atributo.

set(obj, prop, value)

Intercepta el cambio de valores en los atributos de un objeto. Recibe como primer parámetro el objeto interceptado, como segundo parámetro el nombre del atributo y como tercer parámetro el valor a guardar.

deleteProperty(obj, prop)

Captura el instante en que se ejecuta el borrado de una propiedad del objeto, por ejemplo, delete myObject.myProperty. Recibe el objeto y el nombre de la propiedad.

ownKeys(obj)

Se ejecutará cuando se use Object.keys(), o por ejemplo lancemos un for...in. Recibe el objeto.

has(obj, prop)

En este caso, el método se ejecutará cuando llamemos a una sentencia in (menos el bucle for..in). Por ejemplo "property" in myObject.

defineProperty(obj, prop, descriptor)

Llamado en el momento en que se declara una nueva propiedad para el objeto. Hay que tener en cuenta, que si hay un trap para set, no se llegará a llamar a este método. Cuando definamos la propiedad directamente, en ese caso sí se llamará cuando usamos el método defineProperties de Object.

const interceptor = {
  set(obj, prop, value) {
    console.log(`SET ${prop} => ${value}`);
    return true;
  },
  defineProperty(obj, prop, descriptor) {
    console.log(`DEFINE_PROPERTY ${prop}`)
    return true;
  }
};

const digimon = {};
const proxy = new Proxy(digimon, interceptor);

proxy.name = "Agumon";
// En consola: 'SET name => Agumon'

Object.defineProperties(proxy,  {
  type: {
    value: "Fuego",
    writable: false
  }
});
// En consola: 'DEFINE_PROPERTY type'

Si al código anterior le quitamos el trap de set, veríamos lo siguiente:

const interceptor = {
  defineProperty(obj, prop, descriptor) {
    console.log(`DEFINE_PROPERTY ${prop}`)
    return true;
  }
};

const digimon = {};
const proxy = new Proxy(digimon, interceptor);

proxy.name = "Agumon";
// En consola: 'DEFINE_PROPERTY name'

Object.defineProperties(proxy,  {
  type: {
    value: "Fuego",
    writable: false
  }
});
// En consola: 'DEFINE_PROPERTY type'

construct(constructorType, parameters)

Este trap permite captura el momento de instanciación de una clase, es ideal para simular decoradores sobre la misma.

const interceptor = {
  construct(constructorType, parameters) {
    console.log("CONSTRUCTOR", constructorType, parameters);
    const instance = new constructorType();
    instance.setName(parameters[0]);
    return instance;
  }
};

class Digimon {
  #name;
  
  getName() {
    return this.#name;
  }
  
  setName(newName) {
    this.#name = newName;
  }
}

const DigimonProxy = new Proxy(Digimon, interceptor);

const digimon = new DigimonProxy("Agumon");
// En consola: 'CONSTRUCTOR' ƒ Digimon() [ 'Agumon' ]

digimon.getName();
// En consola: 'Agumon'

Resumen

En este artículo hemos aprendido una de las herramientas recientes más potentes de JavaScript. Como siempre os emplazo a que dejéis vuestras dudas y comentarios, que os intentaré ayudar en todo lo posible.

Comparte este artículo con quien quieras
Opinión: modas en programación. El caso Hooks de React
AbortController: cancelando operaciones en JavaScript

Leave a Comment

Your email address will not be published. Required fields are marked *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.