Capítulo 21 — Web Components¶
Introducción¶
Los Web Components son un conjunto de estándares nativos del navegador que permiten crear componentes reutilizables y encapsulados sin necesidad de frameworks.
Existen desde hace años, pero solo recientemente todos los navegadores modernos los soportan completamente. Hoy son una alternativa real a React, Vue o Svelte para ciertos casos.
Los cuatro pilares¶
Los Web Components se construyen sobre cuatro especificaciones:
| Especificación | Qué hace |
|---|---|
| Custom Elements | Definir nuevas etiquetas HTML |
| Shadow DOM | Encapsular estilos y estructura |
| HTML Templates | Definir plantillas reutilizables |
| ES Modules | Cargar los componentes como módulos |
Custom Elements¶
Puedes definir tus propias etiquetas HTML:
class MiBoton extends HTMLElement {
connectedCallback() {
this.innerHTML = '<button>Púlsame</button>';
}
}
customElements.define('mi-boton', MiBoton);
Uso:
<mi-boton></mi-boton>
Reglas:
- El nombre debe contener un guion (
mi-boton, nomiboton). - No puede colisionar con etiquetas HTML estándar.
- Puede extender
HTMLElemento clases más específicas (HTMLButtonElement, etc.).
Ciclo de vida¶
Los Custom Elements tienen varios métodos que se ejecutan en momentos clave:
class MiComponente extends HTMLElement {
constructor() {
super();
// Se ejecuta al crear el elemento
// NO accedas al DOM aquí todavía
}
connectedCallback() {
// Se añade al DOM
// Aquí SÍ puedes manipular
}
disconnectedCallback() {
// Se quita del DOM
// Limpia timers, listeners, etc.
}
attributeChangedCallback(nombre, viejo, nuevo) {
// Un atributo observado cambia
}
static get observedAttributes() {
return ['color', 'tamaño'];
}
}
Shadow DOM¶
El Shadow DOM encapsula la estructura y los estilos. Lo que pasa dentro no afecta al exterior, y viceversa.
class Tarjeta extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.card {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1rem;
font-family: system-ui, sans-serif;
}
</style>
<div class="card">
<slot></slot>
</div>
`;
}
}
customElements.define('mi-tarjeta', Tarjeta);
Uso:
<mi-tarjeta>
<h2>Hola</h2>
<p>Contenido dentro de la tarjeta.</p>
</mi-tarjeta>
Shadow DOM: open vs closed
mode: 'open': puedes acceder conelemento.shadowRootdesde fuera.mode: 'closed':shadowRootesnulldesde fuera. Útil para encapsulación total, pero raro.
Slots¶
<slot> permite inyectar contenido externo dentro del Shadow DOM.
<div class="card">
<header><slot name="titulo"></slot></header>
<main><slot></slot></main>
<footer><slot name="pie"></slot></footer>
</div>
Uso:
<mi-tarjeta>
<h2 slot="titulo">Mi título</h2>
<p>Contenido principal.</p>
<small slot="pie">2026</small>
</mi-tarjeta>
Estilos de slots¶
::slotted(h2) {
color: #6a5acd;
margin: 0;
}
HTML Templates¶
Los <template> contienen HTML que no se renderiza hasta que se clona explícitamente. Perfecto para estructuras complejas.
<template id="tarjeta-template">
<style>
.card {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1rem;
}
</style>
<div class="card">
<h3 class="titulo"></h3>
<p class="contenido"></p>
</div>
</template>
class Tarjeta extends HTMLElement {
constructor() {
super();
const template = document.getElementById('tarjeta-template');
const clone = template.content.cloneNode(true);
this.attachShadow({ mode: 'open' }).appendChild(clone);
}
connectedCallback() {
this.shadowRoot.querySelector('.titulo').textContent =
this.getAttribute('titulo') || 'Sin título';
}
}
Atributos y propiedades¶
Leer atributos¶
connectedCallback() {
this.color = this.getAttribute('color') || '#6a5acd';
}
static get observedAttributes() {
return ['color'];
}
attributeChangedCallback(nombre, viejo, nuevo) {
if (nombre === 'color') this.actualizarColor(nuevo);
}
Propiedades con getters/setters¶
class Contador extends HTMLElement {
#valor = 0;
get valor() { return this.#valor; }
set valor(v) {
this.#valor = v;
this.actualizar();
}
}
Eventos personalizados¶
Los componentes pueden emitir eventos que el exterior escucha.
class MiBoton extends HTMLElement {
connectedCallback() {
this.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('mi-boton-click', {
detail: { id: this.dataset.id },
bubbles: true,
composed: true, // atraviesa el Shadow DOM
}));
});
}
}
Uso:
<mi-boton data-id="42"></mi-boton>
<script>
document.querySelector('mi-boton').addEventListener('mi-boton-click', (e) => {
console.log('Clic en', e.detail.id);
});
</script>
bubbles y composed
bubbles: true→ el evento sube por el DOM.composed: true→ el evento atraviesa el Shadow DOM.
Sin composed, los eventos no salen del shadow root.
Ejemplo completo: contador reutilizable¶
class Contador extends HTMLElement {
#valor = 0;
#step = 1;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: system-ui, sans-serif;
}
button {
background: #6a5acd;
color: white;
border: none;
width: 2rem;
height: 2rem;
border-radius: 50%;
cursor: pointer;
font-size: 1rem;
}
button:hover { background: #5b4bc4; }
.valor {
min-width: 2ch;
text-align: center;
font-weight: 700;
font-size: 1.25rem;
}
</style>
<button id="menos" aria-label="Decrementar">−</button>
<span class="valor">${this.#valor}</span>
<button id="mas" aria-label="Incrementar">+</button>
`;
this.shadowRoot.getElementById('mas')
.addEventListener('click', () => this.#cambiar(this.#step));
this.shadowRoot.getElementById('menos')
.addEventListener('click', () => this.#cambiar(-this.#step));
}
get valor() { return this.#valor; }
set valor(v) {
this.#valor = v;
this.#render();
}
#cambiar(delta) {
this.#valor += delta;
this.#render();
this.dispatchEvent(new CustomEvent('cambio', {
detail: { valor: this.#valor },
bubbles: true,
composed: true,
}));
}
#render() {
this.shadowRoot.querySelector('.valor').textContent = this.#valor;
}
}
customElements.define('mi-contador', Contador);
Uso:
<mi-contador></mi-contador>
<mi-contador></mi-contador>
<script>
document.querySelectorAll('mi-contador').forEach(c => {
c.addEventListener('cambio', (e) => console.log('Nuevo valor:', e.detail.valor));
});
</script>
Cuándo usar Web Components¶
✅ Úsalos cuando¶
- Necesitas componentes reutilizables entre frameworks.
- Diseñas una librería de UI que otros consumirán.
- Quieres encapsulación real sin dependencias externas.
- Construyes un widget embebible en webs de terceros.
❌ Evítalos cuando¶
- Trabajas con un framework que ya tiene su propio sistema de componentes.
- Necesitas SSR (server-side rendering) complejo.
- Requieres reactividad automática (prueba Lit o Stencil).
Librerías recomendadas¶
- Lit — la más popular, minimalista, con reactividad.
- Stencil — compilador que genera Web Components optimizados.
- FAST — de Microsoft.
- Shoelace — librería de componentes ya hecha.
Ejemplo con Lit¶
import { LitElement, html, css } from 'lit';
class MiTarjeta extends LitElement {
static properties = {
titulo: { type: String },
};
static styles = css`
.card {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1rem;
}
`;
render() {
return html`
<div class="card">
<h3>${this.titulo}</h3>
<slot></slot>
</div>
`;
}
}
customElements.define('mi-tarjeta', MiTarjeta);
Buenas prácticas¶
- Prefijo en el nombre para evitar colisiones:
urukais-boton. - Documenta atributos y eventos de cada componente.
- Usa Shadow DOM para encapsular estilos.
- Emite eventos con
composed: truesi quieres que salgan. - Limpia en
disconnectedCallback(timers, observers, listeners). - Usa Lit si necesitas reactividad.
- Publica como módulos ES para que otros puedan importarlos.
- Testea con
@open-wc/testing.
Ejercicios¶
- Crea un Web Component
<mi-saludo nombre="Urukais">que muestre "Hola, Urukais". - Añade un
<slot>y pruébalo con contenido externo. - Implementa un contador con botones y evento
cambio. - Crea una tarjeta con Shadow DOM y estilos encapsulados.
- Añade
observedAttributesy reacciona a cambios de atributos. - Convierte un componente Vue/React en Web Component.
- Publica tus componentes como módulo ES en npm.
⬅️ Capítulo 20 — DevOps para frontend | ➡️ Capítulo 22 — Progressive Web Apps