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
windowydocument, y los tipos de nodo más importantes - Seleccionar elementos con
getElementById,querySelector,querySelectorAll,closestymatches - Conocer la diferencia entre
NodeListyHTMLCollection - Recorrer el árbol: padres, hijos y hermanos
- Leer y modificar contenido con
textContent,innerTexteinnerHTML, conociendo el riesgo de XSS - Manipular atributos, atributos
data-*, clases y estilos - Crear, insertar, mover, reemplazar y eliminar elementos
- Usar
DocumentFragmenty el elementotemplatecomo plantillas - Renderizar listas a partir de arrays de objetos de forma eficiente
- Saber cuándo está listo el DOM (
defer, módulos yDOMContentLoaded)
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,
querySelectoryquerySelectorAllcubren el 95 % de los casos y te obligan a pensar en un único lenguaje (los selectores CSS).getElementByIdsigue siendo perfectamente válido cuando buscas porid.
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:
querySelectordevuelvenullsi no encuentra nada, ynull.textContentlanzaTypeError: 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
NodeListestá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. Prefiereclosest()yquerySelector()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
innerHTMLtexto 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
innerHTMLsolo 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 propiedadinput.valuedevuelve 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:
datasetes perfecto para enlazar elementos con tus datos: guarda eliddel objeto endata-idy, al hacer clic, recupéralo conclosest('[data-id]').dataset.id. Lo usaremos constantemente en los módulos 15 a 17. Y como los atributosdata-*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.classListmodifica 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 adisplay: noney es muy legible. Ojo: si tu CSS da undisplayal elemento, puede anular ahidden.
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:
insertAdjacentHTMLinterpreta HTML, así que tiene el mismo riesgo de XSS queinnerHTML. Ú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,removeChildyreplaceChild. 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
appendacepta varios argumentos, a menudo basta conlista.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:
templatecombina lo mejor de los dos mundos: la estructura se escribe como HTML (legible y fácil de mantener) y los datos se rellenan contextContent(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 elmapdel 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.NumberFormatformatea 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
renderizarconproductos,productos.filter(...)yproductos.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:
- Recalcular estilos: qué reglas CSS se aplican.
- Layout (o reflow): calcular la posición y el tamaño de cada elemento.
- 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:
DOMContentLoadedse dispara cuando el HTML se ha analizado y el árbol DOM está construido (los scriptsdefery los módulos ya se han ejecutado), sin esperar a imágenes y hojas de estilo. El eventoloaddewindowespera 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 necesitasDOMContentLoaded.
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
prioritariade 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 querenderizarmuestre 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 |