tutoriales.com

Desarrollando Componentes Reutilizables con Custom Elements V1 en JavaScript Puro 🚀

Este tutorial te guiará a través del proceso de creación de Custom Elements V1 utilizando JavaScript puro. Descubre cómo definir, registrar y utilizar componentes reutilizables, encapsulados y con su propia lógica y estilo, mejorando la modularidad y el mantenimiento de tus aplicaciones web.

Intermedio20 min de lectura14 views
Reportar error

¡Hola, futuros artesanos web! 👋 ¿Alguna vez has deseado tener tus propios elementos HTML, personalizados para tus necesidades específicas y que funcionen como los elementos nativos del navegador? ¡Con los Custom Elements V1, ese sueño se hace realidad! En este tutorial, nos sumergiremos en el fascinante mundo de los Custom Elements, una de las especificaciones clave de los Web Components, para construir componentes web reutilizables y con una clara separación de preocupaciones. Prepárate para llevar la modularidad de tu código JavaScript y HTML al siguiente nivel.


🎯 ¿Qué son los Custom Elements?

Los Custom Elements son una característica de los navegadores que nos permite definir nuevas etiquetas HTML. Piensa en ellos como la capacidad de crear tus propios <mi-boton-especial>, <mi-galeria-dinamica> o <mi-tarjeta-producto>. Estos elementos personalizados tienen su propia API JavaScript para definir su comportamiento y su propio Shadow DOM opcional para encapsular su estilo y estructura HTML.

📖 Un poco de historia: V0 vs. V1

Originalmente, existió una primera versión de Custom Elements (V0), pero ha sido reemplazada por la versión V1, que es la que se ha estandarizado y la que utilizaremos en este tutorial. La V1 ofrece una API más limpia, una mejor integración con el DOM y un soporte más amplio en navegadores modernos. ¡Estamos usando lo mejor de lo mejor!

📌 Nota: Los Custom Elements forman parte del ecosistema de los Web Components, que también incluye el Shadow DOM, las HTML Templates y los ES Modules. Este tutorial se centrará principalmente en Custom Elements, aunque mencionaremos Shadow DOM.

✅ Ventajas de usar Custom Elements

Utilizar Custom Elements trae consigo una serie de beneficios que mejorarán la forma en que desarrollas tus aplicaciones web:

  • Reusabilidad: Crea componentes una vez y úsalos en cualquier parte de tu aplicación, o incluso en diferentes proyectos.
  • Encapsulación: Los Custom Elements pueden usar Shadow DOM para encapsular su estructura, estilo y comportamiento, evitando conflictos con el resto de la página.
  • Mantenibilidad: Código más organizado y fácil de entender. Cada componente es una unidad autónoma.
  • Interoperabilidad: Funcionan con cualquier framework o librería JavaScript, o sin ninguno. Son estándares web puros.
  • Estándares Web: Al ser una característica nativa del navegador, no dependes de librerías externas para el runtime.

🛠️ Requisitos Previos

Para seguir este tutorial, solo necesitas un navegador moderno (Chrome, Firefox, Edge, Safari) y un editor de texto. ¡Con eso y ganas de aprender, estás listo!

🚀 Primer Custom Element: mi-saludo

Vamos a empezar con un ejemplo simple: un componente que salude al usuario. Lo llamaremos <mi-saludo>.

Paso 1: Definir la clase del Custom Element

Los Custom Elements se definen como clases de JavaScript que extienden HTMLElement. Esta clase base nos proporciona toda la funcionalidad básica de un elemento DOM.

// mi-saludo.js
class MiSaludo extends HTMLElement {
  constructor() {
    super(); // Siempre llama a super() primero en el constructor
    // Aquí podemos inicializar el estado del componente
    console.log('¡Hola desde el constructor de MiSaludo!');
  }
}

Paso 2: Registrar el Custom Element

Una vez que tenemos nuestra clase, necesitamos registrarla con el navegador para que sepa cómo manejar la etiqueta HTML personalizada <mi-saludo>. Esto se hace con customElements.define().

// mi-saludo.js (continuación)

// El primer argumento es el nombre de la etiqueta personalizada (debe contener un guion)
// El segundo argumento es la clase que define el comportamiento del elemento
customElements.define('mi-saludo', MiSaludo);
⚠️ Advertencia: Los nombres de los Custom Elements deben contener al menos un guion (`-`) para evitar colisiones con elementos HTML nativos futuros. Por ejemplo, `saludo` no es válido, pero `mi-saludo` sí lo es.

Paso 3: Usar el Custom Element en HTML

Ahora que nuestro componente está definido y registrado, podemos usarlo como cualquier otra etiqueta HTML.

Crea un archivo index.html:

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mi Primer Custom Element</title>
</head>
<body>
    <h1>Mi primer Custom Element en acción</h1>
    <mi-saludo></mi-saludo>

    <script type="module" src="mi-saludo.js"></script>
</body>
</html>
💡 Consejo: Usar `type="module"` en la etiqueta `script` es una buena práctica para cargar tus archivos JavaScript, ya que permite importar y exportar módulos, lo cual es muy útil para organizar grandes proyectos y también es necesario si trabajas con Shadow DOM y `
Light DOM Shadow DOM Documento HTML Custom Element Contenido Light DOM Documento HTML Custom Element Shadow Root (Encapsulado) Contenido Shadow DOM Barrera de encapsulamiento

🔑 Atributos y Propiedades: Comunicación con Custom Elements

Los Custom Elements pueden recibir datos de dos formas principales: a través de atributos HTML o propiedades JavaScript.

Atributos HTML con observedAttributes y attributeChangedCallback

Podemos hacer que nuestro componente reaccione a cambios en sus atributos HTML. Para ello, necesitamos dos cosas:

  1. Definir un static get observedAttributes() que devuelva un array con los nombres de los atributos que queremos observar.
  2. Implementar el método attributeChangedCallback(name, oldValue, newValue).

Vamos a crear un componente <mi-tarjeta> que muestre un título y una descripción, que serán pasados como atributos.

// mi-tarjeta.js
class MiTarjeta extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._title = 'Título por defecto'; // Estado interno
    this._description = 'Descripción por defecto.'; // Estado interno
  }

  // 1. Definimos qué atributos queremos observar
  static get observedAttributes() {
    return ['titulo', 'descripcion'];
  }

  // 2. Implementamos el callback para reaccionar a los cambios
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return; // Evita actualizaciones innecesarias

    switch (name) {
      case 'titulo':
        this._title = newValue;
        break;
      case 'descripcion':
        this._description = newValue;
        break;
    }
    this._render(); // Volvemos a renderizar el componente con los nuevos datos
  }

  connectedCallback() {
    this._render();
  }

  _render() {
    this.shadowRoot.innerHTML = `
      <style>
        .card {
          border: 1px solid #ddd;
          border-radius: 8px;
          box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
          padding: 16px;
          margin: 16px;
          max-width: 300px;
          font-family: sans-serif;
        }
        .card-title {
          font-size: 1.5em;
          color: #333;
          margin-bottom: 8px;
        }
        .card-description {
          font-size: 0.9em;
          color: #666;
        }
      </style>
      <div class="card">
        <h3 class="card-title">${this._title}</h3>
        <p class="card-description">${this._description}</p>
      </div>
    `;
  }
}

customElements.define('mi-tarjeta', MiTarjeta);

Usa mi-tarjeta.js en tu index.html:

<!-- index.html -->
<body>
    <h1>Custom Element con Atributos</h1>
    <mi-tarjeta titulo="Mi Primer Titulo" descripcion="Esta es la descripción de mi primera tarjeta."></mi-tarjeta>
    <mi-tarjeta titulo="Otra Tarjeta" descripcion="Aquí va una descripción diferente para la segunda tarjeta."></mi-tarjeta>

    <script type="module" src="mi-tarjeta.js"></script>
</body>

Si abres el index.html, verás dos tarjetas, cada una con su título y descripción proporcionados por los atributos.

Propiedades JavaScript: Getters y Setters

Aunque los atributos HTML son excelentes para la configuración inicial, las propiedades JavaScript son más flexibles para datos complejos o para cambiar el estado del componente de forma programática. Podemos usar getters y setters para sincronizar las propiedades JavaScript con el renderizado del componente.

Vamos a añadir una propiedad data a nuestra tarjeta que pueda aceptar un objeto.

// mi-tarjeta-propiedades.js
class MiTarjetaPropiedades extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._data = { title: 'Título Predeterminado', description: 'Descripción Predeterminada.' };
  }

  connectedCallback() {
    this._render();
  }

  // Getter para la propiedad 'data'
  get data() {
    return this._data;
  }

  // Setter para la propiedad 'data'
  set data(value) {
    if (typeof value === 'object' && value !== null) {
      this._data = { ...this._data, ...value }; // Fusiona los nuevos datos
      this._render(); // Vuelve a renderizar cuando la propiedad cambia
    } else {
      console.warn('El valor de la propiedad data debe ser un objeto.');
    }
  }

  _render() {
    this.shadowRoot.innerHTML = `
      <style>
        .card {
          border: 1px solid #ddd;
          border-radius: 8px;
          box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
          padding: 16px;
          margin: 16px;
          max-width: 300px;
          font-family: sans-serif;
          text-align: center;
        }
        .card-title {
          font-size: 1.5em;
          color: #333;
          margin-bottom: 8px;
        }
        .card-description {
          font-size: 0.9em;
          color: #666;
        }
      </style>
      <div class="card">
        <h3 class="card-title">${this._data.title}</h3>
        <p class="card-description">${this._data.description}</p>
        <button id="change-data-btn">Cambiar Datos</button>
      </div>
    `;
    // Adjuntar evento al botón después de renderizar
    this.shadowRoot.getElementById('change-data-btn').addEventListener('click', () => {
        this.data = {
            title: 'Datos Actualizados!',
            description: `Actualizado a las ${new Date().toLocaleTimeString()}`
        };
    });
  }
}

customElements.define('mi-tarjeta-propiedades', MiTarjetaPropiedades);
<!-- index-propiedades.html -->
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Custom Element con Propiedades</title>
</head>
<body>
    <h1>Custom Element con Propiedades JavaScript</h1>
    <mi-tarjeta-propiedades id="miTarjetaUno"></mi-tarjeta-propiedades>

    <script type="module" src="mi-tarjeta-propiedades.js"></script>
    <script type="module">
        // Acceder al componente y establecer sus propiedades desde JavaScript externo
        const miTarjetaUno = document.getElementById('miTarjetaUno');
        miTarjetaUno.data = {
            title: 'Tarjeta desde JS',
            description: 'Esta tarjeta fue inicializada con datos de una propiedad JavaScript.'
        };

        // Puedes cambiar la propiedad más tarde y el componente se actualizará
        setTimeout(() => {
            miTarjetaUno.data = {
                title: 'Cambio Retrasado',
                description: '¡Los datos han cambiado después de 3 segundos!'
            };
        }, 3000);
    </script>
</body>
</html>

En este ejemplo, el botón dentro de la tarjeta actualizará su propia propiedad data, y un script externo también puede modificarla, demostrando la flexibilidad de las propiedades.


📝 Slots: Distribución de Contenido

¿Qué pasa si queremos que nuestro Custom Element sea un contenedor para otro contenido HTML? Ahí es donde entran los <slot>. Los slots son marcadores de posición en el Shadow DOM de un componente que nos permiten proyectar contenido del Light DOM dentro del Shadow DOM.

<slot> por defecto

Un <slot> sin atributo name es el slot por defecto, donde se proyectará todo el contenido que no tenga un slot específico asignado.

// mi-contenedor.js
class MiContenedor extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .container {
          border: 2px dashed #007bff;
          padding: 20px;
          margin: 15px;
          text-align: center;
          font-family: sans-serif;
        }
        h3 { color: #007bff; }
      </style>
      <div class="container">
        <h3>Contenido de mi Custom Element</h3>
        <slot></slot> <!-- Aquí se proyectará el contenido del Light DOM -->
        <p>¡Fin del contenedor!</p>
      </div>
    `;
  }
}

customElements.define('mi-contenedor', MiContenedor);
<!-- index-slots.html -->
<body>
    <h1>Custom Element con Slots</h1>
    <mi-contenedor>
        <p>Este párrafo está en el **Light DOM** pero se proyectará en el **Shadow DOM**.</p>
        <ul>
            <li>Item 1</li>
            <li>Item 2</li>
        </ul>
    </mi-contenedor>

    <script type="module" src="mi-contenedor.js"></script>
</body>

<slot> con nombre

Podemos definir múltiples slots con nombres específicos para una proyección más controlada de contenido.

// mi-panel.js
class MiPanel extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .panel {
          border: 1px solid #ccc;
          border-radius: 5px;
          padding: 10px;
          margin: 10px;
          background-color: #f9f9f9;
        }
        .header {
          font-weight: bold;
          color: #0056b3;
          border-bottom: 1px solid #eee;
          padding-bottom: 5px;
          margin-bottom: 10px;
        }
        .body-content {
          color: #333;
        }
        .footer {
          font-style: italic;
          color: #777;
          margin-top: 10px;
          border-top: 1px solid #eee;
          padding-top: 5px;
        }
      </style>
      <div class="panel">
        <div class="header"><slot name="panel-header">Cabecera por defecto</slot></div>
        <div class="body-content"><slot>Contenido principal por defecto</slot></div>
        <div class="footer"><slot name="panel-footer">Pie de página por defecto</slot></div>
      </div>
    `;
  }
}

customElements.define('mi-panel', MiPanel);
<!-- index-named-slots.html -->
<body>
    <h1>Custom Element con Slots Nombrados</h1>
    <mi-panel>
        <h2 slot="panel-header">Título Personalizado del Panel</h2>
        <p>Este es el contenido principal de mi panel, ¡un texto muy importante!</p>
        <button slot="panel-footer">Acción en el Pie</button>
    </mi-panel>

    <mi-panel>
        <!-- Este panel usará el contenido por defecto para el header y el footer -->
        <p>Solo tiene contenido principal.</p>
    </mi-panel>

    <script type="module" src="mi-panel.js"></script>
</body>

Cuando veas esto en el navegador, notarás cómo los elementos con el atributo slot="nombre-del-slot" son proyectados en su respectivo <slot name="nombre-del-slot"> dentro del Shadow DOM. Los elementos sin un atributo slot son proyectados en el <slot> sin nombre.


💡 Ejercicio Práctico: Un Componente de Contador Sencillo

Vamos a combinar todo lo aprendido para crear un componente de contador simple: <mi-contador>.

// mi-contador.js
class MiContador extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._count = 0; // Estado interno del contador

    // Renderizado inicial
    this.shadowRoot.innerHTML = `
      <style>
        .counter-container {
          display: flex;
          flex-direction: column;
          align-items: center;
          padding: 20px;
          border: 1px solid #007bff;
          border-radius: 8px;
          max-width: 200px;
          margin: 20px auto;
          font-family: Arial, sans-serif;
        }
        .count-display {
          font-size: 3em;
          color: #333;
          margin-bottom: 15px;
        }
        button {
          background-color: #007bff;
          color: white;
          border: none;
          padding: 10px 20px;
          margin: 5px;
          border-radius: 5px;
          cursor: pointer;
          font-size: 1em;
        }
        button:hover {
          background-color: #0056b3;
        }
      </style>
      <div class="counter-container">
        <div class="count-display"></div>
        <div>
          <button id="decrement-btn">-</button>
          <button id="increment-btn">+</button>
        </div>
      </div>
    `;

    // Obtener referencias a los elementos del Shadow DOM
    this._countDisplay = this.shadowRoot.querySelector('.count-display');
    this._decrementBtn = this.shadowRoot.getElementById('decrement-btn');
    this._incrementBtn = this.shadowRoot.getElementById('increment-btn');

    // Asignar eventos
    this._decrementBtn.addEventListener('click', () => this.decrement());
    this._incrementBtn.addEventListener('click', () => this.increment());
  }

  connectedCallback() {
    // Mostrar el valor inicial del contador al conectarse
    this._updateDisplay();
  }

  // Propiedad para obtener y establecer el valor del contador
  get count() {
    return this._count;
  }

  set count(value) {
    this._count = value;
    this._updateDisplay();
    // Opcional: emitir un evento personalizado cuando el contador cambia
    this.dispatchEvent(new CustomEvent('countChange', { detail: { count: this._count } }));
  }

  increment() {
    this.count++;
  }

  decrement() {
    this.count--;
  }

  _updateDisplay() {
    this._countDisplay.textContent = this._count;
  }
}

customElements.define('mi-contador', MiContador);
<!-- index-contador.html -->
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mi Contador Custom Element</title>
</head>
<body>
    <h1>Componente de Contador Interactivo</h1>

    <mi-contador id="contador1"></mi-contador>
    <mi-contador id="contador2"></mi-contador>

    <script type="module" src="mi-contador.js"></script>
    <script type="module">
        const contador1 = document.getElementById('contador1');
        const contador2 = document.getElementById('contador2');

        // Escuchar eventos de cambio del contador
        contador1.addEventListener('countChange', (event) => {
            console.log('Contador 1 cambió a:', event.detail.count);
        });

        // Puedes establecer un valor inicial programáticamente
        contador2.count = 10;
    </script>
</body>
</html>

Este ejemplo demuestra un Custom Element completamente funcional con:

  • Shadow DOM para encapsular estilos y estructura.
  • Manejo de eventos internos (click).
  • Estado interno (_count) y un getter/setter (count) para actualizarlo y reflejar los cambios en la UI.
  • Emisión de eventos personalizados (CustomEvent) para que el componente pueda comunicarse con el resto de la aplicación.

📚 Mejores Prácticas y Consideraciones

  • Nombres de Custom Elements: Siempre usa un guion (-) y minúsculas (ej: mi-componente).
  • Modularización: Usa módulos ES (import/export) para organizar tus componentes en archivos separados. Esto es crucial para proyectos grandes.
  • Performance: Los Custom Elements son nativos y ligeros, pero evita lógica pesada o manipulaciones DOM intensivas en connectedCallback si no es necesario.
  • Accesibilidad: Asegúrate de que tus Custom Elements sean accesibles. Usa atributos ARIA cuando sea apropiado y maneja el foco correctamente.
  • Plantillas (Templates): Para componentes más complejos, es muy común usar la etiqueta <template> en el Shadow DOM. Esto permite predefinir la estructura HTML del componente y clonarla eficientemente. No lo hemos cubierto en profundidad aquí para mantener la simplicidad, pero es una herramienta poderosa.
// Ejemplo con template (no cubierto en detalle en el tutorial)
const template = document.createElement('template');
template.innerHTML = `
<style>...</style>
<div>Mi contenido</div>
`;

class MyTemplateElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
🔥 Importante: Los Custom Elements son una base sólida. Frameworks como Lit o Stencil se construyen sobre ellos, ofreciendo una capa de abstracción y herramientas que facilitan aún más el desarrollo de componentes. Sin embargo, entender Custom Elements puros es fundamental.

🌐 Soporte de Navegadores

El soporte para Custom Elements V1 es excelente en todos los navegadores modernos. No necesitas polyfills para Chrome, Firefox, Safari o Edge. Puedes verificar el estado actual en Can I use... Custom Elements.

98% Soporte Global

🔚 Conclusión

¡Felicidades! Has dado un gran paso en la creación de componentes web reutilizables y encapsulados con Custom Elements V1 y JavaScript puro. Has aprendido a definir clases, registrar elementos, usar los callbacks del ciclo de vida, encapsular con Shadow DOM, manejar atributos y propiedades, y proyectar contenido con slots. Estas herramientas te empoderarán para construir interfaces de usuario más modulares, mantenibles y conformes a los estándares web.

Experimenta, construye tus propios componentes y verás cómo los Custom Elements transforman tu flujo de trabajo de desarrollo frontend. ¡El futuro del desarrollo web está en tus manos! 🚀

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!