Módulo 14: El DOM

Objetivos del módulo

  • Entender qué es el DOM y cómo representa una página como un árbol de nodos
  • Distinguir window y document, y los tipos de nodo más importantes
  • Seleccionar elementos con getElementById, querySelector, querySelectorAll, closest y matches
  • Conocer la diferencia entre NodeList y HTMLCollection
  • Recorrer el árbol: padres, hijos y hermanos
  • Leer y modificar contenido con textContent, innerText e innerHTML, conociendo el riesgo de XSS
  • Manipular atributos, atributos data-*, clases y estilos
  • Crear, insertar, mover, reemplazar y eliminar elementos
  • Usar DocumentFragment y el elemento template como plantillas
  • Renderizar listas a partir de arrays de objetos de forma eficiente
  • Saber cuándo está listo el DOM (defer, módulos y DOMContentLoaded)

1. ¿Qué es el DOM?

Cuando el navegador recibe un fichero HTML, no lo “pinta” tal cual: primero lo analiza y construye en memoria una estructura de objetos que representa la página. Esa estructura es el DOM (Document Object Model, modelo de objetos del documento).

📘 Concepto: El DOM es la representación de la página como un árbol de objetos que JavaScript puede leer y modificar. Cada etiqueta, cada texto y cada comentario del HTML se convierte en un nodo del árbol. Cuando cambias el DOM, el navegador actualiza lo que se ve en pantalla.

Piensa en el HTML como el plano de una casa y en el DOM como la casa construida: puedes mover muebles, pintar paredes o añadir habitaciones sin tocar el plano original. Si recargas la página, el navegador vuelve a construir la casa desde el plano y tus cambios desaparecen.

Este HTML:

<!doctype html>
<html lang="es">
  <head>
    <title>Mi tienda</title>
  </head>
  <body>
    <h1>Ofertas</h1>
    <ul id="productos">
      <li class="producto">Libro</li>
      <li class="producto destacado">Disco</li>
    </ul>
  </body>
</html>

Se convierte en este árbol:

flowchart TD
    D["document"] --> H["html"]
    H --> HE["head"]
    H --> B["body"]
    HE --> T["title"]
    T --> TT["#quot;Mi tienda#quot; (texto)"]
    B --> H1["h1"]
    B --> UL["ul#productos"]
    H1 --> H1T["#quot;Ofertas#quot; (texto)"]
    UL --> LI1["li.producto"]
    UL --> LI2["li.producto.destacado"]
    LI1 --> LI1T["#quot;Libro#quot; (texto)"]
    LI2 --> LI2T["#quot;Disco#quot; (texto)"]

Vocabulario del árbol (como en un árbol genealógico):

Término Significado Ejemplo
Raíz El nodo superior document
Padre (parent) El nodo que contiene a otro ul es padre de los li
Hijo (child) Nodo contenido directamente en otro Cada li es hijo de ul
Hermanos (siblings) Nodos con el mismo padre Los dos li
Descendiente Hijo, nieto, bisnieto… El texto “Libro” desciende de body
Ancestro Padre, abuelo… body es ancestro de los li

💡 Consejo: Abre las DevTools (F12) y mira la pestaña Elementos: lo que ves no es tu HTML, sino el DOM actual. Si JavaScript añade un elemento, aparece ahí aunque no esté en el fichero .html. Si seleccionas un elemento en esa pestaña, en la consola puedes referirte a él como $0.


2. window y document

Objeto Qué representa Ejemplos de uso
window La pestaña del navegador: el objeto global en el navegador window.innerWidth, window.location, setTimeout, alert, localStorage
document La página cargada en esa pestaña: la raíz del DOM document.title, document.body, document.querySelector(...)
console.log(window.innerWidth, window.innerHeight); // tamaño de la ventana en píxeles
console.log(window.location.href); // URL actual

console.log(document.title); // texto del <title>
document.title = 'Nuevo título'; // cambia el título de la pestaña

console.log(document.documentElement); // el elemento <html>
console.log(document.head); // <head>
console.log(document.body); // <body>

Como window es el objeto global, no hace falta escribirlo: document es en realidad window.document, y setTimeout es window.setTimeout.


3. Tipos de nodo

No todo en el DOM son elementos. Los tipos más comunes son:

Tipo nodeType Constante Ejemplo
Elemento 1 Node.ELEMENT_NODE <p>, <div>, <li>
Texto 3 Node.TEXT_NODE El texto dentro de un <p>, incluidos los saltos de línea y espacios entre etiquetas
Comentario 8 Node.COMMENT_NODE <!-- nota -->
Documento 9 Node.DOCUMENT_NODE document
Fragmento 11 Node.DOCUMENT_FRAGMENT_NODE DocumentFragment (sección 12)
const lista = document.querySelector('#productos');

console.log(lista.nodeType); // 1 (elemento)
console.log(lista.nodeName); // "UL" (en mayúsculas para elementos HTML)
console.log(lista.childNodes.length); // 5: texto, li, texto, li, texto
console.log(lista.children.length); // 2: solo los elementos li

⚠️ Importante: Los espacios y saltos de línea con los que indentas el HTML también son nodos de texto. Por eso casi siempre usarás las propiedades que solo devuelven elementos (children, firstElementChild, nextElementSibling…) en lugar de las que devuelven todos los nodos (childNodes, firstChild, nextSibling…).


4. Seleccionar elementos

Antes de modificar algo hay que encontrarlo. Los métodos modernos usan selectores CSS, los mismos que ya conoces de las hojas de estilo.

querySelector y querySelectorAll

// El PRIMER elemento que cumple el selector (o null si no hay ninguno)
const titulo = document.querySelector('h1');
const lista = document.querySelector('#productos');
const destacado = document.querySelector('.producto.destacado');
const enlaceExterno = document.querySelector('a[target="_blank"]');
const primerLi = document.querySelector('#productos > li:first-child');

// TODOS los elementos que cumplen el selector (NodeList, posiblemente vacía)
const productos = document.querySelectorAll('.producto');
console.log(productos.length); // 2

for (const producto of productos) {
  console.log(producto.textContent);
}

Puedes llamarlos sobre cualquier elemento, no solo sobre document, para buscar únicamente dentro de él:

const lista = document.querySelector('#productos');
const itemsDeLaLista = lista.querySelectorAll('li'); // solo los li dentro de #productos

getElementById

Busca por id. Es el más rápido y no necesita la almohadilla:

const lista = document.getElementById('productos'); // sin "#"

💡 Consejo: En la práctica, querySelector y querySelectorAll cubren el 95 % de los casos y te obligan a pensar en un único lenguaje (los selectores CSS). getElementById sigue siendo perfectamente válido cuando buscas por id.

closest: buscar hacia arriba

elemento.closest(selector) sube por los ancestros (empezando por el propio elemento) y devuelve el primero que cumple el selector. Es imprescindible en la delegación de eventos (módulo 15):

// Desde un botón dentro de una tarjeta, encontrar la tarjeta
const boton = document.querySelector('.tarjeta button');
const tarjeta = boton.closest('.tarjeta');
console.log(tarjeta.dataset.id);

matches: ¿cumple este selector?

const elemento = document.querySelector('li');
console.log(elemento.matches('.producto')); // true
console.log(elemento.matches('.destacado')); // false

Métodos antiguos

getElementsByClassName('producto'), getElementsByTagName('li') y getElementsByName('email') siguen existiendo y los verás en código antiguo. Devuelven una HTMLCollection viva, lo que nos lleva a la siguiente sección.

Método Devuelve Si no encuentra ¿Viva?
getElementById(id) Un elemento null —
querySelector(sel) El primer elemento null —
querySelectorAll(sel) NodeList NodeList vacía No (estática)
getElementsByClassName(c) HTMLCollection Colección vacía Sí
getElementsByTagName(t) HTMLCollection Colección vacía Sí
closest(sel) El ancestro más cercano null —

⚠️ Importante: querySelector devuelve null si no encuentra nada, y null.textContent lanza TypeError: Cannot read properties of null. Es el error más común al empezar con el DOM. Causas típicas: un selector mal escrito, o un script que se ejecuta antes de que exista el elemento (sección 15). Usa el encadenamiento opcional si el elemento puede no existir: document.querySelector('#aviso')?.remove();.


5. NodeList frente a HTMLCollection

Ambas parecen arrays (tienen length y se accede por índice), pero no lo son.

Característica NodeList (de querySelectorAll) HTMLCollection (de children, getElementsBy...)
Contiene Nodos (con querySelectorAll, solo elementos) Solo elementos
¿Se actualiza sola? No: es una “foto” del momento Sí: refleja los cambios del DOM al instante
forEach Sí No
for...of Sí Sí
map, filter… No No
const vivos = document.getElementsByClassName('producto'); // HTMLCollection viva
const foto = document.querySelectorAll('.producto'); // NodeList estática

const nuevo = document.createElement('li');
nuevo.className = 'producto';
document.querySelector('#productos').append(nuevo);

console.log(vivos.length); // 3 ← se ha actualizado sola
console.log(foto.length); // 2 ← sigue siendo la foto inicial

Para usar los métodos de array, conviértelas con Array.from o el operador spread:

const nombres = Array.from(document.querySelectorAll('.producto'), (li) => li.textContent);
const destacados = [...document.querySelectorAll('.producto')].filter((li) => li.matches('.destacado'));

⚠️ Importante: Recorrer una colección viva mientras eliminas elementos de ella da resultados extraños (se “salta” elementos, porque los índices cambian). Si vas a eliminar, recorre una NodeList estática o convierte antes la colección en array.


6. Recorrer el árbol

Desde cualquier elemento puedes moverte a sus vecinos:

Propiedad Devuelve Versión con todos los nodos
parentElement El elemento padre parentNode
children HTMLCollection de hijos elemento childNodes
firstElementChild / lastElementChild Primer / último hijo elemento firstChild / lastChild
nextElementSibling / previousElementSibling Hermano elemento siguiente / anterior nextSibling / previousSibling
childElementCount Número de hijos elemento childNodes.length
<ul id="menu">
  <li>Inicio</li>
  <li class="activo">Productos</li>
  <li>Contacto</li>
</ul>
const activo = document.querySelector('#menu .activo');

console.log(activo.parentElement.id); // "menu"
console.log(activo.previousElementSibling.textContent); // "Inicio"
console.log(activo.nextElementSibling.textContent); // "Contacto"
console.log(activo.parentElement.firstElementChild.textContent); // "Inicio"
console.log(activo.parentElement.childElementCount); // 3
console.log(activo.nextElementSibling.nextElementSibling); // null (no hay más)

// Recorrer todos los hijos
for (const item of document.querySelector('#menu').children) {
  console.log(item.textContent);
}

💡 Consejo: Encadenar muchas propiedades de navegación (el.parentElement.parentElement.children[2]...) hace el código frágil: cualquier cambio en el HTML lo rompe. Prefiere closest() y querySelector() con clases o atributos significativos.


7. Leer y modificar el contenido

textContent

Lee o escribe el texto de un elemento y todos sus descendientes. Al escribir, sustituye todo el contenido por un único nodo de texto. Las etiquetas se muestran literalmente, nunca se interpretan como HTML:

const titulo = document.querySelector('h1');
console.log(titulo.textContent); // "Ofertas"

titulo.textContent = 'Ofertas de verano';
titulo.textContent = '<em>Hola</em>'; // se ve literalmente: <em>Hola</em>

innerText

Parecido a textContent, pero tiene en cuenta cómo se ve el texto: ignora elementos ocultos con CSS (display: none) y respeta los saltos de línea visuales. Para calcularlo, el navegador tiene que conocer los estilos, así que es más lento.

innerHTML

Lee o escribe el contenido como HTML: el navegador analiza la cadena y crea los nodos.

const lista = document.querySelector('#productos');
console.log(lista.innerHTML); // '\n  <li class="producto">Libro</li>\n  ...'

lista.innerHTML = '<li class="producto">Libro nuevo</li><li class="producto">Cómic</li>';
Propiedad Lee Escribe Interpreta HTML Rendimiento
textContent Todo el texto, incluido el oculto Texto plano No Rápido
innerText Solo el texto visible Texto plano No Más lento (calcula estilos)
innerHTML El HTML interno como cadena HTML Sí Analiza la cadena; destruye y recrea los hijos

El riesgo de XSS con innerHTML

⚠️ Importante: Nunca metas en innerHTML texto que venga del usuario o de una API sin escaparlo. Es la puerta de entrada al XSS (Cross-Site Scripting): un atacante inyecta HTML con código que se ejecuta en tu página con los permisos de tu usuario (puede leer sus datos, hacer peticiones en su nombre…).

// Imagina que "comentario" viene de un formulario o de la base de datos
const comentario = '<img src="x" onerror="alert(\'Te he robado la sesión\')">';

// ❌ PELIGROSO: el navegador crea la imagen, falla al cargarla y ejecuta onerror
document.querySelector('#comentarios').innerHTML += `<p>${comentario}</p>`;

// ✅ SEGURO: el texto se muestra tal cual, sin interpretarse
const p = document.createElement('p');
p.textContent = comentario;
document.querySelector('#comentarios').append(p);

Reglas prácticas:

  • Para texto, usa siempre textContent.
  • Usa innerHTML solo con plantillas fijas escritas por ti, nunca con datos externos interpolados.
  • Si necesitas mostrar HTML que viene de fuera (por ejemplo, un artículo con formato), sanéalo antes con una librería probada como DOMPurify. La nueva API estándar elemento.setHTML(html) (Sanitizer API) hace este saneamiento de forma nativa; en 2026 ya la incluyen Firefox y Chrome, pero todavía no Safari, así que comprueba su disponibilidad ('setHTML' in Element.prototype) antes de depender de ella.
  • innerHTML += ... además es lento: reconstruye todo el contenido (y pierde los listeners de los hijos) cada vez.

8. Atributos

getAttribute, setAttribute y compañía

<a id="enlace" href="/ayuda" title="Centro de ayuda">Ayuda</a>
<input id="email" type="email" placeholder="tu@correo.es" disabled>
const enlace = document.querySelector('#enlace');

console.log(enlace.getAttribute('href')); // "/ayuda" (tal como está en el HTML)
enlace.setAttribute('target', '_blank');
console.log(enlace.hasAttribute('title')); // true
enlace.removeAttribute('title');

// Atributos booleanos: su mera presencia significa "true"
const email = document.querySelector('#email');
email.toggleAttribute('disabled'); // lo quita si está, lo pone si no está

Atributos frente a propiedades

Para los atributos estándar, los elementos tienen además propiedades equivalentes, más cómodas y con el tipo adecuado:

console.log(enlace.href); // "https://misitio.es/ayuda" (la propiedad da la URL absoluta)
console.log(enlace.getAttribute('href')); // "/ayuda" (el atributo, tal cual)

email.disabled = false; // booleano de verdad
email.placeholder = 'Escribe tu correo';
enlace.id = 'enlace-ayuda';
Atributo HTML Propiedad JavaScript Nota
class className / classList class es palabra reservada
for (en label) htmlFor for es palabra reservada
value (en input) value El atributo es el valor inicial; la propiedad, el actual
checked, disabled checked, disabled Booleanos

⚠️ Importante: En un input, getAttribute('value') devuelve el valor inicial escrito en el HTML, mientras que la propiedad input.value devuelve lo que el usuario ha escrito. Para leer formularios usa siempre las propiedades (módulo 16).

Atributos data-* y dataset

Los atributos que empiezan por data- son tuyos: sirven para guardar información en el HTML sin inventar atributos no válidos. Se leen y escriben con dataset, que convierte los guiones a camelCase:

<article class="tarjeta" data-id="42" data-precio-base="19.99" data-en-oferta="true">
  ...
</article>
const tarjeta = document.querySelector('.tarjeta');

console.log(tarjeta.dataset.id); // "42" ← ¡siempre cadenas!
console.log(Number(tarjeta.dataset.precioBase)); // 19.99 (data-precio-base → precioBase)
console.log(tarjeta.dataset.enOferta === 'true'); // true

tarjeta.dataset.categoria = 'libros'; // crea data-categoria="libros"
delete tarjeta.dataset.enOferta; // elimina data-en-oferta

💡 Consejo: dataset es perfecto para enlazar elementos con tus datos: guarda el id del objeto en data-id y, al hacer clic, recupéralo con closest('[data-id]').dataset.id. Lo usaremos constantemente en los módulos 15 a 17. Y como los atributos data-* también se pueden usar en selectores CSS ([data-estado="activo"]), sirven para dar estilo según el estado.


9. Clases: classList

Cambiar clases es la forma correcta de cambiar el aspecto de un elemento desde JavaScript: el diseño queda en el CSS y JavaScript solo decide qué estado tiene cada elemento.

const tarjeta = document.querySelector('.tarjeta');

tarjeta.classList.add('seleccionada'); // añade (no duplica si ya está)
tarjeta.classList.add('grande', 'sombra'); // varias a la vez
tarjeta.classList.remove('sombra'); // quita
tarjeta.classList.toggle('abierta'); // la pone si no está, la quita si está
tarjeta.classList.toggle('error', hayError); // con segundo argumento: la fuerza según el booleano
console.log(tarjeta.classList.contains('grande')); // true
tarjeta.classList.replace('grande', 'pequena'); // sustituye una por otra

console.log(tarjeta.className); // "tarjeta seleccionada abierta pequena" (cadena completa)
Método Qué hace
add(...clases) Añade una o varias clases
remove(...clases) Quita una o varias clases
toggle(clase, forzar?) Alterna; con forzar (booleano) la pone o la quita según su valor
contains(clase) true si la tiene
replace(vieja, nueva) Sustituye una clase por otra

⚠️ Importante: Evita elemento.className = 'nueva' salvo que quieras sustituir todas las clases: borra las que hubiera. classList modifica solo las que indicas.


10. Estilos

La propiedad style

elemento.style da acceso a los estilos en línea (el atributo style). Las propiedades CSS con guiones se escriben en camelCase:

const aviso = document.querySelector('#aviso');

aviso.style.backgroundColor = '#ffe08a'; // background-color
aviso.style.padding = '1rem'; // ¡con unidades!
aviso.style.display = 'none'; // ocultar
aviso.style.display = ''; // quitar el estilo en línea (vuelve al del CSS)

getComputedStyle: el estilo final

elemento.style solo ve los estilos en línea. Para saber el valor real que aplica el navegador (tras combinar hojas de estilo, herencia, etc.) usa getComputedStyle:

const titulo = document.querySelector('h1');
console.log(titulo.style.fontSize); // "" (no tiene estilo en línea)
console.log(getComputedStyle(titulo).fontSize); // "32px" (siempre en píxeles calculados)

Preferir clases y variables CSS

En lugar de llenar el JavaScript de estilos, define los estados en CSS y cambia clases. Para valores que calcula JavaScript (una posición, un porcentaje, un color elegido por el usuario), usa variables CSS (custom properties):

<style>
  .barra {
    height: 0.5rem;
    background: var(--color-barra, steelblue);
    width: var(--progreso, 0%);
    transition: width 0.3s;
  }
  .oculto {
    display: none;
  }
</style>

<div class="barra" id="barra"></div>
const barra = document.querySelector('#barra');

// JavaScript solo da el dato; el CSS decide cómo se pinta
barra.style.setProperty('--progreso', '75%');
barra.style.setProperty('--color-barra', 'seagreen');

// Leer una variable CSS (getComputedStyle la devuelve como cadena)
console.log(getComputedStyle(barra).getPropertyValue('--progreso')); // "75%"

// Variables globales del tema, definidas en :root
document.documentElement.style.setProperty('--color-principal', '#7b2cbf');

// Mostrar u ocultar con una clase, no con style.display
document.querySelector('#aviso').classList.add('oculto');

💡 Consejo: Para ocultar elementos también existe el atributo HTML hidden (elemento.hidden = true), que equivale a display: none y es muy legible. Ojo: si tu CSS da un display al elemento, puede anular a hidden.


11. Crear, insertar y eliminar elementos

Crear elementos

const tarjeta = document.createElement('article'); // crea el elemento (aún fuera de la página)
tarjeta.className = 'tarjeta';
tarjeta.dataset.id = '7';

const titulo = document.createElement('h2');
titulo.textContent = 'Auriculares';

tarjeta.append(titulo); // titulo dentro de tarjeta
document.querySelector('#lista').append(tarjeta); // ahora sí aparece en la página

📘 Concepto: Un elemento recién creado existe en memoria, pero no se ve hasta que lo insertas en un elemento que ya forma parte del documento.

Métodos de inserción modernos

Dado un elemento de referencia ref:

flowchart TD
    B["ref.before(x) — antes, como hermano"] --> R
    subgraph R["ref"]
        P["ref.prepend(x) — primer hijo"]
        C["... hijos actuales ..."]
        A["ref.append(x) — último hijo"]
    end
    R --> AF["ref.after(x) — después, como hermano"]
const lista = document.querySelector('#productos');
const destacado = lista.querySelector('.destacado');

lista.append(crearItem('Al final'));
lista.prepend(crearItem('Al principio'));
destacado.before(crearItem('Antes del destacado'));
destacado.after(crearItem('Después del destacado'));

// Aceptan varios argumentos y también cadenas (que se insertan como TEXTO, no como HTML)
lista.append(crearItem('Uno'), crearItem('Dos'), 'texto suelto');

function crearItem(texto) {
  const li = document.createElement('li');
  li.textContent = texto;
  return li;
}

Reemplazar, vaciar y eliminar

const viejo = document.querySelector('#aviso');
const nuevo = document.createElement('p');
nuevo.textContent = 'Aviso actualizado';

viejo.replaceWith(nuevo); // sustituye viejo por nuevo
nuevo.remove(); // elimina el elemento del DOM

const lista = document.querySelector('#productos');
lista.replaceChildren(); // vacía todos los hijos
lista.replaceChildren(crearItem('A'), crearItem('B')); // vacía y pone estos

Mover elementos

Un nodo solo puede estar en un sitio. Si insertas un elemento que ya está en la página, se mueve (no se copia):

const primero = lista.firstElementChild;
lista.append(primero); // el primero pasa a ser el último

const copia = primero.cloneNode(true); // true = copia profunda, con todos sus descendientes
lista.append(copia); // ahora sí hay dos

insertAdjacentHTML

Inserta una cadena HTML en una de cuatro posiciones, sin destruir el contenido existente (a diferencia de innerHTML +=):

const lista = document.querySelector('#productos');

lista.insertAdjacentHTML('beforebegin', '<h2>Nuestros productos</h2>'); // antes de la lista
lista.insertAdjacentHTML('afterbegin', '<li>Primero</li>'); // primer hijo
lista.insertAdjacentHTML('beforeend', '<li>Último</li>'); // último hijo
lista.insertAdjacentHTML('afterend', '<p>Fin de la lista</p>'); // después de la lista

Existen también insertAdjacentElement(posicion, elemento) e insertAdjacentText(posicion, texto).

⚠️ Importante: insertAdjacentHTML interpreta HTML, así que tiene el mismo riesgo de XSS que innerHTML. Úsalo solo con plantillas tuyas y datos de confianza.

Método Posición Acepta
append(...nodos) Último hijo Nodos y texto
prepend(...nodos) Primer hijo Nodos y texto
before(...nodos) Hermano anterior Nodos y texto
after(...nodos) Hermano siguiente Nodos y texto
replaceWith(...nodos) En lugar del elemento Nodos y texto
replaceChildren(...nodos) Sustituye todos los hijos Nodos y texto
remove() Elimina el elemento —
insertAdjacentHTML(pos, html) beforebegin, afterbegin, beforeend, afterend Cadena HTML

💡 Consejo: En código antiguo verás appendChild, insertBefore, removeChild y replaceChild. Funcionan, pero los métodos modernos de la tabla son más cómodos (aceptan varios nodos y texto, y no necesitan referencia al padre).


12. DocumentFragment y el elemento template

DocumentFragment

Un fragmento es un contenedor ligero que no forma parte de la página. Puedes llenarlo con muchos elementos y luego insertarlo de una vez: al insertarlo, se “vacía” y solo quedan sus hijos.

const fragmento = document.createDocumentFragment();

for (let i = 1; i <= 100; i++) {
  const li = document.createElement('li');
  li.textContent = `Elemento ${i}`;
  fragmento.append(li); // en memoria: no toca la página
}

document.querySelector('#lista').append(fragmento); // una sola inserción

💡 Consejo: Como append acepta varios argumentos, a menudo basta con lista.append(...elementos) a partir de un array, sin necesidad de fragmento. El fragmento sigue siendo útil cuando construyes estructuras en varios pasos.

El elemento template

Escribir estructuras grandes con createElement es tedioso. El elemento template permite escribir la estructura en el HTML: su contenido no se muestra ni se ejecuta, solo sirve de molde para clonarlo.

<template id="plantilla-producto">
  <article class="tarjeta">
    <img class="tarjeta-imagen" alt="">
    <h2 class="tarjeta-nombre"></h2>
    <p class="tarjeta-precio"></p>
    <button type="button" class="tarjeta-boton">Añadir al carrito</button>
  </article>
</template>

<section id="catalogo"></section>
const plantilla = document.querySelector('#plantilla-producto');

function crearTarjeta(producto) {
  // .content es un DocumentFragment; lo clonamos en profundidad
  const tarjeta = plantilla.content.firstElementChild.cloneNode(true);

  tarjeta.dataset.id = producto.id;
  tarjeta.querySelector('.tarjeta-imagen').src = producto.imagen;
  tarjeta.querySelector('.tarjeta-imagen').alt = producto.nombre;
  tarjeta.querySelector('.tarjeta-nombre').textContent = producto.nombre; // seguro frente a XSS
  tarjeta.querySelector('.tarjeta-precio').textContent = `${producto.precio.toFixed(2)} €`;
  return tarjeta;
}

document.querySelector('#catalogo').append(
  crearTarjeta({ id: 1, nombre: 'Teclado', precio: 49.9, imagen: 'img/teclado.webp' }),
);

💡 Consejo: template combina lo mejor de los dos mundos: la estructura se escribe como HTML (legible y fácil de mantener) y los datos se rellenan con textContent (seguro).


13. Renderizar listas desde datos

En las aplicaciones reales los datos viven en arrays de objetos (que normalmente llegan de una API) y la interfaz se genera a partir de ellos. El patrón es siempre el mismo:

📘 Concepto: Datos → función de renderizado → DOM. Cuando los datos cambian, vuelves a llamar a la función de renderizado. Así hay una única fuente de verdad (el array) y la página siempre refleja su estado. Es la idea en la que se basan React, Vue o Svelte.

<!doctype html>
<html lang="es">
  <head>
    <meta charset="utf-8">
    <title>Catálogo</title>
    <style>
      #catalogo { display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: 1rem; }
      .tarjeta { border: 1px solid #ddd; border-radius: 0.5rem; padding: 1rem; }
      .tarjeta.agotado { opacity: 0.5; }
      .etiqueta { font-size: 0.8rem; background: #ffe08a; padding: 0.1rem 0.4rem; border-radius: 0.25rem; }
    </style>
    <script type="module" src="catalogo.js"></script>
  </head>
  <body>
    <h1>Catálogo</h1>
    <p id="resumen"></p>
    <section id="catalogo"></section>
  </body>
</html>
// catalogo.js
const productos = [
  { id: 1, nombre: 'Teclado mecánico', precio: 89.9, stock: 12, categoria: 'periféricos' },
  { id: 2, nombre: 'Ratón inalámbrico', precio: 29.5, stock: 0, categoria: 'periféricos' },
  { id: 3, nombre: 'Monitor 27"', precio: 249, stock: 3, categoria: 'pantallas' },
  { id: 4, nombre: 'Webcam HD', precio: 45, stock: 7, categoria: 'vídeo' },
];

const formatoEuros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });

function crearTarjeta(producto) {
  const tarjeta = document.createElement('article');
  tarjeta.className = 'tarjeta';
  tarjeta.classList.toggle('agotado', producto.stock === 0);
  tarjeta.dataset.id = producto.id;

  const nombre = document.createElement('h2');
  nombre.textContent = producto.nombre;

  const precio = document.createElement('p');
  precio.textContent = formatoEuros.format(producto.precio);

  const etiqueta = document.createElement('span');
  etiqueta.className = 'etiqueta';
  etiqueta.textContent = producto.stock === 0 ? 'Agotado' : `${producto.stock} en stock`;

  tarjeta.append(nombre, precio, etiqueta);
  return tarjeta;
}

function renderizar(lista) {
  document.querySelector('#catalogo').replaceChildren(...lista.map(crearTarjeta));

  const disponibles = lista.filter((p) => p.stock > 0).length;
  document.querySelector('#resumen').textContent = `${lista.length} productos, ${disponibles} disponibles`;
}

renderizar(productos);

Fíjate en los detalles:

  • lista.map(crearTarjeta) transforma cada objeto en un elemento: es el map del módulo 06 aplicado a la interfaz.
  • replaceChildren(...) vacía y rellena en una sola operación: renderizar dos veces no duplica las tarjetas.
  • Intl.NumberFormat formatea los precios según el idioma (lo verás a fondo en el módulo 19).
  • Todo el texto entra con textContent: aunque un producto se llamara <script>, se mostraría como texto.

✏️ Ejercicio: Añade a la página tres botones (“Todos”, “Disponibles”, “Ordenar por precio”) que llamen a renderizar con productos, productos.filter(...) y productos.toSorted(...). Fíjate en que nunca tocas el DOM a mano: solo cambias qué datos pasas.


14. Rendimiento: reflow y agrupar cambios

Cada vez que cambias el DOM, el navegador puede tener que:

  1. Recalcular estilos: qué reglas CSS se aplican.
  2. Layout (o reflow): calcular la posición y el tamaño de cada elemento.
  3. Paint: pintar los píxeles.

El layout es caro, sobre todo en páginas grandes. El navegador es listo y agrupa los cambios para hacerlos una sola vez antes de pintar… salvo que tú le obligues a calcular antes, leyendo una medida.

const cajas = document.querySelectorAll('.caja');

// ❌ "Layout thrashing": leer y escribir alternando fuerza un layout en cada vuelta
for (const caja of cajas) {
  const ancho = caja.offsetWidth; // LEER: obliga a calcular el layout ya
  caja.style.height = `${ancho / 2}px`; // ESCRIBIR: invalida el layout
}

// ✅ Primero todas las lecturas, después todas las escrituras
const anchos = Array.from(cajas, (caja) => caja.offsetWidth);
cajas.forEach((caja, i) => {
  caja.style.height = `${anchos[i] / 2}px`;
});

Propiedades y métodos que obligan a calcular el layout si hay cambios pendientes: offsetWidth/offsetHeight, clientWidth, scrollTop, getBoundingClientRect(), getComputedStyle()…

Buenas prácticas:

Práctica Por qué
Construir fuera y insertar una vez (append(...lista), fragmento, replaceChildren) Un solo recálculo en lugar de cientos
Cambiar clases en vez de muchas propiedades de style Un cambio en lugar de varios, y el CSS queda en el CSS
Separar lecturas de escrituras Evita el layout thrashing
Guardar en variables los elementos que usas a menudo querySelector es rápido, pero no gratis
Para animaciones, usar CSS (transition, animation) o requestAnimationFrame Se sincronizan con el refresco de la pantalla
No usar innerHTML += en bucles Reconstruye todo el contenido en cada vuelta

💡 Consejo: No optimices a ciegas. La pestaña Rendimiento (Performance) de las DevTools graba lo que hace el navegador y marca en morado los layouts forzados. Con listas de decenas o pocos cientos de elementos, el enfoque sencillo de “renderizar todo otra vez” suele ser más que suficiente.


15. ¿Cuándo está listo el DOM?

Un script clásico en el head se ejecuta antes de que el navegador haya leído el body: si intenta seleccionar un elemento, obtendrá null.

<head>
  <script>
    // ❌ Todavía no existe #app: el navegador no ha llegado al body
    console.log(document.querySelector('#app')); // null
  </script>
</head>
<body>
  <div id="app"></div>
</body>

Las soluciones, de más a menos recomendada:

Opción Cómo Cuándo se ejecuta
Módulo <script type="module" src="app.js"></script> en el head Tras analizar el HTML (diferido por defecto)
defer <script defer src="app.js"></script> en el head Tras analizar el HTML, en orden
DOMContentLoaded document.addEventListener('DOMContentLoaded', iniciar) Cuando el HTML está analizado
Script al final del body <script src="app.js"></script> antes de cerrar body Cuando el navegador llega a él
// Solo necesario en scripts clásicos sin defer
document.addEventListener('DOMContentLoaded', () => {
  console.log(document.querySelector('#app')); // ✅ ya existe
});

📘 Concepto: DOMContentLoaded se dispara cuando el HTML se ha analizado y el árbol DOM está construido (los scripts defer y los módulos ya se han ejecutado), sin esperar a imágenes y hojas de estilo. El evento load de window espera a que todo (imágenes incluidas) haya terminado de cargar; úsalo solo si necesitas medidas de imágenes.

💡 Consejo: En este curso usamos siempre <script type="module">: es diferido por defecto, así que no necesitas DOMContentLoaded.


16. Ejemplo completo: lista de tareas renderizada desde un array

Juntamos todo en una lista de tareas. Los datos viven en un array; una función renderizar() genera la interfaz a partir de él con un template. Para reaccionar a los clics usamos addEventListener, que verás en detalle en el módulo 15 (aquí con lo mínimo imprescindible).

<!doctype html>
<html lang="es">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Mis tareas</title>
    <style>
      :root { --color-principal: #2b6cb0; --color-hecha: #888; }
      body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 2rem auto; padding: 0 1rem; }
      form { display: flex; gap: 0.5rem; }
      input[type="text"] { flex: 1; padding: 0.5rem; }
      button { cursor: pointer; }
      #tareas { list-style: none; padding: 0; }
      .tarea { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-bottom: 1px solid #eee; }
      .tarea-texto { flex: 1; }
      .tarea.hecha .tarea-texto { text-decoration: line-through; color: var(--color-hecha); }
      .tarea.prioritaria { border-left: 4px solid var(--color-principal); }
      .barra { height: 0.4rem; background: #eee; border-radius: 1rem; overflow: hidden; }
      .barra-relleno { height: 100%; width: var(--progreso, 0%); background: var(--color-principal); transition: width 0.3s; }
    </style>
    <script type="module" src="tareas.js"></script>
  </head>
  <body>
    <h1>Mis tareas</h1>

    <form id="formulario">
      <input type="text" id="nueva" placeholder="¿Qué tienes que hacer?" required>
      <button type="submit">Añadir</button>
    </form>

    <p id="resumen"></p>
    <div class="barra"><div class="barra-relleno" id="progreso"></div></div>

    <ul id="tareas"></ul>
    <p id="vacia" hidden>No hay tareas. ¡Disfruta del día!</p>

    <template id="plantilla-tarea">
      <li class="tarea">
        <input type="checkbox" class="tarea-check">
        <span class="tarea-texto"></span>
        <button type="button" class="tarea-borrar" aria-label="Borrar tarea">🗑</button>
      </li>
    </template>
  </body>
</html>
// tareas.js
const tareas = [
  { id: 1, texto: 'Repasar el módulo de asincronía', hecha: true, prioritaria: false },
  { id: 2, texto: 'Practicar querySelector', hecha: false, prioritaria: true },
  { id: 3, texto: 'Leer sobre XSS', hecha: false, prioritaria: false },
];
let siguienteId = 4;

// Referencias a elementos que usamos a menudo
const lista = document.querySelector('#tareas');
const plantilla = document.querySelector('#plantilla-tarea');
const resumen = document.querySelector('#resumen');
const progreso = document.querySelector('#progreso');
const mensajeVacia = document.querySelector('#vacia');
const formulario = document.querySelector('#formulario');
const campoNueva = document.querySelector('#nueva');

function crearElementoTarea(tarea) {
  const li = plantilla.content.firstElementChild.cloneNode(true);
  li.dataset.id = tarea.id;
  li.classList.toggle('hecha', tarea.hecha);
  li.classList.toggle('prioritaria', tarea.prioritaria);

  const check = li.querySelector('.tarea-check');
  check.checked = tarea.hecha;
  check.setAttribute('aria-label', `Marcar "${tarea.texto}" como hecha`);

  li.querySelector('.tarea-texto').textContent = tarea.texto; // textContent: seguro
  return li;
}

function renderizar() {
  // Pendientes primero; dentro de cada grupo, las prioritarias arriba
  const ordenadas = tareas.toSorted((a, b) => a.hecha - b.hecha || b.prioritaria - a.prioritaria);
  lista.replaceChildren(...ordenadas.map(crearElementoTarea));

  const hechas = tareas.filter((t) => t.hecha).length;
  resumen.textContent = `${hechas} de ${tareas.length} tareas completadas`;
  progreso.style.setProperty('--progreso', `${tareas.length ? (hechas / tareas.length) * 100 : 0}%`);
  mensajeVacia.hidden = tareas.length > 0;
}

// --- Acciones: modifican los DATOS y vuelven a renderizar ---
function anadirTarea(texto) {
  tareas.push({ id: siguienteId++, texto, hecha: false, prioritaria: false });
  renderizar();
}

function alternarTarea(id) {
  const tarea = tareas.find((t) => t.id === id);
  tarea.hecha = !tarea.hecha;
  renderizar();
}

function borrarTarea(id) {
  const indice = tareas.findIndex((t) => t.id === id);
  tareas.splice(indice, 1);
  renderizar();
}

// --- Eventos (módulo 15) ---
formulario.addEventListener('submit', (evento) => {
  evento.preventDefault(); // que el formulario no recargue la página
  const texto = campoNueva.value.trim();
  if (texto) anadirTarea(texto);
  formulario.reset();
  campoNueva.focus();
});

// Un único listener en la lista para todas las tareas (delegación de eventos)
lista.addEventListener('click', (evento) => {
  const li = evento.target.closest('.tarea');
  if (!li) return;
  const id = Number(li.dataset.id); // dataset siempre devuelve cadenas

  if (evento.target.matches('.tarea-check')) alternarTarea(id);
  if (evento.target.closest('.tarea-borrar')) borrarTarea(id);
});

renderizar();

Lo importante del diseño:

Pieza Qué hace Conceptos
tareas Única fuente de verdad Array de objetos
crearElementoTarea Convierte un objeto en un li template, cloneNode, dataset, classList.toggle, textContent
renderizar Pinta toda la lista desde los datos toSorted, map, replaceChildren, variables CSS, hidden
Acciones Cambian los datos y llaman a renderizar find, findIndex, splice
Listener de la lista Averigua qué tarea se pulsó closest, matches, Number(dataset.id)

✏️ Ejercicio: Añade un botón ⭐ a la plantilla que alterne la propiedad prioritaria de la tarea. Después añade un párrafo con tres botones de filtro (“Todas”, “Pendientes”, “Hechas”) que guarden el filtro actual en una variable y hagan que renderizar muestre solo las tareas correspondientes.


17. Ejercicios

Ejercicio 1: Explorador del DOM

Dada una página con un nav que contiene una lista de cinco enlaces (uno con la clase activo), escribe código que muestre en consola: el texto del enlace activo, el texto de sus hermanos anterior y siguiente, cuántos enlaces hay, y el href absoluto y el literal de cada enlace (propiedad frente a getAttribute).

Ejercicio 2: Contador de palabras seguro

Crea un textarea y un párrafo. Escribe una función actualizar() que muestre en el párrafo el texto escrito en mayúsculas y el número de palabras. Pruébalo escribiendo <b>hola</b> y comprueba que se muestra literalmente. ¿Qué pasaría si usaras innerHTML? (Para llamar a actualizar al escribir, usa textarea.addEventListener('input', actualizar)).

Ejercicio 3: Tabla desde datos

Con un array de cinco alumnos (nombre, nota1, nota2), genera una tabla con columnas Nombre, Nota 1, Nota 2 y Media. Añade la clase suspenso a las filas con media menor que 5 y la clase matricula a las de media 10. Usa createElement (o un template) y construye la tabla con una sola inserción en el DOM.

Ejercicio 4: Tarjetas con template

Usa un elemento template para renderizar seis tarjetas de películas (titulo, anio, genero, puntuacion). Guarda el id en data-id, muestra la puntuación como estrellas ('★'.repeat(n)) y usa una variable CSS --color-genero distinta según el género, aplicada con style.setProperty.

Ejercicio 5: Modo oscuro con variables CSS

Define en :root las variables --fondo y --texto, y una clase .oscuro en html que las cambie. Crea un botón que alterne esa clase con classList.toggle y que cambie su propio texto entre “🌙 Modo oscuro” y “☀️ Modo claro” según classList.contains.

Ejercicio 6: Mover y clonar

Crea dos listas (#pendientes y #hechas) con varios elementos. Escribe moverPrimero() que mueva el primer elemento de #pendientes al final de #hechas, y duplicarUltimo() que clone el último de #hechas y lo añada a #pendientes con el texto “(copia)” al final. Comprueba en la pestaña Elementos que mover no deja copia.

Ejercicio 7: Medir el rendimiento

Crea 5000 elementos li de tres formas: (a) innerHTML += dentro del bucle, (b) append de cada li directamente en la lista, (c) construir un array y hacer un único replaceChildren(...array). Mide cada una con performance.now() y anota los resultados. ¿Cuál es la más lenta y por qué?


Resumen

Concepto Sintaxis / clave
DOM Árbol de nodos que representa la página; cambiarlo cambia lo que se ve
window / document La pestaña (objeto global) / la página (raíz del DOM)
Tipos de nodo Elemento (1), texto (3), comentario (8), documento (9), fragmento (11)
Seleccionar querySelector(sel), querySelectorAll(sel), getElementById(id)
Buscar hacia arriba / comprobar el.closest(sel), el.matches(sel)
Colecciones NodeList estática (con forEach) frente a HTMLCollection viva
Recorrer parentElement, children, first/lastElementChild, next/previousElementSibling
Contenido textContent (texto, seguro), innerText (texto visible), innerHTML (HTML, riesgo XSS)
Atributos getAttribute, setAttribute, hasAttribute, removeAttribute, toggleAttribute
Datos propios data-mi-dato ↔ el.dataset.miDato (siempre cadenas)
Clases classList.add/remove/toggle/contains/replace
Estilos el.style.prop, getComputedStyle(el), style.setProperty('--var', valor)
Crear document.createElement('etiqueta'), el.cloneNode(true)
Insertar append, prepend, before, after, insertAdjacentHTML
Sustituir / eliminar replaceWith, replaceChildren, remove
Plantillas DocumentFragment, template + content.cloneNode(true)
Renderizar Datos → map(crearElemento) → replaceChildren(...)
Rendimiento Insertar una vez, cambiar clases, separar lecturas y escrituras
DOM listo type="module" o defer; DOMContentLoaded en scripts clásicos