Eventos en WebComponents

Al igual que en cualquier documento, al crear WebComponents nos interesará prepararnos ante ciertas acciones interactivas que el usuario podría realizar, por lo que sería bueno echar un vistazo a las posibilidades que tenemos y las recomendaciones que existen al utilizar eventos en componentes nativos.

WebComponents: Events

Eventos

Los eventos son acciones que realiza el usuario (consciente o inconscientemente), y que como desarrolladores, tenemos que prever y preparar en el código para saber manejarlos y decirle a nuestra página o aplicación como debe actuar cuando ocurra uno.

Existen tres formas de definir eventos en nuestro código:

Estrategia Ejemplo
A través de un atributo HTML donde asocias la función. <tag onclick="...">
A través de una propiedad JS donde asocias la función. tag.onclick = ...
A través del método addEventListener donde añades la función. tag.addEventListener("click", ...)
Desde atributos HTML

Probablemente, la forma más sencilla. En ella, definimos un evento a través de un atributo HTML. Los atributos, cuando son eventos, siempre comienzan por on, y en el valor se indica la función que se quiere ejecutar cuando se dispare dicho evento:

<button onClick="sendMessage()">👀 Press me!</button>

<script>
  const sendMessage = () => alert("Hello!");
</script>

En este sencillo ejemplo, cuando el usuario haga clic en el botón, se disparará una función llamada sendMessage, que a su vez mostrará un mensaje de alerta al usuario.

Desde propiedades JS

Otra forma interesante que podemos contemplar, es haciendo uso de las propiedades de Javascript. Por cada evento, existe una propiedad disponible en el elemento en cuestión:

<button>👀 Press me!</button>

<script>
  const button = document.querySelector("button");
  button.onclick = () => alert("Hello!");
</script>

Por ejemplo, aquí vemos que el elemento button tiene una propiedad onclick (siempre en minúsculas) a la cuál se le puede asociar una función que se disparará cuando ocurra el evento.

Mediante addEventListener

La tercera y última forma sería utilizar el método .addEventListener(), mediante el cual añadimos una función que escucha el evento y, si ocurre, ejecuta dicha función. Este método permite una forma más cómoda y «programática» de añadir eventos, sin sobreescribir las funciones definidas anteriormente.

También tiene un método contrapartida, removeEventListener() que hace justo la operación contraria: eliminar del evento indicado la función que se ha especificado.

Nota: Aunque en los anteriores ejemplos hemos utilizado eventos click sobre un elemento del DOM, no todos los eventos dependen de un elemento del DOM. Por ejemplo, el evento keydown (cuando pulsas una tecla) puede escuchar sobre el objeto window o globalThis.

Eventos en componentes

Dicho esto, vamos a ver como podríamos utilizar eventos en componentes, analizando las tres estrategias anteriores, aplicándolo a lo que hemos aprendido de WebComponents, y observando las limitaciones y características de cada estrategia.

Empecemos con la primera, a través de atributos HTML:

<app-element></app-element>

<script>
  const sendMessage = () => alert("Hello!");

  customElements.define("app-element", class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    connectedCallback() {
      this.shadowRoot.innerHTML = `
        <button onClick="sendMessage()">👀 Press me!</button>
      `;
    }
  });
</script>

En este ejemplo vemos un componente muy sencillo con el evento aplicado. Sin embargo, hay un problema bastante evidente: la función sendMessage() asociada debe estar en un contexto global, lo que en nuestro ejemplo se traduce como poner la función fuera de la clase del componente.

Esto no nos interesa, ya que precisamente lo que buscamos es modularizar toda la funcionalidad relacionada en el propio componente, evitando "polucionar" el contexto global.

Vamos a intentar mejorar un poco nuestro ejemplo, utilizando el método .addEventListener para eliminar las limitaciones que tuvimos en el ejemplo anterior:

<app-element></app-element>

<script>
  customElements.define("app-element", class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    sendMessage() {
      alert("Hello!");
    }

    connectedCallback() {
      this.shadowRoot.innerHTML = "<button>👀 Press me!</button>";
      this.button = this.shadowRoot.querySelector("button");
      this.button.addEventListener("click", this.sendMessage.bind(this));
    }

    disconnectedCallback() {
      this.button.removeEventListener("click", this.sendMessage.bind(this));
    }
  });
</script>

Nota: Observa que al igual que añadimos el listener en el connectedCallback(), es una buena práctica eliminarlos en el disconnectedCallback(). Esto puede ser necesario o prescindible, dependiendo del uso que se le dé al componente y si puede llegar a ser eliminado de la página.

La cosa se empieza a complicar. Repasemos que hemos hecho:

  • La función sendMessage la hemos colocado dentro del componente, como un método de clase.
  • Ahora, en el .innerHTML que hacíamos antes, solo insertamos el marcado HTML, sin evento.
  • La propiedad de clase this.button hará referencia al botón que tenemos en el Shadow DOM.
  • Añadimos un evento de tipo click que lance el método de clase sendMessage().

Dos puntualizaciones respecto al último punto:

En lugar de crear el evento con .innerHTML, lo que hemos hecho es crear el evento mediante el método .addEventListener(), utilizando una sintaxis quizás algo extraña a la hora de llamar al método de clase. Vamos a explicar ese segundo parámetro del .addEventListener(), que se espera que sea la función a asociar al evento que estamos tratando:

this.sendMessage()           /* Opción 1 */
this.sendMessage             /* Opción 2 */
this.sendMessage.bind(this)  /* Opción 3 */
() => this.sendMessage()     /* Opción 4 */
  • Opción 1: Quizás lo primero que se nos ocurra sea escribir this.sendMessage(). Sin embargo, si lo pensamos bien, esto lo que haría sería ejecutar el método de clase sendMessage(), y el valor devuelto por ese método es el que se pasa finalmente como segundo parámetro de .addEventListener(), por lo que no es lo que buscamos.

  • Opción 2: Si la opción 1 no nos vale porque estamos ejecutando el método, podríamos probar a indicar this.sendMessage (sin especificar los paréntesis que hacen que se ejecute el método). Con esto, estaríamos efectivamente pasando una referencia al método de clase y podría funcionarnos correctamente.

Sin embargo, esta segunda opción tiene un pequeño inconveniente. El método de clase se ejecutará correctamente cuando sea necesario, pero habremos perdido el contexto de this dentro del método, que en este caso, será una referencia al elemento que contiene el evento (button) y no a la clase del componente.

  • Opción 3: En este caso solucionamos el problema anterior indicando el método que queremos ejecutar, pero llamando al método .bind() (disponible en todas las funciones), pasándole por parámetro el valor que queremos que tenga this dentro de esa función. Esta era la solución usada en Javascript y anteriores. Personalmente, me parece bastante críptica, teniendo a nuestra disposición la opción 4.

  • Opción 4: Como el segundo parámetro de .addEventListener() se espera una función, podemos pasar una función flecha anónima que ejecute la función que nos interesa y devuelva su resultado. Al estar dentro de la función flecha, no se ejecuta directamente como en la opción 1 y no hay problema al indicarle los (), por lo que resulta más natural. Además, es mucho más legible que la opción anterior. Por último, las funciones flecha, al no tener un concepto propio de this, pues no pierden el valor como en la opción 2, y this sigue haciendo referencia a la clase del componente.

Utilizando esta última opción 4, nuestro componente quedaría así:

<app-element></app-element>

<script>
  customElements.define("app-element", class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    sendMessage() {
      alert("Hello!");
    }

    connectedCallback() {
      this.shadowRoot.innerHTML = "<button>👀 Press me!</button>";
      this.button = this.shadowRoot.querySelector("button");
      this.button.addEventListener("click", () => this.sendMessage());
    }

    disconnectedCallback() {
      /* Ojo, esto no funcionará correctamente */
      this.button.removeEventListener("click", () => this.sendMessage());
    }
  });
</script>

Observa que el problema de utilizar una función flecha anónima en el .addEventListener(), es que complica el poder realizar un .removeEventListener(), ya que este requiere la misma función que se utilizó en el .addEventListener().

No nos valdría volver a definir una nueva función flecha anónima igual a la anterior (seguiría siendo una función diferente aunque haga lo mismo), sino que tendríamos que guardar la anterior en una propiedad de clase, lo que probablemente iría complicando demasiado la lógica y puede que no nos interese.

Otra opción que podría ser interesante, y que vimos más atrás, es utilizar las propiedades del elemento en lugar del método .addEventListener(), lo que nos permitiría hacer una asociación de la función flecha, y retirarla simplemente con una asignación a :

<app-element></app-element>

<script>
  customElements.define("app-element", class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    sendMessage() {
      alert("Hello!");
    }

    connectedCallback() {
      this.shadowRoot.innerHTML = "<button>👀 Press me!</button>";
      this.button = this.shadowRoot.querySelector("button");
      this.button.onclick = () => this.sendMessage();
    }

    disconnectedCallback() {
      this.button.onclick = null;
    }
  });
</script>

En este caso, el inconveniente más evidente es que no tenemos la facilidad para añadir nuevos eventos encima de otros como con .addEventListener(), sino que tendríamos que crear una nueva función flecha que contenga todo lo que queramos disparar.

Nota: Hay que tener mucho cuidado con los eventos cuando reescribimos parte del DOM de nuestro componente, especialmente cuando utilizamos .innerHTML. Eventos como click o mousedown están asociados a los elementos del DOM, por lo que si los reescribimos perderemos el evento, que ya no estará escuchando en el nuevo elemento del DOM, como quizás creemos.

La función mágica handleEvent

Para intentar evitar los problemas anteriores, existe un patrón Javascript muy interesante que permite organizar el código de nuestra clase de una forma muy elegante. Se trata de crear un método llamado .handleEvent() que se encargará de gestionar los eventos y reenviarlos a donde corresponda:

<app-element></app-element>

<script>
  customElements.define("app-element", class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    handleEvent(event) {
      if (event.type === "click")
        this.sendMessage();
    }

    sendMessage() {
      alert("Hello!");
    }

    connectedCallback() {
      this.shadowRoot.innerHTML = "<button>👀 Press me!</button>";
      this.button = this.shadowRoot.querySelector("button");
      this.button.addEventListener("click", this);
    }

    disconnectedCallback() {
      this.button.removeEvenetListener("click", this);
    }
  });
</script>

Observa que en el segundo parámetro del método .addEventListener() simplemente colocamos this (una referencia a la clase). Como es una referencia a una clase, el navegador buscará mágicamente si existe un método llamado .handleEvent() y si existe, lo procesa.

En dicho método, lo que haremos es comprobar el event.type, que es el tipo de evento que se ha lanzado (click, mousedown, mousemove, etc...) y ejecutar la función que buscamos. De esta forma centralizamos en este método la gestión de las funciones necesarias, por lo que nos quedará todo mucho más organizado.

Existen algunas librerías externas (de terceros) como lit-html o htm que facilitan la forma en la que trabajamos con el HTML, DOM y eventos en componentes, y pueden hacernos la vida un poco más fácil. Las veremos un poco más adelante.

Manz
Publicado por Manz

Docente, divulgador informático y freelance. Autor de Emezeta.com, es profesor en la Universidad de La Laguna y dirige el curso de Programación web FullStack y Diseño web FrontEnd de EOI en Tenerife (Canarias). En sus ratos libres, busca GIF de gatos en Internet.