Componentes web

Figura
Figura. Instancia 0 de un componente web

Un componente web es la programación de una utilidad para insertar en una página y reutilizar en otras páginas. Cabe la posibilidad de que se inserten múltiples instancias del mismo componente en una página. Pueden programarse usando POO (Programación Orientada a Objetos). Pero usando la capacidad de encapsulamiento de los closures en JavaScript podemos montar módulos que consigan los mismos objetivos que los originados con POO.

Supongamos que hemos de realizar un componente como el de la Figura. Se trata de insertar en una página web una o más instancias de una interfaz de usuario que lleven a cabo operaciones usando los valores que el usuario introduce en los elementos de control.

En este ejemplo muy simple el usuario introduce los valores de los argumentos, selecciona una operación aritmética y obtiene un resultado con el botón para operar. Se trata, por tanto, de una calculadora muy simple que podría necesitarse en varios puntos de una página web, por lo que cada instancia debe ser independiente de las demás. Si además cabe necesitarse en el futuro en otras páginas, estaría más que justificado afrontar el problema usando módulos de múltiples instancias.

Inicialmente planteaba este tipo de problemas usando la programación orientada a objetos (POO). Es el caso de los componentes Formularios Emergentes, Calendario o Menú contextual entre otros. JavaScript no es un lenguaje POO, pero puede simularlo bastante bien. De hecho ES6 se actualiza con varias mejoras como expuse en inicializando objetos con constructores. Tal como finalizaba diciendo en ese apartado, POO no siempre es la mejor estrategia para resolver este tipo de problemas en JavaScript.

Posteriormente llevé a cabo otros componentes como Calculadora, Grafos SVG o Gráficas lineales con SVG que no usan POO, sino una estrategia de módulos con interfaces de múltiples instancias basados en el concepto de encapsulamiento que aportan los closures. En este tema intentaré explicar como llevarlo a cabo usando el ejemplo simple de la Figura.

Se insertarán dos instancias del ejemplo en esta página, explicándose todos los pormenores. También puede observarlo en una página aparte con los ejemplos de las dos instancias exclusivamente.

El módulo operacional y el de interfaz de usuario

Figura
Figura. calc.js en wxtable

El problema siempre tendrá la necesidad de implementar un módulo de interfaz de usuario. Este módulo nos permitirá crear múltiples instancias de la interfaz en las que interactuará el usuario. Generalmente el usuario introducirá datos que se procesarán para presentar un resultado visual en la página.

A veces nos interesará que ese proceso de datos se ubique en un módulo operacional distinto del de la interfaz. Esté módulo sólo se encarga de realizar las operaciones y procesos, como las operaciones aritméticas en nuestro ejemplo. Ofrecerá al exterior un conjunto de funciones que lleven a cabo las operaciones y devuelven un resultado. No interacciona con la página web, por lo que en este aspecto está completamente aislado del exterior. La ventaja de usar un módulo operacional es que podemos mejorarlo o ampliarlo en el futuro sin necesidad de tocar el módulo de interfaz. Y podría usarse para distintos módulos de interfaz u otras aplicaciones con diferentes propósitos.

Un ejemplo de esto es el módulo calc.js que reúne operaciones propias de una calculadora. Se usa con el módulo de interfaz calcui.js que inserta instancias de calculadoras en una página. Pero también se usa en el Gestor de tablas como se observa en la Figura. Se trata de ejecutar cualquier clase de operación en las celdas de la tabla, parecido a como lo hace una hoja de cálculo. El módulo recibe una expresión como 123+456 y devuelve el resultado 579.

En el simple ejemplo que usaremos para explicar todo esto disponemos de module.js como módulo operacional y module-ui.js como módulo de interfaz de usuario.

Cargando e iniciando los módulos

En la página cargamos los módulos y usaremos window.onload para iniciarlos cuando ya estén disponibles. Usaremos un espacio de nombres como Namespace ubicado en global para almacenar todas las referencias.

...
<script>
    var Namespace = Namespace || {};
    window.onload = function() {
        //Iniciamos el módulo de operaciones
        Namespace.module = Namespace.startModule();
        ...
    }
</script>
<script src="module.js" async></script>
<script src="module-ui.js" async></script>
</body>

En primer lugar iniciamos el módulo de operaciones (que hemos cargado en el archivo module.js). Omitimos el resto del código con puntos suspensivos pues luego seguiremos explicando la secuencia de acciones paso a paso. De este inicio del módulo operacional obtenemos el siguiente objeto con variables y funciones:

Con ese objeto tendremos acceso desde el exterior a sus variables y funciones públicas. Se observa que podemos ejecutar las dos operaciones del módulo: sumar y multiplicar. En la representación visual de estos objetos omitimos el contenido interior de las funciones usando puntos suspensivos. Más abajo explicaremos con detalle el interior de esas funciones.

A continuación iniciamos el módulo de interfaces (que hemos cargado en el archivo module-ui.js). Hemos de pasarle como argumento el módulo de operaciones que iniciamos antes:

Namespace.moduleUi = Namespace.startModuleUi(Namespace.module);

Nos devuelve el siguiente objeto, donde por ahora no hay instancias creadas. La función create() nos permitirá crearlas posteriormente:

Creando las instancias del módulo interfaz de usuario

Creamos la primera instancia para ubicarla en el elemento con ID "location0", un DIV que tenemos en esta página:

let location0 = document.getElementById("location0");
Namespace.ui0 = Namespace.moduleUi.create({location: location0});

Nos devueve este objeto cuya referencia almacenamos en Namespace.ui0:

Se observa que en ui tenemos la referencia al elemento DIV que contiene la instancia de la interfaz creada. Expone también la función pública de la interfaz copy(). Esta es la primera instancia que hemos creado:

Ejemplo: Instancia 0

La función copy() se ejecuta desde el exterior del componente. Copia el resultado ajustándo a tres decimales y lo ubica en un elemento al lado. Esto lo explicaremos más abajo.

Creamos la segunda instancia dentro del elemento con ID "location1":

let location1 = document.getElementById("location1");
Namespace.ui1 = Namespace.moduleUi.create({location: location1});

Nos devuelve este objeto cuya referencia almacenamos en Namespace.ui1:

Ejemplo: Instancia 1

En la creación de instancias no es necesario almacenar esos objetos pues también podemos acceder a ellos desde

Detalles del módulo operacional

Este es el módulo que lleva a cabo las operaciones:

var Namespace = Namespace || {};
Namespace.startModule = function(){
    //Privado
    let msg = "ERROR!!!! Uno o más argumentos no son números";
    function isNumber(a){
        return !isNaN(a) && Number.isFinite(+a);
    }
    //Público
    const version = "1.0";
    function sum(a=0, b=0){
        if (isNumber(a) && isNumber(b)){
            return Number(a) + Number(b);
        } else {
            return msg;
        }
    }
    function product(a=0, b=0){
        if (isNumber(a) && isNumber(b)){
            return Number(a) * Number(b);
        } else {
            return msg;
        }
    }
    //Devuelve públicas
    return {
        version,
        sum,
        product
    };
};

Poco hay que decir acerca de este módulo. Habrán algunas variables y funciones privadas y otras públicas. Lo que devolvemos en el objeto será público. Al resto no podremos acceder desde el exterior siendo, por tanto, privado. Por ejemplo, la función isNumber() es privada pues sólo se necesita internamente.

Detalles del módulo interfaz de usuario

Este es el código resumido del módulo para crear las distintas instancias de interfaces de usuario:

var Namespace = Namespace || {};
Namespace.startModuleUi = function(module){
    //Público (general): objeto con todas las instancias creadas
    const instances = {};

    //Privado: útil cuando insertamos un SVG dentro de un botón
    function getButton(element){...}

    //Privado: Maneja evento click sobre el botón de operar
    function operate(event){...}

    //Público (instancia): copiar el resultado ajustando decimales
    function copy(ui=null, {decimalPlaces=2}={}){...}

    //Público (general): Constructor de las interfaces
    function create({location=null}={}){
        ...
        //Devuelve públicas de esta instancia
        instances[ui.id] = {
            ui,
            id,
            copy: copy.bind(null, ui),
        };
        return instances[ui.id];
    }

    //Devuelve públicas de este módulo
    return {
        instances,
        create
    };
};

El módulo devuelve variables y funciones públicas generales del módulo. Son instances que es el objeto que reúne todas los objetos de las instancias. Y la función create() que nos pemitirá crear instancias.

Se observan funciones privadas. Algunas serán las que manejan los eventos, como operate(event) que maneja el evento "click" sobre el botón para operar. En otro apartado veremos como el módulo puede deducir la instancia donde se está pulsando el botón.

Y se observa una función publica de las instancias. Es la función copy() que permite copiar externamente el resultado de una determinada instancia. Observe dentro de create() el lugar donde devolvemos esa función.

La función para crear instancias

Veamos la función para crear instancias:

function create({location=null}={}){
    try {
        if (!location || location.constructor.name!=="HTMLDivElement")
            throw new Error(`Location es nulo o no es un HTMLDivElement`);
        let idbase = `modui-`, n = -1, id = "";
        while(n++, id = `${idbase}${n}`, n<100 && document.getElementById(id));
        if (n>100) throw new Error(`Maximum loop exceeded`);
        let html = `<div class="modui" id="${id}">` +
            `<div><small>Instancia #${n}</small></div>` +
            `<label>a: <input type="text" value="0" data-arg="a" /></label>` +
            `<label>b: <input type="text" value="0" data-arg="b" /></label>` +
            `<label>Operación: <select data-operation>` +
                `<option value="sum">Sumar (+)</option>` +
                `<option value="product">Multiplicar (×)</option>` +
            `</select></label>` +
            `<button type="button" data-operate>OPERAR</button>` +
            `<div>Resultado:<div data-result></div></div>` +
        `</div>`;
        let sty = document.getElementById("modui-css");
        if (!sty){
            sty = document.createElement("style");
            sty.id = "modui-css";
            document.head.appendChild(sty);
            sty.textContent = `.modui {width: 20em; border: blue solid 2px; padding: 0.2em}` +
            `.modui > div:first-child{float: right}` +
            `.modui label {display: block; margin: 0.2em auto}` +
            `.modui div[data-result] {border: black solid 1px; min-height: 1.5em}` +
            `.modui input, .modui select, .modui div[data-result]{color: blue; font-size: 1em}`;
        }
        location.innerHTML = html;
        let ui = location.firstElementChild;
        window.setTimeout(() => {
            //Identificar las instancias en cada elemento con evento
            document.querySelectorAll(`#${id} button`).
                forEach(v => v[Symbol.for("modui")] = ui);
            //Declarar eventos
            document.querySelector(`#${id} button[data-operate]`).
                addEventListener("click", operate);
        });
        //Devuelve públicas de esta instancia
        instances[ui.id] = {
            ui,
            id,
            copy: copy.bind(null, ui),
        };
        return instances[ui.id];
    } catch(e){alert(`#Error createUi() de moduleUi.js: ${e.message}`)}
    return null;
}

Para crear una instancia necesitamos que se nos pase un elemento en la página donde ubicarlo. Si no se pasa o no es un elemento DIV cursará error. Dentro de ese elemento insertaremos otro DIV que contendrá todo el HTML de la interfaz. Ese DIV contenedor ui (user interface) será identificado con un ID="modui-N", donde N será un número correlativo desde cero.

Podemos buscar en la página cuántos elementos con este ID existen para saber así cual sería el siguiente. Una forma alternativa será consultar el objeto Namespace.moduleUi.instances para observar las que hay creadas previamente. Pero con la primera alternativa podemos evitar que ese ID pudiera ya existir previamente para otro cometido que nada tuviera que ver con estos módulos.

El siguiente paso es construir el HTML y el CSS. Observe que el único elemento identificado es el DIV contenedor. El resto los identificamos con atributos data. Con esto es suficiente para localizarlos y declarar clases de estilos. El estilo lo ponemos en un elemento STYLE identificado, lo que se ejecuta sólo cuando se crea la primera instancia.

Insertamos el HTML con location.innerHTML = html. Y guardamos la referencia al DIV contenedor con let ui = location.firstElementChild. Esta referencia la usaremos para identificar el DIV contenedor en las funciones manejadoras de eventos. Por ejemplo, la función operar(event) recibe un evento "click" y llevará a cabo la ejecución de la operación. Ya dentro de esa función necesitaremos saber desde que instancia se provocó el evento.

Funciones privadas para manejar eventos en la interfaz de usuario

Como acabamos de decir, necesitamos conseguir identificar el contenedor DIV donde se origina un evento. Para ello en la función creadora de instancias encolamos una tarea con window.setTimeout(), pues hemos de esperar a que el contenedor HTML se cargue en la página. Se trata entonces de buscar los elementos que contengan eventos, en este ejemplo sólo el botón, para agregarle una propiedad que apunte a la referencia del contenedor. Dado que estamos agregando propiedades a un objeto del DOM, es decir, un objeto del tipo HTMLButtonElement propio de JavaScript, es aconsejable usar variables de tipo símbolo (Symbol). Con v[Symbol.for("modui")] = ui agregamos esta referencia y nos aseguramos que no vamos a sobrescribir ninguna propiedad ya existente en ese objeto. Sólo resta declarar el evento "click" para ese botón apuntando a la función operar().

Veamos la función que maneja el evento operar():

//Privado: Maneja evento click sobre el botón de operar
function operate(event){
    let dev = {error: ""}, temp, element, ui;
    try {
        if (element = getButton(event.target), element){
            ui = element[Symbol.for("modui")];
            let a = ui.querySelector(`[data-arg="a"]`).value;
            let b = ui.querySelector(`[data-arg="b"]`).value;
            let operation = ui.querySelector(`[data-operation]`).value;
            let result = ui.querySelector(`[data-result]`);
            result.textContent = module[operation](a, b);
        }
    } catch(e){dev.error = `#Error operate(): ${e.message}`}
    if (dev.error) alert(dev.error);
}

Como toda función de evento, portará como argumento un objeto con el evento. Así que en event.target tendremos el botón que originó el evento "click". Usamos una función privada getButton(event.target) que nos devolverá lo mismo, la referencia al botón event.target. Si dentro del botón sólo hay texto no haría falta usar eso, pero con objeto de documentar el uso de una función privada he decidido incluirla. Ahora bien, si dentro del botón ponemos contenido HTML o SVG si que sería necesario detectar el botón.

Como se observa en la función operate(), para realizar la operación necesitamos acceder a los campos de los argumentos y la operación específica de la instancia donde se genera el "click". Para ello accedemos a la referencia al contenedor con ui = element[Symbol.for("modui")]. Con ui y usando querySelector() es simple recuperar todo lo necesario para realizar la operación y volcar el resultado. Vease que la operación se ejecuta con module[operation](a, b) usando el módulo de operaciones module, referencia que fue pasada como argumento al iniciar el módulo de interfaz.

Funciones públicas de las instancias

El último paso dentro de la función creadora de instancias es completar los campos del objeto instancia en instances[ui.id]. Este es el código al final de la función creadora de instancias que vimos más arriba:

...
    //Devuelve públicas de esta instancia
    instances[ui.id] = {
        ui,
        id,
        copy: copy.bind(null, ui),
    };
    return instances[ui.id];
...

Incluiremos la referencia ui al DIV contenedor, el identificador id del mismo y las funciones públicas de las instancias existentes. En nuestro ejemplo sólo tenemos de este tipo la función copy(). Pero no la devolveremos tal cual, sino que haremos algo con ella antes. Para entenderlo veamos primero como es esta función:

//Público (instancia): copiar el resultado ajustando decimales
function copy(ui=null, {decimalPlaces=2}={}){
    let dev = {error: "", value: null};
    try {
        if (isNaN(decimalPlaces) || !Number.isInteger(decimalPlaces)){
            throw new Error(`Decimal places is not integer`);
        }
        let n = document.querySelector(`#${ui.id} div[data-result`).textContent;
        dev.value = Number(n).toFixed(decimalPlaces);
    } catch(e){dev.error = `#Error copy(): ${e.message}`}
    return dev;
}

En esencia lo que hace es recuperar el resultado usando la referencia ui y aplicarle toFixed() para ajustar el número de decimales a los que se pasen en el argumento decimalPlaces. Si no se pasa ese argumento se tomará 2 como valor por defecto. Vease que es aconsejable pasar el resto de argumentos dentro de un objeto, pero no estrictamente necesario.

Es importante entender que esta es una función pública de la instancia. Esto quiere decir que, al igual que pasaba con las funciones de eventos, necesitaremos particularizarla para cada instancia. Una forma de hacerlo es que porte un primer argumento con la referencia ui al contenedor DIV. Esta función podría usarse internamente al módulo interfaz, pero su objetivo principal es usarla desde el exterior. Recordemos que el objeto instancia al que tendremos acceso desde el exterior, por ejemplo para la primera instancia, podría ser así en caso de que devolviéramos la función copy() sin más transformación:

Namespace.ui0:{
    "ui": [object HTMLDivElement],
    "id": "modui-0",
    "copy": function copy(ui=null, {decimalPlaces=2}={}){...}
}

Así que si estamos en la instancia 0, entonces la ejecutaríamos externamente con Namespace.ui0.copy(Namespace.ui0.ui, {decimalPlaces: 3}), ajustando el resultado a tres decimales. Se observa que si estoy llamando desde una función que está en Namespace.ui0 parece superfluo pasar Namespace.ui0.ui. Esto lo podemos evitar usando el método bind() del objeto Function en base al concepto aplicación parcial de argumentos.

Devolviendo copy.bind(null, ui) fijamos el primer argumento ui de la función copy() con el valor que en ese momento tenga. Asi que esa función que era copy(ui=null, {decimalPlaces=2}={}) con dos argumentos pasa a ser copy({decimalPlaces=2}={}) con un único argumento, teniendo acceso al argumento ui como una variable local dentro de la función cuando se ejecute. Es decir, ese valor de ui particular queda fijado en el momento en que ejecutamos bind(). El objeto instancia quedará entonces así:

Namespace.ui0:{
    "ui": [object HTMLDivElement],
    "id": "modui-0",
    "copy": function () { [native code] }
}

Observe que al representar como texto la función ahora obtenemos "copy": function (){[native code]}. Esto es porque le aplicamos bind() a la función copy() original. El método bind() devuelve una nueva función anónima, donde el código interno de la función no es accesible, es lo que se denomina como native code.

Usando externamente funciones públicas de una instancia

La primera instancia fue insertada en esta página usando este HTML:

<div id="location0"></div>
<div><button class="btn" data-ui="0">Copiar</button> <span></span></div>

Veamos como adjudicamos el evento para ese botón exterior:

//Adjudicamos eventos a los botones copiar exteriores
[...document.body.getElementsByClassName("btn")].forEach(v => 
v.addEventListener("click", (event) => {
    let button = event.target;
    let numInstance = button.getAttribute("data-ui");
    let instance = Namespace[`ui${numInstance}`];
    let dev = instance.copy({decimalPlaces: 3});
    button.nextElementSibling.textContent = dev.error || dev.value;
}));

Obtenemos el número de instancia que se porta en un atributo data-ui. Obtenemos el objeto instancia de ese número y ejecutamos pasando sólo el segundo argumento instance.copy({decimalPlaces: 3}). Finalmente ponemos el valor resultante en un elemento siguiente al botón.