Web Components#

En el capítulo del DOM hemos introducido gran parte de los conocimientos necesarios para este capítulo. No obstante, necesitamos haber trabajado con todos los demás conceptos y haber practicado con proyectos simples para entender la importancia de los Web Components.

Si queremos hacer una SPA que maneje datos obtenidos del servidor y los muestre de manera reactiva, podemos seguir estos pasos:

  • El router manda descargar los datos del servidor y los pasa a una función que los aplica a una plantilla.

  • El router se suscribe a un Observable de los datos del servidor y cada vez que llegan nuevos datos los renderiza con una plantilla.

  • El router renderiza una plantilla con “placeholders”, espera los datos del servidor y rellena la plantilla con esos datos.

En cualquiera de estas opciones, hay problemas para volver a renderizar cuando hay datos nuevos, para mantener a raya a las suscripciones, para gestionar el estado de la aplicación o para mantenerla suficientemente desacoplada. También es complicado reutilizar esas funciones en otros proyectos.

Los Web Components son una definición de la W3C para estandarizar lo que comenzaba a ser un aspecto común de los frameworks e intentar hacer componentes reutilizables para todos. Estos componentes se pueden hacer a partir de las mejoras de ES6 y HTML5. Se trata de una tecnología base que no depende de ningúna librería o framework. Aunque las librerías y frameworks pueden hacer uso de ellos para ampliarlos o mejorar la facilidad de uso.

En la definición que podemos encontrar en la web de MDN: https://developer.mozilla.org/en-US/docs/Web/API/Web_components, no incorpora la reactividad, pero nosotros vamos a incorporarla para hacerlos más útiles en el curso.

Llegado el momento de hacer Web Components desde 0 con “vanilla Javascript” nos podemos preguntar si no será mejor usar un framework. Intentemos resistir esa tentación, al menos para aprender, porque esos conocimientos nos permitirán entender profundamente cómo funciona, por ejemplo, Angular o React. También hay frameworks especializados en crear Web Components como Lit: https://lit.dev/

Conceptos iniciales#

Antes de comenzar con los detalles, hay que destacar los tres pilares de los Web Components:

  • Custom Elements

  • Shadow DOM

  • Templates

Se pueden crear componentes de muchas maneras, pero la definición oficial parte de la posibilidad de crear Elementos personalizados o Custom Elements. Esto se puede hacer porque Javascript permite heredar de HTMLElement: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements. Luego veremos las posibilidades que hay con esto.

Una de las necesidades cubiertas por los Web Components es la reutilización del código. Los elementos personalizados pueden ser diferentes al resto de la aplicación y tener sus propios scripts y estilos. Estos pueden colisionar con una aplicación para la que no fueron creados desde el principio. Pero Javascript permite encapsular estos elementos en un DOM separado del principal. Esta técnica se llama Shadow DOM.

Para crear elementos personalizados en el Shadow DOM, se pueden hacer programáticamente o a través del HTML con etiquetas como <template> o <slot>.

Por consiguiente, un Web Component tiene una clase que hereda de HTMLElement registrada en el CustomElementRegistry para que pueda ser utilizada en cualquier parte. Este elemento tiene un Shadow DOM (opcional) para que su código y estilos no molesten al resto y se suele usar <template> para las plantillas. A continuación se puede usar como cualquier etiqueta.

Veamos un ‘hola mundo’:

class HelloWorldComponent extends HTMLElement {
    connectedCallback() {
        this.textContent = 'hello world!';
    }
}
customElements.define('x-hello-world', HelloWorldComponent);
const helloComponent = document.createElement('x-hello-world');
document.body.append(helloComponent);

A parte de insertar el componente por javascript, también podemos incluirlo directamente en el HTML:

<x-hello-world></x-hello-world>

Alternativas#

Como todo en Javascript, es opcional usar Web components, pero es que también es opcional usar todas las técnicas anteriores. Si no hacemos un Custom Element no entra en la definición de lo que estamos tratando, pero se puede hacer una función que retorne un elemento con Shadow DOM y código personalizado que atiende a los eventos, descarga los datos, se suscribe a Observables… El problema es que perdemos las ventajas del ciclo de vida de los HTMLElements.

Tampoco es necesario siempre hacer Shadow DOM si el código o estilo no va a interferir nunca.

Por otro lado, el uso de <template> puede ser farragoso en algunos casos comparado con las Template Literals. En nuestro caso, haremos un uso combinado de los mismos para aprovechar las ventajas de ambas técnicas.

Custom Elements#

Se trata de elementos HTML que tienen un comportamiento definido por el desarrollador. Una vez registrados, quedan disponibles en el navegador en esa página web.

Ya existen algunos creados como HTMLImageElement o HTMLParagrafElement que tienen un comportamiento extendido respecto al estándar de los elementos comunes. Pero a nosotros nos interesa crear nuevos desde cero.

Para crearlo tan solo hay que extender la clase:

class PopupInfo extends HTMLElement {
  constructor() {
    super();
  }
  // Element functionality written in here
}

En el constructor se inicializa el elemento con valores por defecto, registro de eventos, creación del Shadow Root y poco más. Dejaremos la creación de elementos hijos o añadir atributos a estos para después, en otras etapas del ciclo de vida.

Ciclo de vida#

Los elementos personalizados cuentan con una serie de funciones que pueden ser implementadas y que son invocadas a lo largo de su ciclo de vida. Estas son:

  • connectedCallback(): Se llama cada vez que se agrega el elemento al documento. Se recomienda configurar el elemento en este punto, mejor que en el constructor.

  • disconnectedCallback(): Se llama cada vez que se elimina el elemento del documento.

  • adoptedCallback(): Se llama cada vez que se mueve el elemento a un nuevo documento.

  • attributeChangedCallback(): se llama cuando se cambian, agregan, eliminan o reemplazan atributos.

Podemos probar este código en la consola del navegador:

class MyCustomElement extends HTMLElement {
  static observedAttributes = ["class", "size"];

  constructor() {
    super();
      console.log("Constructor")
  }

  connectedCallback() {
    console.log("Custom element added to page.");
  }

  disconnectedCallback() {
    console.log("Custom element removed from page.");
  }

  adoptedCallback() {
    console.log("Custom element moved to new page.");
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute ${name} has changed from ${oldValue} to ${newValue}.`);
  }
}

customElements.define("my-custom-element", MyCustomElement);
// La prueba
let customElement = document.createElement('my-custom-element');
console.log('Creado');
document.body.append(customElement);
console.log('Añadido');
customElement.classList.add('customClass','customClass2');
console.log('Cambio del atributo class');
customElement.remove();
console.log('Eliminado');

Además de esas funciones del ciclo de vida, se pueden añadir otras. Una típica puede ser la función render() que retorna el elemento redibujado. Esto es útil si queremos que el contenido pueda ser actualizado reactivamente.

Elementos HTML nativos (como div, span, etc.) no tienen métodos del ciclo de vida de los Web Components, por lo que asignar connectedCallback o otras manualmente no funcionará.

Registrar el elemento personalizado#

Como se puede ver en el ejemplo anterior, lo hemos registrado con:

customElements.define("my-custom-element", MyCustomElement);

Es importante recalcar que el nombre es preciso que tenga un guión (-) en medio del nombre para que Javascript no los confunda.

Usar el elemento personalizado#

Se puede usar con document.createElement() y como cualquier etiqueta HTML:

<my-custom-element></my-custom-element>

Shadow DOM#

Como ya hemos visto en la introducción, el objetivo del Shadow DOM es encapsular el comportamiento del elemento personalizado.

A partir de la raíz de DOM principal de la página web cuelgan todos los nodos de forma jerárquica. El Shadow DOM permite registrar árboles ocultos a partir de un nodo del árbol principal. Estos árboles empiezan por el Shadow Root. Esta raíz está unida al árbol principal mediante un nodo que actúa de Shadow Host.

Se pueden crear de forma imperativa mediante Javascript:

const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);

También se puede crear de forma declarativa (desde el servidor) mediante HTML:

<div id="host">
  <template shadowrootmode="open">
    <span>I'm in the shadow DOM</span>
  </template>
</div>

La segunda manera permite delegar la creación del Shadow DOM al servidor, al enviar este el HTML ya sea dinámica o estáticamente.

Observemos que en los dos casos tenemos la palabra open, esta permite que el Shadow DOM sea accesible desde la web. Si está en close, se verá, pero no será accesible mediante selectores.

Si no ponemos el shadowrootmode en el template no se verá, si lo ponemos, ya sea open o close, se verá pero será o no accesible. Esto es porque template tiene dos propósitos, sin especificar el shadowrootmode es una plantilla útil para ser clonada, pero pensada para ser renderizada. Si lo ponemos como en el ejemplo, estamos indicando que requemos que actúe de shadow root.

Llegados a este punto, podemos poner esto en práctica copiando y pegando este código en la consola de cualquier web que tengamos abierta:

const body = document.querySelector("body");
const host = document.createElement('div');
body.append(host);
const shadow = host.attachShadow({ mode: "closed" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM from Javascript";
shadow.append(span);

const host2 = document.createElement('div');
host2.innerHTML = `
  <template shadowrootmode="open">
    <span>I'm in the shadow DOM from HTML</span>
  </template>
`;
body.append(host2);

El segundo no se va a ver porque innerHTML no crea el Shadow Root. Este, por javascript, ha de ser creado explícitamente como en el primer ejemplo. No obstante, aunque no se vea, el template queda accesible y puede ser clonado para incorporarlo más adelante.

No es posible que un Element que actua como shadow host tenga tanto shadow-root como elementos hijos en el árbol principal. No da error, pero no serán visibles. De la misma manera, cualquier div o otros elementos que contengan <template> se convierte automáticamente en un shadow host, por lo que no puede tener otras etiquetas normales dentro.

los nodos descencientes del shadow-root no son accesibles mediante document.querySelector, por ejemplo. No obstante, son accesibles a través del atributo shadowRoot, el cual permite ejecutar querySelector internamente.

host.shadowRoot.querySelector("span");

Estilos en shadow DOM#

Para dar estilo a un shadow DOM se puede hacer modificando .style... en los elementos hijos, poniendo la etiqueta <style> y escribiendo el CSS ahí mismo o importando los ficheros CSS.

Los estilos generales no se aplican a los elementos dentro del shadow root. En general es positivo que los estilos del DOM principal no afecten a los Web components. Esto los hace más robustos para poderlos reutilizar y que siempre se vean igual. También hace que sus estilos no puedan contaminar los de la web principal. No obstante, si estamos usando, por ejemplo, Boostrap tanto en el componente como en la web en general, podemos aplicar alguna de las técnicas que explicaremos a continuación:

Podemos usar un código como este en la creación del componente para que adopte las reglas del documento:

const adoptedStyleSheets = new CSSStyleSheet();
const rules = [...document.styleSheets]
  .flatMap((s) => [...s.cssRules].map((r) => r.cssText))
  .join(" ");
adoptedStyleSheets.replace(rules);
shadow.adoptedStyleSheets = [adoptedStyleSheets];

Aquí se ha obtenido la lista de reglas de todas las hojas de estilo y se han añadido al shadow. Para ello hay que transformar mediante flatMap y demás la lista de estilos, dentro de ellos las reglas y dentro las reglas en string, crear un CSSStyleSheet personalizado y añadir todas las reglas.

También se podrían buscar solo las reglas de un determinado tipo, una regla en concreto o las que vienen en un fichero.

En el caso de usar Vite y Bootstrap, podemos importar el css con ?inline, lo cual importa el texto del css y no lo inyecta en la web (https://vitejs.dev/guide/features#disabling-css-injection-into-the-page). Luego ese texto se puede usar tal cual en la plantilla. Observa el ejemplo:

import * as bootstrap from "bootstrap";
import "./styles.scss";
import styles from "./styles.scss?inline";

const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("div");
span.innerHTML = `
 <style>${styles}</style>
<button type="button" class="btn btn-primary">Primary</button>
<button type="button" class="btn btn-secondary">Secondary</button>
<button type="button" class="btn btn-success">Success</button>
<button type="button" class="btn btn-danger">Danger</button>
<button type="button" class="btn btn-warning">Warning</button>
<button type="button" class="btn btn-info">Info</button>
<button type="button" class="btn btn-light">Light</button>
<button type="button" >Dark</button>
`;
shadow.appendChild(span);

CSS Scopes / Shadow Parts#

Si usamos en css la etiqueta en concreto si les afecta el css:

custom-element {
  display: block;
  background: black;
  color: white;
  padding: 10px;
  margin: 5px;
}

Para acceder desde dentro del shadow root al elemento que lo contiene, que es el custom element se puede usar :host, host() y :host-context():

:host {
  display: block;
  background: black;
  color: white;
  padding: 10px;
  margin: 5px;
}
span {
  font-weight: bold;
  vertical-align: super;
  font-size: small;
}

Templates#

Ya hemos visto en el apartado anterior que se pueden crear Shadow Root desde una <template>. En los ejemplos anteriores eran usados con ese propósito, pero la etiqueta <template> también puede ser usada para hacer plantillas que no se van a ver hasta que se clonen.

Suponiendo que definimos esta plantilla:

<template id="custom-paragraph">
  <p>My paragraph</p>
</template>

Podemos crear un Web Component de esta manera:

customElements.define(
  "my-paragraph",
  class extends HTMLElement {
    constructor() {
      super();
      let template = document.getElementById("custom-paragraph");
      let templateContent = template.content;

      const shadowRoot = this.attachShadow({ mode: "open" });
      shadowRoot.appendChild(templateContent.cloneNode(true));
    }
  }
);

Observemos que estamos extendiendo la clase HTMLElement dentro de la misma función customElements.define(), que creamos explícitamente el Shadow Root y que le añadimos un clon de la <template>.

Es posible crear plantillas con <template> que puedan ser modificadas después de ser clonadas. Se puede hacer manualmente buscando dentro de ellas los elementos y cambiando su innerText o value. Por ejemplo, si tenemos que crear una tabla y poner datos en cada fila, podemos tener una plantilla para las filas y, en este caso, para la última columna:

<template id="tableBodyTR">
  <tr>
    <th scope="row">{id}</th>
    <td>{ejemplo}</td>
  </tr>
</template>
<template id="lastColumn">
  <td class="buttonsCol">
    <span class="edit">📝</span><span class="delete">🗑️</span>
  </td>
</template>
let lastColumnTemplate = div.querySelector("#lastColumn");
let tableBody = div.querySelector("#tableBody");
let tableBodyTR = div.querySelector("#tableBodyTR");

for (let row of datos) {
  let tableBodyTRRow = tableBodyTR.content.cloneNode(true).querySelector("tr");
  tableBodyTRRow.innerHTML = columns
    .map((col) => `<td data-col="${col}">${row[col]}</td>`)
    .join("");
  let lastColumn = lastColumnTemplate.content
    .cloneNode(true)
    .querySelector("td");
  lastColumn.dataset.id = row.id;
  tableBodyTRRow.append(lastColumn);
  tableBody.append(tableBodyTRRow);
}

En el ejemplo anterior, la primera columna de la plantilla tableBodyTR desaparece por el innerHTML.

Para usar <template> no es necesario estar en un Web Component. Se puede usar en cualquier momento, por eso también está explicado en el capítulo del DOM.

Usar <template> tiene mucho más sentido si parte del HTML se genera en el servidor. Este puede enviar las plantillas dentro del documento y estas ser extraidas y utilizadas en el cliente. Si hacemos una SPA totalmente generada en el cliente, puede que sea más rápido usar las funciones del DOM o template Literals.

Alternativa con Template Literals#

Usar <template> implica tener que buscarlo, clonarlo y modificarlo. En ocasiones es mucho más simple el uso de Template Literals. Incluso se puede hacer con Tagged Template Literals y queda un código más compacto. En el capítulo del DOM hay un ejemplo: https://xxjcaxx.github.io/libro_dwec/dom.html#creacion-de-elementos-mediante-tagged-template-literals

Pasar datos al componente#

En los Web Components, a menudo hay que inicializarlos con algunos datos. Depende del propósito y cómo se añadan al DOM, se puede hacer de varias formas:

  • Mediante el constructor

  • Mediante atributos

  • Mediante data-

  • Mediante slots

  • Mediante propiedades o métodos.

  • Mediante Observables (RxJS).

  • Elementos hijos con

  • Custom Events

Constructor de los componentes#

Como se trata de una clase, tiene constructor. Si lo creamos con new, podemos pasarle parámetros por ahí. Esta forma excluye el uso de la etiqueta en un HTML. La forma de crear un Web Component sería la siguente:

class MyComponent extends HTMLElement {
    constructor(dataService) {
        super();
        this.dataService = dataService;
    }
    connectedCallback() {
        const data = this.dataService.fetchData();
        this.innerHTML = `<p>${data}</p>`;
    }
}

const dataService = new DataService();
const myComponent = new MyComponent(dataService);
document.body.appendChild(myComponent);

Como veremos más adelante, este método se llama inyección de dependencias.

Atributos#

Los atributos se pueden usar directamente en el HTML del componente para pasar datos. Se acceden dentro del componente con this.getAttribute().

<my-component nombre="Juan"></my-component>
...
  class MyComponent extends HTMLElement {
    connectedCallback() {
      const nombre = this.getAttribute('nombre');
      this.innerHTML = `<p>Hola, ${nombre}!</p>`;
    }
  }
  customElements.define('my-component', MyComponent);

Hay que tener en cuenta que los atributos no son accesibles antes de que se añada al DOM. Por eso hay que acceder a ellos en en connectedCallback.

Se puede lograr un comportamiento reactivo con los atributos, ya que tenemos observedAttributes y attributeChangedCallback que permite atender a los cambios en los atributos:

static observedAttributes = ['src', 'alt'];

attributeChangedCallback() {
        this.update();
    }

update() {
        const img = this.querySelector('img');
        if (img) {
            img.src = this.getAttribute('src');
            img.alt = this.getAttribute('alt') || 'avatar';    
        }
    }

data-#

Es como los atributos pero más centralizado y accesible por .dataset.

<my-component data-nombre="María"></my-component>
...
  class MyComponent extends HTMLElement {
    connectedCallback() {
      const nombre = this.dataset.nombre;  // Accedemos con `dataset`
      this.innerHTML = `<p>Hola, ${nombre}!</p>`;
    }
  }
  customElements.define('my-component', MyComponent);

Problemas de pasar datos como atributos o en el dataset:

  • No se pueden para más que datos primitivos como string.

  • Si queremos pasar al componente un objeto complejo hay que serializarlo con JSON.stringify().

  • Objetos complejos como Maps o Sets hay que convertirlos a Objects o Arrays antes de serializar.

  • Incluso si las string tienen comillas simples o dobles por el medio dan problemas tanto como atributos primitivos como dentro de objetos al serializar.

  • No es posible pasar funciones como atributos.

Propiedades#

En este caso, los datos se pasan como propiedades de la instancia del componente en JavaScript. Las propiedades permiten una interacción más directa entre el componente y el código JavaScript que lo maneja. Las propiedades no son visibles en el código HTML y pueden ser objetos más complejos que simples strings como, Sets, Maps, funciones o Observables.

<my-component></my-component>
...

  class MyComponent extends HTMLElement {
    set nombre(value) {
      this.innerHTML = `<p>Hola, ${value}!</p>`;
    }
  }
  customElements.define('my-component', MyComponent);

  const myComponent = document.querySelector('my-component');
  myComponent.nombre = 'Carlos';  // Establecemos la propiedad desde JS

En este ejemplo se usa un Setter, ya que el componente existirá antes de tener la propiedad. Pero si lo creamos por Javascript, podemos atender a esa propiedad en el connectedCallback.

Métodos#

Muy parecido a las propiedades, en este caso hacemos que una propiedad sea un método al que le pasamos los datos y este, por ejemplo, actualiza el componente:

update(list) {
         this.innerHTML = `<ul>${list.map(item => `<li>${item}</li>`).join('')}</ul>`;
    }

Observables#

Utilitzando RxJS, se puede hacer que un componente sea reactivo a los cambios de datos pasados como flujos de eventos (Observables). Esto es útil para gestionar flujos asíncronos de datos.

<my-component></my-component>
...
  class MyComponent extends HTMLElement {
    connectedCallback() {
      this.nombre$ = new BehaviorSubject('');  // Observable reactivo
      this.nombre$.subscribe(nombre => {
        this.innerHTML = `<p>Hola, ${nombre}!</p>`;
      });
    }

    set nombre(value) {
      this.nombre$.next(value);  // Emitimos el nuevo valor
    }
  }
  customElements.define('my-component', MyComponent);

  const myComponent = document.querySelector('my-component');
  myComponent.nombre = 'Ana';  // Actualizamos el observable

Slots#

Algunas veces se puede hacer más elegantemente con <slot>. La etiqueta debe tener un name y permite ser sustituida.

Dentro de la plantilla se puede poner esta etiqueta de esta manera:

<p><slot name="my-text">My default text</slot></p>

Y será incorporada en la plantilla en HTML así:

<my-paragraph>
  <span slot="my-text">Let's have some different text!</span>
</my-paragraph>

Pasar elementos del DOM como hijos del Web Component#

El comportamiento por defecto es aceptar elementos dentro de la etiqueta. No obstante, si dentro del código del componente padre ponemos algo como this.innerHTML = ... el componente hijo se sobrescribirá.

Veamos cómo crear un badge para que se muestre en la parte superior de una imagen:

class customBadge extends HTMLElement {
  #contentDiv; // Esto será inicializado

  static observedAttributes = ["content"];
  constructor() {
    super();
  }
  connectedCallback() {
    if (!this.#contentDiv) {
      this.#contentDiv = document.createElement("span");
      this.#contentDiv.className = "badge";
    }
    const shadowRoot = this.attachShadow({ mode: "closed" });
    const style= document.createElement('style');
    style.textContent = `
            .badge-container {
                position: relative;
                display: inline-block;
            }
            .badge {
                position: absolute;
                top: 0;
                right: 0;
                background-color: red;
                color: white;
                border-radius: 50%;
                padding: 0.2em 0.5em;
                font-size: 0.75em;
                font-weight: bold;
            }
    `
    // Insertamos antes de los hijos que ya tiene, en vez de innerHTML
    const badgeContainer = document.createElement('div');
    badgeContainer.classList.add('badge-container')
    const slot = document.createElement('slot');
    badgeContainer.append(slot, this.#contentDiv);
    shadowRoot.append(style);
    shadowRoot.append(badgeContainer);

    this.update();
  }
  attributeChangedCallback() {
    this.update();
  }
  update() {
    if (this.#contentDiv)
      this.#contentDiv.textContent = this.getAttribute("content");
  }
}

customElements.define("custom-badge", customBadge);

interval(1000)
.subscribe(numero=> {
  document.querySelector('custom-badge').setAttribute('content',numero);
});

En este ejemplo hemos creado un badge que acepta un elemento hijo. Por un lado, tenemos un shadow root que incorpora tanto el estilo como el badge como los elementos hijos declararos explícitamente en el HTML. Para aceptar un elemento hijo, en este caso, usamos un slot que recogerá la imagen y la añadirá al badgeContainer y posteriormente al shadowRoot.

Este es un ejemplo de cómo añadirlo al HTML:

    <custom-badge content="6">
          <img src="javascript.svg" style="width: 100px; height: 100px;">
    </custom-badge>

Mediante Eventos#

Puesto que los componentes pueden emitir eventos, esto se puede usar para crear eventos personalizados para enviar información a los componentes padres. Incluso se puede hacer que un Web Component escuche los eventos en document para enviar información de unos componentes a otros.

Veremos un ejemplo en el apartado de mantener el estado de la aplicación.

Como hay muchas opciones, podemos ver esta tabla comparativa:

Método

Facilidad de uso en HTML

Soporte para objetos complejos o funciones

Facilidad de modificar durante ejecución

Usable antes de estar en el DOM

Posibilidad de que el componente tenga hijos

Constructor

No es usable directamente en HTML

Permite pasar cualquier tipo de dato

Permite modificar al crear el componente en JS

Datos accesibles desde la creación

Puede tener <slot> o hijos sin problema

Atributos

Fácil de definir en HTML

Solo soporta valores de tipo cadena (también JSON)

Modificable con setAttribute o attributeChangedCallback

Valores accesibles antes de conectar al DOM

Puede tener hijos o <slot>

data-* Atributos

Simple de usar en HTML

Solo soporta valores de tipo cadena aunque más organizados. (también JSON)

Modificable con dataset o setAttribute

Accesible antes de estar en el DOM

Puede tener hijos o <slot>

Slots

Requiere estructura HTML específica

Puede enviar texto o HTML

Baja. Cambiar el contenido del slot requiere manipulación del DOM

No. El contenido no está disponible hasta la conexión al DOM

Perfecto para componentes con estructura y contenido de hijos

Propiedades o Métodos

Usable solo en JavaScript

Permite pasar objetos y funciones

Las propiedades se pueden modificar directamente en el JS

Valores configurables antes y después de conectar

Permite tener hijos o <slot>

Observables (RxJS)

Requiere suscripción

Puede manejar cualquier tipo de dato

Cambios reflejados en tiempo real con subscribe

Sí, pero depende de la suscripción (mejor después de conectar)

Ideal para componentes con actualizaciones en vivo

Elementos Hijos (<slot>)

Fácil de usar en HTML

Media-Alta. Permite estructuras complejas usando HTML o Custom Elements

Media. Cambios requieren manipulación del DOM o redefinir el contenido

No. Solo disponible después de conexión al DOM

Sí. Permite diseñar estructuras de componente con hijos o slot

Eventos personalizados

Fácil de usar en HTML

Media-Alta. Permite estructuras complejas usando HTML o Custom Elements

Media. Hay que implementar los eventos y escuchar otros eventos

No.

Sí.

Mantener el estado de la aplicación con componentes#

_images/alternativasestado.png

Fig. 8 Decidiendo entre dos posibles gestiones del estado global.#

Mantener el estado es complejo. Podemos hechar mano de patrones de diseño. Por ejemplo, podemos centralizar el estado en un Singleton Observable y que todos los componentes se suscriban. Esto los hace muy acoplados con el estado global y puede ser complejo de gestionar en aplicaciones grandes. La solución puede ser usar la inyección de dependencias al construir el componente y así seguir manteniendo la aplicación desacoplada. Si optamos por esa alternativa sólo podemos crear los componentes por Javascript, ya que necesitamos usar el constructor.

Mantener el estado con Inyección de dependencias#

El framework Angular utiliza servicios que inyecta en los componentes. Con Javascript vanilla se puede conseguir algo similar.

Introducción a la inyección de dependencias#

Dejemos por un momento los Web Components para explicar la base teórica de la DI (Dependency injection). Supongamos que tenemos estas dos clases:

(()=>{
    class Api{
        constructor(){}
        post() { console.log("Api post"); }
    }
    class Order {
        constructor(){
            this.api = new Api();
        }
        makeOrder(){
            this.api.post()
        }
    }
    const order = new Order();
    order.makeOrder();
})();
Api post

Los problemas de este enfoque son: dificultad para hacer pruebas (habrá que simular o interceptar métodos de la API antes de las llamadas reales) y poca flexibilidad. Si la clase de API se usa en varios servicios (como pedidos, carrito, usuario), reemplazarla por otra (por ejemplo, para cambiar de HTTP a WebSockets) implicaría modificar cada parte del código que la usa. La inyección de dependencias y un contenedor DI resolverían estos problemas al permitir una configuración declarativa que especifica las dependencias que cada clase debe usar.

La siguiente aproximación puede ser:

(() => {
    class Api {
        constructor() {}
        post() { console.log("Api post"); }
    }
    class Order {
        constructor(api) {
            this.api = api;
        }
        makeOrder() {
            this.api.post()
        }
    }
    const order = new Order(new Api());
    order.makeOrder();
})();
Api post

En este caso, la clase order ya no gestiona la creación de su dependencia. El siguiente problema es que se pueden crear muchas instancias de la Api por distintos componentes y si el servicio mantiene el estado puede ser un problema tener varios estados. Mediante el patrón Singleton se consigue tener un sólo estado.

(() => {
    class Api {
        count = 0
        constructor() {
            if (Api.instance) {
                return Api.instance;
            }
            Api.instance = this;
        }
        post() { this.count++; console.log("Api post",this.count); }
    }
    class Order {
        constructor(api) {
            this.api = api;
        }
        makeOrder() {
            this.api.post()
        }
    }
    const order = new Order(new Api());
    order.makeOrder();
    const order2 = new Order(new Api());
    order2.makeOrder();
    order.makeOrder();
    order2.makeOrder();
})();
Api post 1
Api post 2
Api post 3
Api post 4

Aún tenemos el problema de tener que ir construyendo new Api() en muchos sitios. En caso de que el constructor de Apinecesite parámetros, hay que ir repitiendo por todo el código y es difícil de mantener. Para solucionar este problema podemos recurrir al patrón Factory y centralizar la creación de objetos o usar técnicas más avanzadas como proxies (tutorial con proxies: https://medium.com/@sasha.kub95/dependency-injection-di-and-di-container-with-vanilla-javascript-adb888498bd )

DI en Web Components#

En los Web Components contamos con una dificultad extra. Puesto que podemos necesitar crearlos sin javascript directamente en HTML, no podemos llamar a su constructor explícitamente para inyectarle las dependencias.

Una de las posibles soluciones es usar un contenedor global de dependencias. Este puede estar disponible para todos los componentes. Los componentes dependen del contenedor para acceder a sus servicios. Si bien el contenedor centraliza las dependencias y simplifica la gestión, esto puede crear un acoplamiento con el propio contenedor, haciéndolo menos flexible si se necesita cambiar la estrategia de inyección.

Ejemplo:

const contenedorDependencias = new Map();

const apiService = new ApiService();
const logService = new LogService();

contenedorDependencias.set('apiService', apiService);
contenedorDependencias.set('logService', logService);


class ComponenteUsuario extends HTMLElement {
  connectedCallback() {
    // Obtenemos el servicio necesario del contenedor
    this.apiService = contenedorDependencias.get(this.dataset.api_service);
    this.logService = contenedorDependencias.get(this.dataset.log_service);
    this.render();
  }
  ...
}

customElements.define('componente-usuario', ComponenteUsuario);
<componente-usuario data-api_service="apiService" data-log_service="logService">
</componente-usuario>

Este enfoque te permite manejar las dependencias de manera flexible y desacoplada usando un contenedor Map. Al utilizar dataset para especificar las claves de dependencia en HTML, se puede reutilizar el componente ComponenteUsuario en diferentes contextos sin modificar su código interno.

Mantener el estado con un Context Provider#

Por otro lado, podemos hacer que el estado se comunique entre componentes hijos y padres a través de atributos o propiedades (de padres a hijos) o eventos (de hijos a padres). Este enfoque tiene el problema de tener que pasar atributos que no necesitan los hijos intermedios.

Para evitar lo que en react llaman el Prop Drilling podemos centralizar el estado de alguna manera (Objetos, Sets, Maps, Observables, Redux…) y hacerlo accesible desde un componente que los contenga a todos.

Para hacer que algo que está en el primer nivel del DOM pueda compartir el estado con cualquier hijo se puede hacer con eventos y callblacks. El elemento hijo que necesite consultar el estado, puede enviar un evento y en el mensaje del evento incorporar un callback. El elemento que lo atienda (context provider), ejecutará ese callback así le pasará el estado al elemento que ha lanzado el evento.

_images/estadocomponentes.png

Fig. 9 Diagrama de la petición y paso del contexto#

En el siguiente ejemplo, el CustomInput emite un evento informando de su función para inicializar su Observable que atenderá a los cambios en el context. El componente CustomApp atiende a ese tipo de eventos ejecutando la función de callback que le ha enviado. Esta función, en este caso, se subscribe al Subject que le ha pasado y a partir de ahí se actualizará cada vez que cambie ese dato en el context. De esta manera, el estado global de la aplicación queda centralizado en un Map accesible para cualquier otro componente.

  class CustomInput extends HTMLElement {
    constructor() {
      super();
      this.input = document.createElement("input");
      this.shadowContainer = document.createElement("div");
    }

    update = (newValue) => {
      this.input.value = newValue.get(this.input.name);
    };

    connectedCallback() {
        this.input.name = this.getAttribute('name');
      requestAnimationFrame(() => {
        this.dispatchEvent(
          new CustomEvent("init", {
            bubbles: true,
            detail: {
              setObservable: (newValue) => {
                this.stateSubscription = newValue.subscribe(this.update);
              },
              requestedValue: this.input.name
            },
          })
        );
      });
      this.append(this.shadowContainer);
      const shadowRoot = this.shadowContainer.attachShadow({ mode: "closed" });
      shadowRoot.append(this.input);
    }

    disconnectedCallback() {
      this.stateSubscription.unsubscribe();
    }
  }

  customElements.define("custom-input", CustomInput);

  class CustomApp extends HTMLElement {
    constructor() {
      super();
      this.$context = new BehaviorSubject(new Map());
    }
    connectedCallback() {
      console.log("app");

      this.initSubscription = fromEvent(this, "init")
        .pipe(
          withLatestFrom(this.$context),
          map(([event, context]) => {
            console.log(event, context);
            const newContext = structuredClone(context).set(
              event.detail.requestedValue,
              "Initial Value"
            );
            return newContext;
          }),
          tap((newContext) => event.detail.setObservable(this.$context))
        )
        .subscribe((c) => this.$context.next(c));
    }
    disconnectedCallback() {
      this.initSubscription?.unsubscribe();
    }
  }

  customElements.define("custom-app", CustomApp);

Código HTML para usarlos:

   <custom-app>
      <div>
        <h2>Ejemplo del contexto</h2>
        <custom-div>
          <custom-input name="ejemploInput">
            
          </custom-input>
        </custom-div>
      </div>
    </custom-app>

En este artículo lo explican con detalle sin usar RxJS: https://plainvanillaweb.com/blog/articles/2024-10-07-needs-more-context/

En el ejemplo no es necesario porque el evento es enviado desde fuera del shadow dom, pero si queremos que el evento de dentro se expanda, hay que añadir composed: true y eso hará que salga del shadow dom.

Veamos esta tabla comparativa:

Característica

Inyección de Dependencias (DI)

Servicio Global

Context Provider

Desacoplamiento

Alto. Los componentes dependen de la interfaz del servicio, no de su implementación.

Bajo. Los componentes dependen del contexto global, lo que los acopla a la estructura global de la app.

Medio. Los componentes dependen del contenedor pero no directamente del servicio.

Facilidad de uso en HTML

Baja. La inyección en el constructor impide instanciación directa en HTML. Requiere inyección manual (constructor o setters).

Alta. No hay restricciones para la instanciación en HTML.

Alta. Los componentes pueden instanciarse en HTML dentro del contenedor.

Escalabilidad

Alta. Facilita agregar o cambiar servicios sin modificar componentes.

Baja. El uso de servicios globales tiende a ser más difícil de gestionar en aplicaciones grandes.

Media-Alta. Escalable para componentes anidados, pero puede ser confuso con varios contextos.

Pruebas Unitarias

Alta. Facilita el uso de mocks/stubs en lugar del servicio real, mejorando pruebas unitarias.

Baja. El servicio global está acoplado, lo que dificulta el uso de mocks/stubs en pruebas.

Media. Es fácil sustituir el ContextProvider o inyectar el servicio en pruebas.

Persistencia de Estado

Alta. Cada componente tiene su instancia inyectada, manteniendo el estado donde sea necesario.

Alta. El estado es global, compartido y persistente en toda la aplicación.

Alta. Los datos pueden persistir mientras el contenedor esté activo, ofreciendo un “estado local compartido”.

Flexibilidad para Reemplazar Servicios

Alta. La dependencia puede reemplazarse fácilmente al inyectarla, permitiendo alternar entre implementaciones.

Baja. Reemplazar un servicio global afecta toda la aplicación y necesita cuidado adicional.

Media. El contexto puede ser reemplazado cambiando el proveedor, sin modificar los componentes.

Propenso a Errores en Tiempo de Ejecución

Medio. Requiere asegurar que el servicio esté inyectado antes de usarlo.

Alto. Cualquier cambio en el servicio global afecta toda la aplicación, propenso a efectos colaterales.

Medio. Hay que asegurarse de que los hijos estén dentro del proveedor de contexto, sino no recibirán el servicio.

Mantenimiento

Alto. Buen mantenimiento en aplicaciones grandes, especialmente con servicios independientes y bien definidos.

Bajo. El uso excesivo de servicios globales aumenta el acoplamiento y la dificultad de mantener la estructura.

Medio-Alto. Adecuado para componentes hijos, pero puede ser complejo con muchos contextos anidados.

Rendimiento

Medio. Cada componente que recibe el servicio inyectado tiene su propia referencia, sin problemas significativos de rendimiento.

Alto. El servicio es accesible en todo momento, sin sobrecarga en inyección o propagación.

Medio. Algunos eventos adicionales pueden afectar el rendimiento si hay muchas actualizaciones de contexto.

  • Inyección de Dependencias (DI): Ideal para aplicaciones donde el desacoplamiento es crucial, como en proyectos medianos o grandes con muchos componentes reutilizables. La DI es especialmente útil cuando quieres que los componentes sean fáciles de probar y de mantener con configuraciones dinámicas de servicios.

  • Servicio Global: Útil en aplicaciones pequeñas donde la simplicidad es más importante que la modularidad o el desacoplamiento. Es un buen enfoque si el servicio es fundamental para toda la aplicación (por ejemplo, un usuario autenticado) y se usará de manera uniforme en todos los componentes.

  • Context Provider: Aporta un buen equilibrio entre desacoplamiento y accesibilidad en aplicaciones con jerarquías complejas de componentes. Es adecuado cuando varios componentes deben compartir un estado, pero solo dentro de una sección de la aplicación (por ejemplo, el estado de un tema visual en una subaplicación).

Ejemplo completo#

Presuponemos un proyecto usando Vite, Bootstrap y RxJS.

La idea es crear un formulario reactivo como un Web Component. Para ello vamos a utilizar muchas de las técnicas explicadas, aunque alguna la podríamos omitir. En primer lugar, podemos crear una plantilla para el formulario con <template> y <slot>:

const plantillaFormulario = `
    <div class="container mt-5">
      <h2 class="mb-4">
      <slot name="titulo">Formulario reactivo</slot>
      </h2>
      <form id="formularioReactivo">
        <!-- Campo de texto -->
        <div class="mb-3">
          <label for="nombre" class="form-label">Nombre</label>
          <input
            type="text"
            class="form-control"
            id="nombre"
            placeholder="Ingresa tu nombre"
            required
            name="nombre"
          />
        </div>

        <!-- Campo de correo electrónico -->
        <div class="mb-3">
          <label for="email" class="form-label">Correo Electrónico</label>
          <input
            type="email"
            class="form-control"
            id="email"
            placeholder="Ingresa tu correo electrónico"
            required
            name="correo"
          />
        </div>

        <!-- Botón de enviar -->
        <button type="submit" class="btn btn-primary">Enviar</button>
      </form>
      <div>
        <p><strong>Valor Nombre:</strong> 
        <span id="nombre-output"></span>
        </p>
        <p>
          <strong>Valor Correo Electrónico:</strong>
          <span id="email-output"></span>
        </p>
      </div>
    </div>
`;

En la tercera línea hay <slot name="titulo">Formulario reactivo</slot> que luego será reemplazado. Por lo demás, es un formulario normal.

Para crear el Web Component vamos a usar una función. De esta manera, se puede reutilizar un poco más el código y se aproxima más a la programación funcional:

const createCustomFormComponent = ({ // Object destructuring
  tag,
  template,
  keyUpFunction, 
}) => {
  class customComponent extends HTMLElement {
    constructor() {
      super();
      this.dataSubject = new Subject(); // La manera de obtener datos del padre
    }
    connectedCallback() {
      const templateComponent = document.createElement("template");
      templateComponent.innerHTML = template;
      const shadowRoot = this.attachShadow({ mode: "closed" });

      // Este paso es redundante e innecesario en este caso ya que estamos creando la plantilla dinámicamente
      // Lo dejamos para dejar la referencia a las funciones en un mismo ejemplo.
      const contentFragment = templateComponent.content;
      const content = contentFragment.cloneNode(true);
      shadowRoot.append(content);
    }
  }
  customElements.define(tag, customComponent);
};

En este caso es un componente para crear diversos formularios reactivos. Necesita para ser inicializado el tag, una plantilla del formulario que tenga inputs con el atributo name y una función para indicar qué hacer cuando ocurra un cambio en el formulario. Esta función ya registra el componente.

Al connectedCallback le añadiremos las subscipciones necesarias para implementar la reactividad interna y externa:

connectedCallback() {
        const templateComponent = document.createElement('template');
        templateComponent.innerHTML = template;
        const shadowRoot = this.attachShadow({ mode: "closed" });

        // Este paso es redundante e innecesario en este caso ya que estamos creando la plantilla dinámicamente
        // Lo dejamos para dejar la referencia a las funciones en un mismo ejemplo. 
        const contentFragment = templateComponent.content;
        const content = contentFragment.cloneNode(true);
        shadowRoot.append(content);

        // El evento de keyup tratado de forma reactiva 
        this.keyUpSubscription = fromEvent(shadowRoot, 'keyup')
          .pipe(
            map((event) => new FormData(shadowRoot.querySelector('form'))),
            distinctUntilChanged(
              (previous, current) =>
                [...previous.entries()]
                  .every(
                    ([key, value], index) =>
                      value === [...current.entries()][index][1]
                  )),
            debounceTime(200)
          ).subscribe(values => {
            // Evento para informar al componente padre de un cambio
            const customEvent = new CustomEvent('formChanged', {
              bubbles: true,
              detail: { message: 'Form changed', values }
            });
            this.dispatchEvent(customEvent);
            // Implementar la reactividad en el mismo formulario
            keyUpFunction(shadowRoot)(values);
          });

          // Atender a los datos externos
        this.dataSubscription = this.dataSubject.subscribe(data => {
          [...shadowRoot.querySelectorAll('input')]
          .forEach(input => input.value = data[input.name]);
          shadowRoot.dispatchEvent(new KeyboardEvent('keyup'));
        });
      }

En este caso, nos interesa atender a los eventos keyup que modifiquen el formulario (distinctUntilChanged) con un cierto debounceTime para no saturar con cambios al componente padre. Cuando esto ocurre emitimos un evento personalizado, que es la manera de comunicar los cambios al componente padre. Luego se ejecuta la keyUpFunction tanto con el contenedor como con los últimos valores para que haga lo que se considere necesario. Esta función pasada como parámetros podría validad el formulario o, como en el caso que nos ocupa, mostrar los datos en otro sitio dentro del formulario.

Es importante quitar todas las suscripciones a observables si se elimina el componente:

 disconnectedCallback() {
        this.keyUpSubscription.unsubscribe();
        this.dataSubscription.unsubscribe();
      }

Para crear el componente invocaremos a la función:

  createCustomFormComponent(
    {  // Al pasar un objeto como parámetro, podemos asignar los nombres de los atributos y desordenarlos.
      tag: 'custom-formularioreactivo',
      template: plantillaFormulario,
      keyUpFunction: (shadowRoot) => (values) => {
        [...values].forEach(([key, value]) => {
          shadowRoot.querySelector(`#${key}-output`).innerText = value;
        });
      }
    }
  )

Como se puede ver, la keyUpFunction usará los valores del formulario para cambiar el contenido de ciertos elementos de la plantillaFormulario

Luego lo incorporamos a la web, por ejemplo:

  const customFormulario = document.createElement('custom-formularioreactivo');

  // Crear el span con el slot="titulo"
  const spanTitulo = document.createElement('span');
  spanTitulo.setAttribute('slot', 'titulo');
  spanTitulo.textContent = 'Formulario reactivo con el titulo en slot';

  // Añadir el span al custom element
  customFormulario.appendChild(spanTitulo);

  // Insertar el custom element en el DOM
  document.querySelector("#reactividad").appendChild(customFormulario);

Ahora podemos comprobar que realmente es reactivo. Si modificamos inputs del formulario se ven los cambios. Pero también informa de los cambios al componente padre:

  // Atender al evento 
  document.querySelector("#reactividad").addEventListener('formChanged', event => {
    console.log(event.detail);
  });

Con este código vemos que los cambios internos son devueltos como eventos personalizados.

Y el componente personalizado también se puede actualizar

  interval(1000).pipe(
    map(i=>({nombre: i, email: i}))
  )
  .subscribe(customFormulario.dataSubject)

Este código suscribe el subject interno del componente a un observable externo y esto provoca todos los cambios reactivamente.

Conclusiones#

Como se ve en todo este capítulo, hay muchas opciones para hacer Web Components. En la práctica se vuelve casi imprescindible para proyectos grandes y para reutilizar dentro del mismo proyecto o en otros. Se han mencionado alternativas a todo y ventajas y desventajas, pero no una guía definitiva porque no la hay, depende del tipo de proyecto. Así que vamos a hacer una breve reseña de cada técnica y el tipo de web adecuado:

Para una web estática en HTML que necesite Web Components se puede usar <template>. En cuanto a la comunicación y el estado de la aplicación, como se supone que vienen del servidor no hace falta mucha sofisticación. Tal vez pasar los datos por atributos o dataset. Puede ser útil hacer Web Components con Shadow DOM que se puedan reutilizar en otras webs.

Para una web generada en el servidor con PHP, Python, Java, Node… y “hidratada” con javascript, también se puede usar <template> generados por el servidor. En este caso ya puede ser que necesitemos usar propiedades o observables para lograr la reactividad. También hay que plantearse si gestionar el estado de forma sofisticada.

Para una SPA que consume los datos en formato JSON de una API, sería mejor usar template literals o document.createElement y el resto de funciones para generar dinámicamente los Web Components. Puesto que no es necesario que se pongan en el HTML, lo podemos crear diréctamente en JS y así poder asignarle propiedades más que atributos o data. También hay que mantener muy bien el estado de la aplicación. Para ello o usamos servicios con inyección de dependencias o context providers.

En cuanto a los estilos, ya depende de la reusabilidad de los componentes la decisión de implementar Shadow Dom o depender de estilos globales.

Lecturas: