Web Components en JavaScript: Custom Elements, Shadow DOM y HTML Templates

Los Web Components son un conjunto de APIs nativas del navegador que permiten crear elementos HTML reutilizables y encapsulados sin depender de React, Vue ni ningún otro framework. Se basan en tres pilares: Custom Elements para definir nuevas etiquetas HTML, Shadow DOM para aislar estilos y estructura, y HTML Templates para definir marcado reutilizable con slots.

Custom Elements: definir nuevas etiquetas HTML

Con customElements.define registras una clase que extiende HTMLElement y la asocias a un nombre de etiqueta (que debe contener un guion). El ciclo de vida tiene cuatro callbacks: connectedCallback, disconnectedCallback, attributeChangedCallback y adoptedCallback.

class ContadorElemento extends HTMLElement {
  static observedAttributes = ['valor-inicial'];

  #cuenta = 0;

  connectedCallback() {
    this.#cuenta = parseInt(this.getAttribute('valor-inicial') ?? '0', 10);
    this.#render();
    this.addEventListener('click', this.#incrementar);
  }

  disconnectedCallback() {
    this.removeEventListener('click', this.#incrementar);
  }

  attributeChangedCallback(nombre, antes, despues) {
    if (nombre === 'valor-inicial') {
      this.#cuenta = parseInt(despues, 10);
      this.#render();
    }
  }

  #incrementar = () => {
    this.#cuenta++;
    this.#render();
    this.dispatchEvent(new CustomEvent('cambio', { detail: this.#cuenta, bubbles: true }));
  };

  #render() {
    this.textContent = `Clics: ${this.#cuenta}`;
  }
}

customElements.define('mi-contador', ContadorElemento);
// <mi-contador valor-inicial="5"></mi-contador>

Shadow DOM: encapsulación de estilos

El Shadow DOM crea un árbol DOM interno aislado del documento principal. Los estilos definidos dentro no afectan al exterior y los estilos del documento no entran al Shadow DOM, evitando colisiones de CSS.

class TarjetaProducto extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ddd;
          border-radius: 8px;
          padding: 16px;
          font-family: sans-serif;
        }
        .nombre { font-size: 1.2em; font-weight: bold; color: #333; }
        .precio { color: #e44; font-size: 1.4em; margin-top: 8px; }
        button {
          background: #0066cc;
          color: white;
          border: none;
          padding: 8px 16px;
          border-radius: 4px;
          cursor: pointer;
          margin-top: 12px;
        }
        button:hover { background: #0052a3; }
      </style>
      <div class="nombre">${this.getAttribute('nombre')}</div>
      <div class="precio">${this.getAttribute('precio')}</div>
      <button>Añadir al carrito</button>
    `;

    shadow.querySelector('button').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('anadir-carrito', {
        detail: { nombre: this.getAttribute('nombre') },
        bubbles: true,
        composed: true, // el evento cruza el Shadow DOM
      }));
    });
  }
}

customElements.define('tarjeta-producto', TarjetaProducto);

HTML Templates con slots

El elemento <template> define marcado que no se renderiza hasta que se clona explícitamente. Los <slot> dentro del Shadow DOM son puntos de inserción para el contenido que el usuario pone entre las etiquetas del componente:

// Definir la plantilla en HTML:
// <template id="tarjeta-tpl">
//   <style> ... </style>
//   <div class="cabecera"><slot name="titulo">Sin título</slot></div>
//   <div class="cuerpo"><slot></slot></div>
// </template>

class TarjetaConSlots extends HTMLElement {
  connectedCallback() {
    const tpl = document.getElementById('tarjeta-tpl');
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.appendChild(tpl.content.cloneNode(true));
  }
}
customElements.define('mi-tarjeta', TarjetaConSlots);

// Uso en HTML:
// <mi-tarjeta>
//   <h2 slot="titulo">Título del artículo</h2>
//   <p>Contenido que va al slot por defecto</p>
// </mi-tarjeta>

Componente reactivo con atributos observados

Un botón con tema configurable que reacciona a cambios de atributos, combinando Custom Elements y Shadow DOM:

class BotonTema extends HTMLElement {
  static observedAttributes = ['tema', 'deshabilitado'];

  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.#actualizar();
  }

  attributeChangedCallback() {
    this.#actualizar();
  }

  #actualizar() {
    if (!this.shadowRoot) return;
    const tema = this.getAttribute('tema') || 'primario';
    const colores = { primario: '#0066cc', peligro: '#cc0000', exito: '#007733' };

    this.shadowRoot.innerHTML = `
      <style>
        button {
          background: ${colores[tema] ?? colores.primario};
          color: white; border: none; padding: 10px 20px;
          border-radius: 4px; cursor: pointer;
          opacity: ${this.hasAttribute('deshabilitado') ? 0.5 : 1};
        }
      </style>
      <button ${this.hasAttribute('deshabilitado') ? 'disabled' : ''}>
        <slot>Botón</slot>
      </button>
    `;
  }
}
customElements.define('boton-tema', BotonTema);
// <boton-tema tema="peligro">Eliminar</boton-tema>

Los Web Components son especialmente útiles para equipos que mezclan tecnologías o necesitan componentes que funcionen en proyectos con distintos frameworks. Al ser estándar del navegador, un <mi-tarjeta> funciona igual en React, Vue, Angular o HTML plano.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP