Patrón mediador ¿Dónde está el doble binding en LitElement?

  • Por
Como en LitElement no tenemos doble binding, tenemos que jugar con 1 way binding y eventos personalizados en lo que se conoce como patrón mediador.

En LitElement han decidido dejar de lado soporte a doble binding, que sí disponían los desarrolladores de Web Components con la Librería Polymer. Es un hecho que parece chocante, pues el doble binding había sido tradicionalmente bien acogido por la comunidad de desarrolladores de frameworks Javascript en general.

Si ya llevas un tiempo acompañando las tendencias en desarrollo frontend, quizás no te parecerá tan extraño y de hecho no es para nada un problema. Debes saber que el doble binding tiene su coste en términos de procesamiento. Por tanto, reduce el rendimiento de las aplicaciones en general. Además de eso, puede llegar a producir enlaces de datos confusos en proyectos con cierta envergadura. Aunque es útil en muchos casos, lo cierto es que es uno de los puntos grises de las librerías y frameworks, donde es fácil caer en malas prácticas.

En el mundo del desarrollo frontend hemos visto en los últimos años cómo se ha establecido casi como un estándar el flujo unidireccional de la información, con patrones como Flux o su implementación Redux. Por ello quizás más que nunca tiene sentido que lo hayan dejado fuera el data-binding de dos direcciones. Seguro que esta carencia es uno de los factores que han conseguido que la LitElement se mantenga especialmente ligero en Kb y con un rendimiento realmente alto.

Sin embargo, en muchos casos lo seguimos necesitando. Por ejemplo, imagina un componente con un campo de texto. Generalmente su "value" está enlazado con una propiedad del componente y además, cuando el usuario edita el valor del campo de texto, deseamos que el nuevo valor también viaje hacia la propiedad. También puede darse el caso que tengamos un componente que deseamos que avise al padre ante el cambio de una de sus propiedades.

¿Qué debemos de hacer en estos casos? Existen algunos patrones que puedes aplicar. La regla general más básica y más usada es el patrón mediador, que puedes implementar con los mismos mecanismos que LitElement te ofrece.

Nota: Otra posibilidad, que no vamos a tratar en este artículo pero que está siendo adoptada de manera generalizada en LitElement es el uso de Redux. Redux nos ofrece muchas ventajas en el desarrollo de aplicaciones, aunque en verdad no es necesario para poder desarrollar con LitElement. Lo más sencillo es comenzar con el patrón mediator, que vamos a tratar a continuación.

Patrón mediador

El patrón Mediador, o "Mediator" define un flujo de los datos entre componentes en la jerarquía de la aplicación. De esta manera:

  • Las comunicaciones de los padres a los hijos se realizan por binding
  • Las comunicaciones de los hijos a los padres (o abuelos…) se realizan por medio de eventos.

En otras palabras, cuando desarrollemos componentes que se basan en otros componentes, el sistema de binding lo usaremos solamente para enviar datos desde los padres a los hijos. Ese binding unidireccional es además el único soportado por LitElement. Cuando un hijo tiene que comunicar un dato a un padre, entonces lo escala utilizando la burbuja de eventos.

Nota: La burbuja de eventos es algo propio de Javascript y de los navegadores. Básicamente provoca que los eventos suban de padres hacia los hijos, por toda la jerarquía de objetos del navegador (DOM), hasta llegar al objeto Window que es la raíz. Mediante la burbuja de eventos se pueden comunicar datos desde los padres a los hijos, usando el objeto evento que reciben todos los manejadores de eventos que se ejecuten.

Vamos a hacer ahora un ejemplo para que lo puedas comenzar a conocer y aplicar el patrón mediador, ya que es algo que se utiliza mucho en LitElement y el desarrollo frontend en general.

Bases del patrón mediador, para comunicación bidireccional de datos

El patrón mediador lo podemos aplicar al desarrollar componentes simples, así como aplicaciones enteras. Vamos a comenzar por unas nociones básicas.

Envío de datos desde padres a hijos

Esta parte ya la hemos explicado en el artículo de la sintaxis del binding en LitElement. De todos modos, recordemos que el paso de datos se hace por medio de propiedades y lo hacemos mediante la siguiente sintaxis en el template.

<my-element .prop=${this.data}>

En el caso anterior estamos enviando el un dato mediante 1 way binding. En el componente padre el dato está alojado en la propiedad "data". Lo enlazamos con la propiedad "prop" del hijo.

De este modo, cada vez que this.data cambia de valor en el componente padre, ese nuevo valor se estará comunicando al hijo, colocando ese nuevo dato en la propiedad "prop" del hijo.

Envío de datos del hijo al padre con eventos personalizados

Este envío del dato se origina en el hijo y va hacia el padre. LitElement hace uso del sistema de eventos del navegador para transportar esos datos, así que estaremos usando comportamientos que ya existen en el propio Javascript nativo.

Lo que se hace en estos casos es disparar un evento personalizado, en el código del componente hijo. Esto se hace con Javascript con el método dispatchEvent, que está vinculado a un objeto del DOM. Como nuestros elementos personalizados (componentes) son en sí mismos elementos del DOM, podemos hacer uso de la misma API disponible en ellos.

this.dispatchEvent(new CustomEvent('my-evento', {
      detail: this.value
}));

Así estamos disparando un evento personalizado llamado "my-evento". Este evento además envía un dato al padre. Para ello usamos la propiedad "detail" que enviamos en el objeto de configuración del evento disparado.

Los eventos personalizados se reciben en los padres. Aunque si queremos que escalen luego a los abuelos, bisabuelos y así hasta el objeto Window, tenemos que configurar el objeto evento de esta manera.

this.dispatchEvent(new CustomEvent('my-evento', {
      bubbles: true,
      composed: true,
      detail: this.value
}));

Recibir el evento y el dato

Una vez hemos disparado un evento, podemos asociar un manejador de eventos en el padre. Para ello podemos usar la función de Javascript nativo "addEventListener", como se hace habitualmente en Javascript.

Por ejemplo, así estaríamos asociando un manejador de evento, que se ejecutará cada vez que el objeto "document" reciba el evento personalizado.

document.addEventListener('my-evento', function(e) {
    console.log(e.detail.data);
});
Nota: para que el evento escale por la burbuja hasta llegar al objeto document, necesitas configurar el evento con bubbles y composed a true, como hemos visto antes.

Fíjate además que dentro del manejador podemos recuperar un dato mediante el objeto evento. El objeto evento lo recibimos en todas las funciones que hacen de manejadores de eventos. En el detalle del evento, propiedad detail, podemos recibir cualquier dato que nos mande el hijo.

Sin embargo, existe una forma más directa de definir un manejador de eventos, de manera declarativa en el template, como ya se adelantaba en el artículo anterior de la sintaxis en templates de LitElement.

<my-element @my-evento=${this.handler}>

Así estaríamos diciendo que el método handler del componente (this.handler) será encargado de procesar el evento personalizado "my-evento".

Ejemplo de componente que para implementar doble binding

Ahora que ya conoces todos los mecanismos para poder implementar el flujo de la información entre la jerarquía de componentes de una aplicación, vamos a ver un ejemplo completo de componente que envía y recibe datos del padre.

Este es un componente "input", que permite escribir texto en un campo de texto, avisando al padre cuando el texto cambie. Como LitElement no tiene doble binding usaremos el patrón mediador.

El componente recibe por propiedad el valor de cadena de texto, para mostrar en el campo input y, cada vez que el usuario escribe, lanza eventos personalizados al padre para avisar que el texto se ha modificado.

import { LitElement, html } from 'lit-element';

export class MyTextInput extends LitElement {
  static get properties() {
    return {
      value: { type: String }
    }
  }
  render() {
    return html`
      <p>
        <input type="text" .value="${this.value}" @input="${this.inputChange}">
      </p>
    `;
  }
  inputChange(e) {
    this.value = e.target.value;
    this.dispatchEvent(new CustomEvent('change', {
      detail: this.value
    }));
  }
}

customElements.define('my-text-input', MyTextInput);
  • Mi componente declara una propiedad "value" donde almacenamos el valor que hay escrito en el campo de texto.
  • En la etiqueta INPUT asignamos este valor mediante un binding de propiedad
  • También en el INPUT colocamos un manejador de evento, para @input, que se ejecutará cada vez que el usuario escriba algo en el campo de texto. El evento "input" es nativo de Javascript y se dispara automáticamente cuando cambia el texto escrito en el campo de texto.
  • Cuando se escribe en el campo de texto se actualiza el valor de la propiedad "value" del componente. Además se escala un evento hacia el padre, en el que enviamos como detalle el valor actual de la propiedad "value".

Usar el componente con doble binding

Ahora vamos a colocar el código de un segundo componente, que usa el elemento que se acaba de definir en el punto anterior. Es decir, vamos a implementar el componente que haría las veces de padre y vamos a proveerlo de las herramientas para poder realizar el doble binding mediante el patrón mediator.

import { LitElement, html } from 'lit-element';
import './my-text-input';

class MyElement extends LitElement {
  static get properties() {
    return {
      miDato: { type: String }
    };
  }
  constructor() {
    super();
    this.miDato = 'Valor de inicialización';
  }
  render() {
    return html`
      <p>Soy My Element</p>
      <my-text-input .value=${this.miDato} @change="${this.inputCambiado}"></my-text-input>
      <p>El dato escrito es ${this.miDato}</p>
      <button @click=${this.resetTexto}>Borrar texto</button>
    `;
  }

  inputCambiado(e) {
    this.miDato = e.detail;
  }

  resetTexto() {
    this.miDato = '';
  }
}

customElements.define('my-element', MyElement);
  • El componente padre hacemos el import del componente hijo, el que vamos a usar.
  • Definimos una propiedad llamada "miDato", donde vamos a guardar el dato.
  • Recordar que las inicializaciones las realizamos en el constructor. Allí estamos aplicando un valor inicial para la propiedad "miDato".
  • En el template hacemos uso del componente "my-text-input", enviando como propiedad value el valor de "this.miDato". Además también definimos un manejador de eventos para ejecutar código cuando cambia el dato. El evento que que estamos recibiendo en este caso es personalizado. Se llama "change" y se dispara desde el código del componente "my-text-input".
  • Adicionalmente tenemos un párrafo para poder visualizar el valor actual de la propiedad "miDato".
  • Para poder probar distintas posibilidades del flujo de datos, también tenemos un botón que hace un reseteo de la propiedad "miDato". Cuando se hace clic en el botón se llama al método resetTexto(), que se encarga de hacer una asignación a la cadena vacía.

Eso es todo, si montas ambos componentes en tu aplicación podrás observar que de esta manera hemos conseguido el flujo bilateral de los datos.

Observaciones finales sobre la ausencia del doble binding

No existe doble binding en LitElement. Pero eso no es un problema, porque con un poco de código somos capaces de producir el mismo comportamiento que tenemos en los frameworks que sí soportan doble binding.

Quizás, para los que lleguen a LitElement desde la librería Polymer, o desde otros frameworks, esta ausencia resultará significativa. Salta a la vista que la cantidad de código para producir el doble binding es significativamente mayor ahora, lo que en principio puede parecer una desventaja.

Lo que sí está claro que nada en la vida es gratuito. El aumento de rendimiento de LitElement acaba repercutiendo en la experiencia de desarrollo. Puedo imaginar que habrá opiniones de todos los tipos y desarrolladores que prefieran que el framework les proporcione herramientas como doble binding, a pesar de todos sus problemas. Sin embargo, gracias a esta carencia LitElement nos permite disfrutar de un peso menor de librería y una mayor velocidad.

La filosofía del equipo de Polymer está perfectamente reflejada en este caso: "Use the platform". Si algo lo puedes hacer directamente con el navegador, no tiene sentido que una librería cree nuevos mecanismos propietarios. Puedes seguir aprendiendo en el Manual de Lit-Element.