Ander San Miguel

Desarrollador Web

16/11/2017

Primer contacto con Web Components

Pues eso, que estamos todos un poco locos con React, Vue, Angular y etcéteras, pero casi se me pasa, que en breve (I hope), estaremos escribiendo nuestros componentes utilizando vanilla JS.

Así que el otro día me puse a mirar por encima el estado del asunto. Y está bien, no ha llegado a todos los navegadores pero ahí anda, así que los he empezado a probar y ... holly molly, me encanta. Vale, se necesita bastante más boilerplate que usando un framework, pero nada demasiado exagerado y el tema, ¡¡¡100% nativo!!!

En mi caso el primer contacto fue tal que así: Lo primero buscar algo de código para poner en pantalla, easy peasy. Me voy a MDN y cojo el primer ejemplo que encuentro, lo pego en un documento nuevo y, oh sorpresa, oh dolor, no funciona. Ok, Firefox, no hay soporte, no pasa nada, lo puedo abrir en Chrome o puedo usar un polyfill. Pues polyfill al canto, seguimos.

Veo cosas familiares: atributos, «hooks», ... creo que puedo trabajar con esto.

Es momento de arrancar con uno e ir aprendiendo sobre la marcha, ¡mi cosa favorita!

Lo primero a saber es que un custom element es una clase de es6 que extiende de HTMLElement, vale, mi elemento va a ser un temporizador que inicia una cuenta atrás según el valor del atributo time y que desaparece al llegar a cero. Entonces, al insertar el elemento del dom iniciará la cuenta atrás y cada segundo volverá a recrear los nodos que muestran el valor en ese momento, poco más:

class Timer extends HTMLElement {
  // Sólo un atributo => time
  static get observedAttributes() { return ['time']; }

  connectedCallback() {
    // Aquí es donde empieza todo
    // Inicio la cuenta atrás
    this.startCountDown();
    // Renderizo!
    this.render();
  }

  attributeChangedCallback(attr, oldValue, newValue) {
    // Aja, aja, aquí ha cambiado un atributo (sólo los observados)
    if (attr === 'time') {
      // No quiero strings
      this.time = parseInt(newValue);
      // Vuelvo a renderizar
      this.render();
    }
  }

  startCountDown() {
    // Cuenta atrás, la asigno a una variable local
    this.timer = setInterval(() => {
      this.time--;
      if (this.time == 0) {
        // Fin, quito intervalo
        clearInterval(this.timer);
      }
      // Cada vez renderizo
      this.render();
    }, 1000);
  }

  render() {
    // Locura, template literals
    var dom = `
      <div class="timer ${this.time <= 0 ? 'hide' : ''}">
      <p>${this.time}</p>
      </div>
      `;
    this.innerHTML = dom;
  }

}

En la función render uso template literals que es una forma muy cool de escribir plantillas en javascript. Y cuando llega a cero el temporizador añado una clase hide.

Bueno, no ha sido muy difícil, ahora sólo falta definir el elemento:

customElements.define('timer', Timer);

Y ya puedo usar el elemento:

<timer time="5"></timer>

Refresco la página y no veo nada, yaiks. Busco en google, ok, el nombre del elemento tiene que tener guión (es por especificación, para que no de conflictos con los elementos nativos), ok:

customElements.define('basic-timer', Timer);

Y ya puedo usar el elemento:

<basic-timer time="5"></basic-timer>

Ahora sí. Cool. Pero, ¿y no falta todo eso del shadow DOM? Pues no, se puede usar, y tiene sus ventajas, pero no hace falta, aunque si quisieramos...

Añadimos una función constructor y añadimos un shadow DOM a nuestro elemento:

constructor() {
  // Lo primero llamar a super, no vaya a ser que rompamos algo
  super();
  // Añadimos shadow y lo guardamos en una variable
  // mode open => accesible desde el exterior; closed => lo contrario
  this.shadow = this.attachShadow({ mode: 'open' });
}

Luego en nuestra función render:

var dom = `
<div class="timer ${this.time <= 0 ? 'hide' : ''}">
<p>${this.time}</p>
</div>
`;

this.shadow.innerHTML = dom;

Lo siguiente, performance. Los defensores acérrimos de React y similares siempre alaban la rapidez de sus frameworks y evitan la manipulación del DOM lo máximo porque es lo más lento. Y yo aquí renderizando todo cada vez...

Pues a ello, tengo dos opciones, currarme la creación y renderizado de mi DOM o usar una librería de renderizado de plantillas tipo lit-html.

Con la primera opción pierdo las plantillas de javascript :(, pero se puede usar ya, aunque la verdad es que el código es un poco más rollo. Cambios: dos funciones, renderizado inicial y luego los parciales; la construcción del DOM es elemento por elemento.

class Timer extends HTMLElement {
  // Sólo un atributo => time
  static get observedAttributes() { return ['time']; }

  connectedCallback() {
    // Aquí es donde empieza todo
    // Inicio la cuenta atrás
    this.startCountDown();
    // Renderizo! => pero todo
    this.initialRender();
  }

  attributeChangedCallback(attr, oldValue, newValue) {
    // Aja, aja, aquí ha cambiado un atributo (sólo los observados)
    if (attr === 'time') {
      // No quiero strings
      this.time = parseInt(newValue);
      // Vuelvo a renderizar => este es parcial
      this.render();
    }
  }

  startCountDown() {
    // Cuenta atrás, la asigno a una variable local
    this.timer = setInterval(() => {
      this.time--;
      if (this.time == 0) {
        // Fin, quito intervalo
        clearInterval(this.timer);
      }
      // Cada vez renderizo => parcial
      this.render();
    }, 1000);
  }


  initialRender() {
    // Elemento contenedor con su clase
    this.$root = document.createElement('div');
    this.$root.classList.add('timer');

    // El reloj
    this.$inner = document.createElement('p');

    this.$root.appendChild(this.$inner);
    // Y lo añado al DOM
    this.appendChild(this.$root);
    // Y el texo (el número)
    this.$inner.textContent = this.time;
  }

  render() {
    // Esta es la función parcial
    if (this.time <= 0) {
      // Aquí cambio la clase
      this.$root.classList.add('hide');
    } else {
      if (this.$inner) {
        // Sólo cambio el texto
        this.$inner.textContent = this.time;
      }
    }
  }

}

Ais, mi no gustar demasiado, probemos con lit-html, los cambios respecto a la primera versión. Importar el archivo como módulo de es6 (sólo con esto ya me aseguro de que sólo funcione en chrome), para ello me tengo que asegurar de que la etiqueta script que llame a mi elemento tenga type=module, pero luego sólo tengo que modificar ligeramente mi función de render:

import {html, render} from 'https://unpkg.com/lit-html@0.7.1/lib/lit-extended.js';

...

render() {
  render(html`
    <div class="timer ${this.time <= 0 ? 'hide' : ''}">
      <p>${this.time}</p>
    </div>
  `, this);
}

Me gusta, me gusta. Aunque lo dicho no es usable de momento, así que me quedo con la primera versión y si llego a algún tope de performance me curro la segunda opción.

Y ya por último, tengo mi flamante elemento que llega a cero y se oculta, pero tendré que avisar a algo de que la cuenta a acabado: eventos. Eso, es, disparar un custom event, sólo tengo que modificar mi función startCountDown:

startCountDown() {
  this.timer = setInterval(() => {
    this.time--;
    if (this.time == 0) {
      clearInterval(this.timer);
      var event = new Event('timer');
      // Dispatch the event.
      this.dispatchEvent(event);
    }
    this.render();
  }, 1000);
}

Demo de todo junto

See the Pen mqBryB by Ander (@andersanmiguel) on CodePen.

Desarrollo Web

Esto es a lo que me dedico, así que es lo que más vas a encontrar en esta página.

Contacto

Esta sección está clara, si quieres contactar conmigo, este es un buen sitio.