Seleccionar página

ES6 pone a nuestra disposición una nueva y poderosa herramienta que permite aprovechar algunos aspectos de la metaprogramación que hasta ahora no estaban disponibles con Javascript: Proxy(). 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 esta forma con Proxy() podemos utilizar en Javascript la metaprogramación dinámica, es decir, permite definir el comportamiento de las propiedades presentes y, especialmente, futuras de un objeto. Hasta ahora sólo podíamos aplicar metaprogramación estática y volver a procesar los objetos tras haber sido ampliados o modificados. Además, Proxy() nos va a permitir interceptar algunas operaciones que de otra forma no podríamos capturar.

A diferencia de otros casos en los que podemos disfrutar de estas funcionales en ES5.1 a través de un transpiler o pollify, en el caso de Proxy() esta operación no es posible, ya que la mayoría de su funcionalidad no se puede simular con código escrito en versiones anteriores de Javascript.

Antes de continuar tenemos que agradecer muy especialmente la colaboración de Javier Vélez, que con su paciencia y sabiduría nos ha ayudado a muchos a comprender algunos de los conceptos fundamentales de la metaprogramación. Si queréis profundizar en esta materia, no dejéis de consultar su muy interesante intervención en el JSDay de Madrid en 2015: http://www.javiervelezreyes.com/metaprogramacion-compositiva-en-javascript/ Aquí vamos a centrarnos en algunos aspectos técnicos sobre el uso de Proxy() y no vamos a entrar en los patrones y modelos arquitectónicos de la metaprogramación.

Proxy()

Primero vamos a dar un rápido repaso a la descripción básica de Proxy(), más adelante veremos algunos ejemplos más concretos sobre su uso.

Proxy() es un constructor (que siempre debe utilizarse con new) que recibe dos parámetros:

  • targer: un objeto sobre el cual se producirá la supervisión
  • handler: un objeto con una serie de manejadores para cada una de las diferentes operaciones que se realizarán sobre el objeto pasado en el primer parámetro

Este constructor devuelve el objeto pasado como parámetro, pero que está siendo interceptado. Es importante tener en cuenta que Proxy() no copia el objeto, devuelve el mismo objeto, pero supervisado.

Las acciones que se pueden capturar son:

  • handler.getPrototypeOf, captura Object.getPrototypeOf().
  • handler.setPrototypeOf, captura Object.setPrototypeOf().
  • handler.isExtensible, captura Object.isExtensible().
  • handler.preventExtensions, captura Object.preventExtensions().
  • handler.getOwnPropertyDescriptor, captura Object.getOwnPropertyDescriptor().
  • handler.defineProperty, captura Object.defineProperty().
  • handler.has, captura el operador in.
  • handler.get, captura el acceso a las propiedades.
  • handler.set, captura la escritura de las propiedades.
  • handler.deleteProperty, captura el borrado de propiedades.
  • handler.ownKeys, captura Object.getOwnPropertyNames().
  • handler.apply, captura la ejecución de la función.
  • handler.construct, captura el uso del operador new.

Vamos a poner un sencillo ejemplo que nos ayude a entenderlo:

var obj = {a: 1, b: 2};

var obj2 = new Proxy(
    obj,
    {
        get : function(target, propertyKey) {
            console.log('get:', propertyKey);
            return Reflect.get(target, propertyKey);
        }
    }
);

// Referencia original
var c = obj.a;          // no muestra ningún mensaje

// Referencia supervisada
var d = obj2.a;         // get: a

// Acceso a una propiedad no existente
var e = obj2.no_exist;  // get: no_exist

ejecutar… (requiere un navegador con soporte para ES6)

Este ejemplo no hace nada especialmente interesante, sólo muestra un mensaje cuando se accede a las propiedades del objeto que está siendo supervisado. Como se puede comprobar, este mensaje se muestra tanto si existe como si no existe la propiedad, lo cual nos abre un campo muy interesante de comportamientos que podemos implementar al realizarse el acceso sobre propiedades no presentes en el objeto.

En ES5.1 sólo podemos definir una función get o set para las propiedades que ya existen:

var obj = {a: 1, b: 2};
Object.defineProperty(obj, 'a', {
    get: function() {
        console.log('get: a');
        return 1;
    }
});
var a = obj.a;          // get: a

ejecutar…

Las diferencias entre la captura de set y get con Object.defineProperty() y con Proxy() son muy importantes:

  • Object.defineProperty() actúa sobre las propiedades ya existentes
  • Proxy() actúa sobre las propiedades existentes y sobre las que se creen posteriormente.
  • Object.defineProperty() define el set y get por cada propiedad
  • Proxy() define un solo set y get para todas las propiedades.
  • Proxy() no sólo captura set y get, captura también otros tipo de acciones como el uso de delete, el acceso al prototipo, la comprobación de la existencia de una propiedad con in, el acceso a la lista de propiedades, etc.

En este nuevo ejemplo vamos a capturar los intentos de borrar una propiedad del objeto con delete.

var obj = {a: 1, b: 2};

var obj2 = new Proxy(
    obj,
    {
        deleteProperty: function(target, property) {
            console.log('deteleProperty:', property);
            return Reflect.deleteProperty(target, property);
        }
    }
);

// Borrado de una propiedad del objeto original
delete obj.a;

// Borrado de una propiedad del objeto supervisado
delete obj2.a;         // deteleProperty: a

// Borrado de una propiedad no existente
delete obj2.no_exist;   // deteleProperty: no_exist

ejecutar… (requiere un navegador con soporte para ES6)

Una mención sobre el uso de Reflect: este objeto aparece en ES6 y lo estamos utilizando para realizar de forma efectiva la operación que estamos interceptando. Realmente las operaciones de Reflect se pueden ejecutar de otras formas y puede parecer que es algo redundante, pero los métodos de Reflect devuelven el valor que espera cada evento de Proxy() como retorno a diferencia de los métodos de Object que devuelven el objeto. Por ejemplo, si devolvemos false en la función asociada a deleteProperty se producirá un error del tipo: TypeError: 'deleteProperty' on proxy: trap returned falsish for property X. Sólo se realizará correctamente la operación si se devuelve true. Los métodos de Reflect devuelve los valores esperados como retornos de los métodos de captura de Proxy.

A modo de ejemplo general y como referencia de todas las acciones que se pueden interceptar, hemos preparado la función trace() que nos devuelve el objeto capturando todas sus acciones disponibles y mostrando un mensaje en consola por cada una de ellas.

function trace (obj) {
    var handler = {
        getPrototypeOf : (target) => {
            console.log('getPrototypeOf:');
            return Reflect.getPrototypeOf(target);
        },
        setPrototypeOf : (target, prototype) => {
            console.log('setPrototypeOf:');
            return Reflect.setPrototypeOf(target, prototype);
        },
        isExtensible : (target) => {
            console.log('isExtensible:');
            return Reflect.isExtensible(target);
        },
        preventExtensions : (target) => {
            console.log('preventExtensions:');
            return Reflect.preventExtensions(target);
        },
        getOwnPropertyDescriptor : (target, prop) => {
            console.log('getOwnPropertyDescriptor:', prop);
            return Reflect.getOwnPropertyDescriptor(target, prop);
        },
        defineProperty : (target, prop, attributes) => {
            console.log('defineProperty:', prop);
            return Reflect.defineProperty(target, prop, attributes);
        },
        has : (target, prop) => {
            console.log('has:', prop);
            return Reflect.has(target, prop);
        },
        get : (target, propertyKey, receiver) => {
            console.log('get:', propertyKey);
            return Reflect.get(target, propertyKey, receiver);
        },
        set : function(target, prop, value, receiver) {
            console.log("set: " + prop + " = " + value);
            return Reflect.set(target, prop, value, receiver);
        },
        deleteProperty : (target, prop) => {
            console.log('deleteProperty:', prop);
            return Reflect.deleteProperty(target, prop);
        },
        ownKeys : (target) => {
            console.log('ownKeys:');
            return Reflect.ownKeys(target);
        },
        apply : (target, thisArgument, argumentsList) => {
            console.log('apply:');
            return Reflect.apply(target, thisArgument, argumentsList);
        },
        construct : (target, argumentsList, newTarget) => {
            console.log('construct:', argumentsList);
            return Reflect.construct(target, argumentsList, newTarget);
        }
    };
    return new Proxy(obj, handler);
}
 

ejecutar ejemplo… (requiere un navegador con soporte para ES6)

Si ejecutáis el ejemplo del enlace anterior, veréis que la mayoría de las operaciones que consideramos atómicas por debajo están lanzando varios eventos, por ejemplo, para modificar la propiedad length cuando ampliamos o reducimos el tamaño del objeto matriz que estamos interceptando. Esta traza nos muestra cómo está funcionando Javascript a la hora de hacer estas operaciones.

Ejemplos

A continuación vamos a mostrar algunos ejemplos sobre el uso de Proxy() con la esperanza de que puedan dar algo de luz sobre su funcionalidad y capacidad. Para ello hemos seleccionado algunos casos donde vamos a ver diferentes modalidades de interceptación de operaciones con los objetos.

undo()

Esta sencilla función permite cambiar el estado de un objeto por medio de un nuevo método undo(). Este método nos va a permitir deshacer cualquier cambio que hayamos realizado sobre las propiedades del objeto, incluido el borrado de alguna de sus propiedades.

function undo(obj) {
    var states = [];
    var off = false;
    obj.undo = function() {
        if (states.length === 0) {
            return false;
        }
        var prev = states.pop();
        off = true;
        obj[prev.property] = prev.value;
        off = false;
        return true;
    };
    obj = new Proxy(
        obj,
        {
            set: (obj, prop, value) => {
                if (!off) {
                    states.push({property: prop, value: obj[prop]});
                }
                return Reflect.set(obj, prop, value);
            },
            deleteProperty: (obj, prop) => {
                states.push({property: prop, value: obj[prop]});
                return Reflect.deleteProperty(obj, prop);
            }
        }
    );
    return obj;
}
 

ejecutar ejemplo… (requiere un navegador con soporte para ES6)

Como se puede comprobar, interceptar las operaciones set y deleteProperty nos permite crear un objeto cuyo estado podemos revertir fácilmente por medio de un nuevo método undo(). Los diferentes estados se van guardando en states[], variable que tenemos accesible en la closure.

hidden()

En este caso vamos a ocultar todas las propiedades de un objeto y las que hereda de toda su cadena de prototipos, por lo que el objeto aparecerá como vacío de propiedades, aunque sigan existiendo y siendo completamente operativas.

function hidden(obj) {
    var proto = Object.getPrototypeOf(obj);
    if (typeof proto === 'object' && proto !== null) {
        Object.setPrototypeOf(obj, hidden(proto));
    }
    return new Proxy(obj, {
        has: () => false,
        ownKeys: () => []
    });
}

ejecutar ejemplo… (requiere un navegador con soporte para ES6)

En este caso estamos capturando las acciones has y ownKeys, devolviendo en ambos casos valores que van a ocultar las propiedades que realmente tiene el objeto. Además, se ha recorrido recursivamente la cadena de prototipos y se ha aplicado la función hidden() a cada uno de ellos y asignado el nuevo objeto como prototipo con Object.setPrototypeOf() (también podríamos haber usado Reflect.setPrototypeOf).

Como consecuencia hemos obtenido un objeto misterioso, que aunque dispone de propiedades, las tiene todas ellas completamente ocultas (en el ejemplo hemos usado para comprobarlo nuestra función getPropertyNames() que describimos en el artículo Obtener todas las propiedades de un objeto).

cache()

Un patrón que se utiliza en bastantes ocasiones cuando la ejecución de una función resulta pesada es crear una caché que guardando los parámetros de entrada nos devuelva el resultado sin necesidad de ejecutar el cuerpo de la función más de una vez. Aunque este patrón se puede implementar por medio de una función, vamos a ver una implementación por medio de Proxy() y la captura de apply.

function cache(fn) {
    var cacheData = new Map();
    fn = new Proxy(fn, {
        apply: function(target, thisArg, argumentsList) {
            var args = argumentsList.toString();
            if (cacheData.has(args)) {
                return cacheData.get(args);
            }
            var ret = Reflect.apply(target, thisArg, argumentsList);
            cacheData.set(args, ret);
            return ret;
        }
    });
    fn.clearCache = () => !cacheData.clear();
    return fn;
}

ejecutar ejemplo… (requiere un navegador con soporte para ES6)

Aunque nos pueda sorprender, Proxy() puede recibir como primer parámetro una función, al fin y al cabo son objetos. Las funciones disponen de dos eventos que se pueden capturar con Proxy(): apply, que es el que hemos usado en el ejemplo, y construct que se produce cuando se usa new con la función como constructor.

Si queremos aplicar esta función a todos los métodos de un objeto, podemos usar este pequeño código complementario de cache():

function cacheObject(obj) {
    var cacheFunction = new Map();
    // Propiedades ya existentes
    Object.keys(obj).forEach((p) => {
        obj[p] = (typeof obj[p] === 'function' ? cache(obj[p]) : obj[p]);
    });
    // Nuevas propiedades
    return new Proxy(obj, {
        set: (object, property, value) => {
            value = typeof value === 'function' ? cache(value) : value;
            Reflect.set(object, property, value)
        }
    });
}

ejecutar ejemplo… (requiere un navegador con soporte para ES6)

Lo interesante en este caso es observar juntos dos formas de interceptación, la tradicional basada en recorrer y modificar las propiedades ya existentes con Object.keys(obj).forEach() y la que se realiza con new Proxy(obj, {set:...}) que permite modificar todas las nuevas propiedades que se incorporen al objeto.

throttleCache()

Un patrón muy interesante es aplicar la caché anterior sólo durante un tiempo determinado. Una vez pasado ese tiempo se vuelve a calcular el valor de retorno, ejecutando el cuerpo de la función y renovando el valor en la caché. El modelo throttle se usa en ocasiones para poder responder a eventos que se producen de forma muy continuada y que queremos procesar sólo cada determinado tiempo. Estamos utilizado las dos funciones anteriores, cache() y cacheObject, dentro del código de throttleCache().

function throttleCache(obj, ms) {
    var throttling = {};
    return new Proxy(
        cacheObject(obj),
        {
            get: (object, property) => {
                if (typeof object[property] === 'function' && !throttling[property]) {
                    throttling[property] = setTimeout(() => {
                        throttling[property] = ! object[property].clearCache();
                    }, ms);
                }
                return Reflect.get(obj, property);
            }
        }
    );
}

ejecutar ejemplo… (requiere un navegador con soporte para ES6)

Este ejemplo sirve para ver que podemos encadenar varios proxy ya que en la línea 4, en la instrucción new Proxy(), se recibe como primer parámetro el resultado de cacheObject() que retorna el resultado de aplicar otro proxy. De esta forma, creando un Proxy() sobre un objeto devuelto por otro Proxy(), podemos añadir varias interceptaciones de eventos de forma acumulada sin ningún tipo de problema.

pathBuilder()

Este ejemplo no tiene ninguna utilidad práctica, pero muestra otra interesante forma de aprovechar Proxy().

function pathBuilder(root) {
    var parts = [];
    var folder = new Proxy(
        function () {
            var returnValue = '/' + root + '/' + parts.join('/');
            parts = [];
            return returnValue;
        },
        {
            get: function (object, prop) {
                parts.push(prop);
                return folder;
            }
        }
    );
    return folder;
}
var pathExample = pathBuilder('base');
console.log(pathExample.esto.solo.es.una.prueba());     // /base/esto/solo/es/una/prueba

ejecutar ejemplo… (requiere un navegador con soporte para ES6)

Se puede observar que pathExample.esto.solo.es.una.prueba() ha encadenado objetos que realmente no existen, pero que han servido para construir una ruta con sus nombres. El secreto está en que cada vez que se pide una propiedad se está devolviendo el objeto folder que permite volver a ser invocado de forma encadenada.

Conclusiones

Es posible que no se vea con facilidad que problema viene a solucionar Proxy() y que nunca hayamos echado en falta su existencia, pero su incorporación abre nuevas e interesantes posibilidades, que van desde como devolver un métodos o valor por defecto cuando se intenta acceder a una propiedad que no existe, hasta, en general, aprovechar las capacidades de composición dinámica para generar nuevos comportamientos.

Es una pena que no pueda funcionar con Babel o similares, pero, como ya hemos comentado, esta es una funcionalidad que no se puede simular. Está disponible en Chrome 49, Eadge 13, Firefox 18, Safari 10, Opera 36 y Node 6.

Sinceramente creemos que Proxy() ofrece nuevas oportunidades a la hora de dotar de una mayor plasticidad a nuestros objetos y puede ser una interesante herramienta a la hora de construir frameworks o librerías de cierta complejidad. Quizás un abuso de en su uso pueda producir comportamientos desconcertantes o inesperados, pero es nos dota de una capacidad hasta ahora no disponible de manejar los objetos.

Esperamos que está introducción os haya sido de interés y que os animéis a descubrir algunos las posibilidades de Proxy(). Os animamos a dejar aquí algunos ejemplos adicionales sobre posibles utilidades de esta nueva funcionalidad de ES6.

Novedades

JSDayES en directo

Sigue en directo las charlas y talleres del impresionante JSDayES 2017. Ponentes de primer orden, tanto nacionales como internacionales, nos mostrarán todos los aspectos de Javascript. El viernes 12 de mayo de 16:00 a 21:00 y el sábado 13 de mayo de 9:30 a 21:00 (hora de Madrid).

The CITGM Diaries by Myles Borins [video]

Myles Borins de Google, miembro del CTC de Node.js (Node.js Core Technical Committee), nos cuenta (en inglés, por supuesto) como funciona CITGM (Canary In The Gold Mine), una herramienta que permite obtener un módulo de NPM y probarlo usando una versión específica de NodeJS.

Debate: Tecnologías de Front Web [vídeo]

Desde las principales comunidades de desarrollo de tecnologías de front (Madrid JS, Polymer Madrid, Angular Madrid y VueJS Madrid) se ha organizado este debate que pretende ser un ejercicio de sentido común en relación a las tecnologías de front actuales centradas en componentes.

breves

Descrubir algunas características de console

En el día a día nos encontramos muy a menudo utilizando console. Es una navaja multiusos que nos facilita la vida a la hora de depurar nuestro código. La mayoría de nosotros ha utilizado console.log(), pero tiene otras muchas funcionalidades.

Matrices dispersas o sparse arrays en JS

Una característica que puede producir algunos problemas, si no lo tenemos en cuenta, es la posibilidad de tener matrices con huecos, es decir, con algunos de sus elementos sin definir. Es lo que se suele denominar una matriz dispersa o sparse array. Veamos cómo trabajar con esta características de las matrices.

Algunos operadores de bits usados con asiduidad

Cada día más a menudo podemos encontrar operadores binarios utilizados como formas abreviadas de algunas operaciones que de otra forma sería algo menos compactas y, quizás, más comprensibles. Veamos algunos casos en detalle.

Cómo diferenciar arrow function de function

En un reciente artículo Javier Vélez Reyes hace patente las principales diferencias entre las funciones tradicionales y las funciones flecha, ya que ambos modelos no son equivalentes e intercambiables. Veamos cómo es posible saber si una función ha sido construida por medio de la instrucción function o como una arrow function.