Qué son y cómo funcionan los proxies de ECMAScript 5 (ES2015)

17 de febrero de 2016

Los proxies de ES2015 proporcionan una API para capturar o interceptar cualquier operación realizada sobre un objeto y para modificar cómo se comporta ese objeto. Los proxies de JavaScript son útiles para muchas cosas, como por ejemplo:

  • Intercepción.
  • Virtualización de objetos.
  • Gestión de recursos.
  • Hacer profiling y generar logs durante la depuración de una aplicación.
  • Seguridad y control de acceso.
  • Definición de "contratos" al usar objetos.

La API Proxy define un constructor de proxies al que se le pasa como primer argumento el objeto que se va a capturar (llamado target) y como segundo argumento el handler que realizará la captura. Ejemplo:

var target = { /* propiedades y métodos */ };
var handler = { /* funciones capturadoras */ };

var proxy = new Proxy(target, handler);

El handler es el encargado de modificar el comportamiento original del objeto target. Este handler contiene métodos "capturadores" (por ejemplo .get(), .set(), .apply()) que se ejecutan al realizar la llamada correspondiente del proxy.

Intercepción

En este primer ejemplo, vamos a utilizar la API Proxy para añadir un middleware de intercepción en un objeto JavaScript simple que solo contiene propiedades. El primer argumento es el objeto que se quiere interceptar y el segundo argumento es el handler que define el código necesario para capturar los getters, setters, etc.

var target = {};

var superhero = new Proxy(target, {
   get: function(target, name, receiver) {
       console.log('ejecutado "get" para propiedad: ', name);
       return target[name];
   }
});

superhero.power = 'Flight';
console.log(superhero.power);

Si ejecutas el código anterior en un navegador que soporte los proxies de JavaScript, verás el siguiente mensaje en la consola:

ejecutado "get" para propiedad: power "Flight"

En este ejemplo se ve claramente cómo las llamadas get sobre el objeto se están capturando en el proxy, que a su vez puede ejecutar cualquier código arbitrario antes de ejecutar el método solicitado. Los handlers pueden capturar los getters que leen propiedades, los setters que asignan valores a las propiedades y también la ejecución de cualquier método del objeto.

Las funciones capturadoras pueden ejecutar cualquier código propio y también pueden redirigir a los métodos del objeto original. Esto último es por ejemplo lo que ocurre cuando tu handler no atrapa algún método del objeto original. El siguiente ejemplo muestra un proxy vacío que hace exactamente eso:

var target = {};

// proxy vacío que no captura nada
var proxy = new Proxy(target, {});

// la asignación de la propiedad se reenvía al objeto original
proxy.paul = 'irish';

// el resultado es: 'irish' (el reenvío ha funcionado)
console.log(target.paul);

En este artículo puedes ver un ejemplo completo con todos los métodos que puede capturar un proxy.

Interceptando métodos

El ejemplo anterior intercepta objetos simples que solo tienen propiedades, pero los proxies también pueden interceptar objetos con métodos. En este caso, la función capturadora se llama apply(). Ejemplo:

// función que se va a capturar
function sum(a, b) {
    return a + b;
}

var handler = {
    apply: function(target, thisArg, argumentsList) {
        console.log(`Calcular la suma de: ${argumentsList}`);
        return target.apply(thisArg, argumentsList);
    }
};

var proxy = new Proxy(sum, handler);
proxy(1, 2);
// Calcular la suma de: 1, 2
// 3

Comprobando la identidad de los proxies

La identidad de los proxies se puede comprobar mediante los operadores == y === de JavaScript. Como sabes, al aplicar esos operadores sobre objetos, se compara la identidad de los objetos.

Cuando se comparan dos proxies distintos, el resultado es false aunque los objetos que se estén capturando sean iguales. De la misma manera, el objeto original también se considera diferente a cualquiera de sus proxies:

// función que se va a capturar
function sum(a, b) {
    return a + b;
}

var handler = { ... };

var proxy = new Proxy(sum, handler);
var proxy2 = new Proxy (sum, handler);

console.log(proxy == proxy2); // false
console.log(proxy == sum);    // false

Idealmente no debería ser posible distinguir un proxy de un objeto normal, de manera que el uso de los proxies no afecte de ninguna manera la ejecución de la aplicación. Este es el motivo por el que la API Proxy no incluye ningún método para comprobar si un objeto es realmente un proxy y tampoco para comprobar si define funciones capturadoras.

Casos de uso de los proxies

Como se mencionó anteriormente, los proxies tienen muchos casos de uso interesantes. La mayoría de ellos, como el control de acceso y el profiling se enmarcan dentro del tipo proxy genérico, que capturan objetos existentes. También se define otro tipo llamado proxy virtual que emulan objetos que no existen, como por ejemplo objetos remotos y objetos que guardan resultados que no están disponibles todavía.

Validación

Uno de los casos de uso más habituales consiste en validar si la operación a realizar es correcta antes de realizarla. Si la validación es positiva, la operación se reenvía al objeto original para ejecutarla. Ejemplo:

var validator = {
  set: function(obj, prop, value) {
    if (prop === 'yearOfBirth') {
      if (!Number.isInteger(value)) {
        throw new TypeError('yearOfBirth no es un número entero');
      }

      if (value > 3000) {
        throw new RangeError('yearOfBirth no parece válido');
      }
    }

    // operación original para guardar el valor en la propiedad
    obj[prop] = value;
  }
};

var person = new Proxy({}, validator);

person.yearOfBirth = 1986;
console.log(person.yearOfBirth); // 1986
person.yearOfBirth = 'eighties'; // lanza una excepción
person.yearOfBirth = 3030;       // lanza una excepción

Extensión de objetos

Otro caso de uso común de los proxies consiste en extender o mejorar las operaciones realizadas sobre los objetos. Por ejemplo puede que al ejecutar los métodos de un objeto quieras generar logs, enviar notificaciones y lanzar mejores excepciones cuando se produzcan errores.

function extend(sup,base) {

  var descriptor = Object.getOwnPropertyDescriptor(base.prototype,"constructor");

  base.prototype = Object.create(sup.prototype);

  var handler = {
    construct: function(target, args) {
      var obj = Object.create(base.prototype);
      this.apply(target,obj, args);
      return obj;
    },

    apply: function(target, that, args) {
      sup.apply(that,args);
      base.apply(that,args);
    }
  };

  var proxy = new Proxy(base, handler);
  descriptor.value = proxy;
  Object.defineProperty(base.prototype, "constructor", descriptor);
  return proxy;
}

var Vehicle = function(name){
  this.name = name;
};

var Car = extend(Vehicle, function(name, year) {
  this.year = year;
});

Car.prototype.style = "Saloon";

var Tesla = new Car("Model S", 2016);

console.log(Tesla.style); // "Saloon"
console.log(Tesla.name);  // "Model S"
console.log(Tesla.year);  // 2016

Control de acceso

El control de acceso también es un buen caso de uso de los proxies. En vez de pasar un objeto a una aplicación en la que no confías, puedes definir un proxy que "proteja" al objeto. Una vez que el objeto ha sido devuelto por el código no fiable, puedes recuperar el objeto original y eliminar el proxy.

Usando reflexión con los proxies

Reflect es un nuevo tipo de objeto JavaScript que proporciona métodos para interceptar operaciones JavaScript. Este objeto también es muy útil cuando se trabaja con proxies y de hecho, comparten muchos métodos.

Los lenguajes con tipado "estático", como Python y C#, ofrecen APIs para reflexión de código desde hace mucho tiempo. JavaScript, por su naturaleza dinámica, no necesita este tipo de funcionalidades y por eso no incluía esta API.

ES5 ya incluye varias funcionalidades íntimamente relacionadas con la reflexión, como por ejemplo Array.isArray() o Object.getOwnPropertyDescriptor(). ES2015 introduce la API "Reflection" para agrupar todos estos métodos y los nuevos que se vayan definiendo.

Usando Reflect es posible mejorar la forma en la que se capturan los getters y setters en uno de los ejemplos mostrados anteriormente:

// interceptando propiedades con las API Proxy y Reflect

var pioneer = new Proxy({}, {
  get: function(target, name, receiver) {
      console.log(`ejecutado "get" para propiedad: ${name}`);
      return Reflect.get(target, name, receiver);
  },

  set: function(target, name, value, receiver) {
      console.log(`ejecutado "set" para propiedad: ${name} y valor: ${value}`);
      return Reflect.set(target, name, value, receiver);
  }
});

pioneer.firstName = 'Grace';
pioneer.secondName = 'Hopper';
// Grace
pioneer.firstName

El código anterior genera los siguientes logs:

ejecutado "set" para propiedad: firstName y valor: Grace

ejecutado "get" para propiedad: secondName y valor: Hopper

ejecutado "get" para propiedad: firstName

Otro ejemplo relacionado podría consistir en:

  • Definir el proxy dentro de un constructor propio para no tener que crear un nuevo proxy cada vez que se utiliza un determinado objeto.
  • Añadir la opción de guardar los cambios mediante un método llamado save() que solamente se ejecuta si los datos se han modificado realmente.
function Customer() {

    var proxy = new Proxy({
      save: function(){
        if (!this.dirty){
          return console.log('No se guarda nada porque no hay cambios.');
        }
        console.log('Guardando los cambios: ', this.changedProperties);
      },

    }, {

      set: function(target, name, value, receiver) {
        target.dirty = true;
        target.changedProperties = target.changedProperties || [];

        if(target.changedProperties.indexOf(name) == -1){
          target.changedProperties.push(name);
        }
        return Reflect.set(target, name, value, receiver);
      }

    });

    return proxy;
  }

  var customer = new Customer();

  customer.name = 'seth';
  customer.surname = 'thompson';
  // Guardando los cambios:  ["name", "surname"]
  customer.save();

Definiendo un polyfill para Object.observe()

Aunque Object.observe() está en desuso, puedes utilizar los proxies de JavaScript para definir un polyfill. También puedes echar un vistazo al shim de Object.observe() creado por Simon Blackwell y que utiliza proxies.

Soporte en navegadores

Los proxies ES2015 están disponibles en Google Chrome 49, Opera, Microsoft Edge y Firefox. Reflect está disponible en Google Chrome, Opera y Firefox, mientras que Microsoft Edge añadirá soporte pronto.

Recursos útiles

Sobre el autor

Este artículo fue publicado originalmente por Addy Osmani y ha sido traducido con permiso por Javier Eguiluz.