La lección anterior terminó con un index.html semántico y con la sensación de que llegar a un elemento saltando de firstElementChild en nextElementSibling es incómodo y frágil. Lo es. La forma real de trabajar con el DOM consiste en describir lo que buscas con un selector CSS y dejar que el navegador lo encuentre por ti, y después cambiarlo: su texto, sus atributos, sus clases. En esta lección aprenderás las dos mitades de ese trabajo, junto con las decisiones de criterio que separan el código profesional del que funciona por casualidad: por qué textContent es la opción por defecto y innerHTML un riesgo de seguridad, por qué un atributo y una propiedad con el mismo nombre pueden no valer lo mismo, por qué cambiar una clase es siempre mejor que tocar style, y por qué leer la anchura de un elemento puede ser sorprendentemente caro. Al final, las tareas de tu página mostrarán el estado y la prioridad reales que salen del Tablero.

Contenido

  1. Los selectores CSS como idioma común
  2. Los métodos clásicos: getElementById y compañía
  3. querySelector y querySelectorAll
  4. Tabla comparativa de los métodos de selección
  5. Buscar dentro de un elemento: el ámbito de la búsqueda
  6. closest() y matches()
  7. De NodeList a array
  8. textContent, innerText e innerHTML
  9. Atributos frente a propiedades
  10. dataset y los atributos data-*
  11. Clases con classList
  12. Estilos en línea con style (y por qué casi nunca)
  13. Leer geometría: getBoundingClientRect y offsetWidth
  14. Nómada Tareas: pintar estado y prioridad
  15. Errores Comunes y Consejos
  16. Ejercicios
  17. Conclusión

  1. Los selectores CSS como idioma común

Un selector CSS es una expresión que describe un conjunto de elementos. Los conoces del CSS que escribiste en la lección anterior; lo importante ahora es que el mismo idioma sirve para buscar desde JavaScript. Un repaso rápido de los que más vas a usar:

Selector Selecciona Ejemplo en Nómada Tareas
p Todos los elementos de esa etiqueta li
.clase Los que tengan esa clase .tarea--alta
#id El que tenga ese id #lista-tareas
[attr] Los que tengan ese atributo [data-id]
[attr="valor"] Los que tengan ese atributo con ese valor exacto [data-id="6"]
[attr^="v"] Cuyo atributo empiece por [href^="https"]
a, b Los que cumplan a o b .tarea--alta, .tarea--vencida
a b Los b que sean descendientes de a .tablero li
a > b Los b que sean hijos directos de a #lista-tareas > li
a + b El b inmediatamente después de un a h2 + p
a:first-child El que sea primer hijo li:first-child
a:not(sel) Los a que no cumplan el selector li:not(.tarea--hecha)
a:has(b) Los a que contengan un b li:has(button[disabled])

Que los selectores se compongan es lo que los hace potentes. #lista-tareas > li.tarea--alta:not(.tarea--hecha) describe, en una línea, «los hijos directos de la lista que sean tareas de prioridad alta y no estén hechas». Escribir eso navegando el árbol a mano serían diez líneas.

  1. Los métodos clásicos: getElementById y compañía

Antes de que existieran los selectores en JavaScript había tres métodos, y todavía se usan:

// Por id. Devuelve el elemento o null. Solo existe en 'document'.
const lista = document.getElementById('lista-tareas');   // sin '#'

// Por clase. Devuelve una HTMLCollection VIVA (puede estar vacía).
const altas = document.getElementsByClassName('tarea--alta');   // sin '.'

// Por etiqueta. También HTMLCollection viva.
const items = document.getElementsByTagName('li');

Tres detalles que hay que tener presentes:

  • getElementById no lleva # y los otros dos no llevan .: reciben nombres, no selectores. Confundirlo es un error clásico que devuelve null sin explicación.
  • Los dos getElements… devuelven colecciones vivas, con todo lo que eso implica (lección 06-01, apartado 6): si añades un <li> con la clase, aparece solo en la colección; si recorres con un for clásico y a la vez añades elementos, entras en un bucle infinito.
  • Si no hay coincidencias, getElementById devuelve null y los otros devuelven una colección vacía, no null. Son dos formas distintas de decir «nada», y confundirlas provoca errores distintos.

getElementById sigue siendo perfectamente válido y es el más rápido de todos, porque el navegador mantiene un índice interno de ids. Los otros dos han quedado prácticamente desplazados por querySelectorAll.

  1. querySelector y querySelectorAll

Estos dos métodos aceptan cualquier selector CSS y son los que usarás el 90 % del tiempo.

// El PRIMER elemento que coincide, o null si no hay ninguno.
const lista = document.querySelector('#lista-tareas');
const primeraAlta = document.querySelector('.tarea--alta');
const carpinteria = document.querySelector('[data-id="6"]');

// TODOS los que coinciden, como NodeList ESTÁTICA (posiblemente vacía).
const tareas = document.querySelectorAll('#lista-tareas > li');
console.log(tareas.length);   // 3

Cuatro cosas que conviene interiorizar:

Devuelven null o una lista vacía, y hay que contar con ello. Este es el TypeError más frecuente de todo el módulo:

const boton = document.querySelector('#boton-que-no-existe');
console.log(boton);          // null
// boton.textContent = 'Hola';
// ✗ TypeError: Cannot set properties of null (setting 'textContent')

La guarda correcta usa lo que ya sabes de 01-06 y del Módulo 2:

const boton = document.querySelector('#guardar');
if (boton === null) {
  console.warn('No se ha encontrado el botón de guardar.');
} else {
  boton.disabled = true;
}

// O con encadenamiento opcional, cuando la ausencia es aceptable:
document.querySelector('#guardar')?.setAttribute('disabled', '');

querySelectorAll devuelve una NodeList estática. Es una foto del momento: si después añades tareas al DOM, esa lista no crece. En la práctica es una ventaja, porque elimina las sorpresas de las colecciones vivas.

La NodeList sí tiene forEach, aunque siga sin tener map ni filter:

document.querySelectorAll('.tarea').forEach((li) => {
  console.log(li.dataset.id);
});

El selector debe ser válido, o el método lanza una excepción en vez de devolver null:

// document.querySelector('##mal');
// ✗ SyntaxError: Failed to execute 'querySelector': '##mal' is not a valid selector.

Ojo con un caso concreto: los ids que empiezan por un número son válidos en HTML pero rompen el selector (#1 no es un selector legal). Si generas ids desde datos, prefija con letra (tarea-1) o usa [data-id="1"], que no tiene ese problema. Y para casos generales existe CSS.escape(valor).

  1. Tabla comparativa de los métodos de selección

Método Recibe Devuelve ¿Vivo? ¿Está en elementos? Cuándo usarlo
getElementById(id) Un id sin # Elemento o null No, solo en document El más rápido; perfecto para el contenedor raíz
getElementsByClassName(c) Un nombre de clase HTMLCollection Casi nunca; heredado
getElementsByTagName(t) Un nombre de etiqueta HTMLCollection Casi nunca; heredado
querySelector(sel) Selector CSS Elemento o null Opción por defecto para un elemento
querySelectorAll(sel) Selector CSS NodeList estática No Opción por defecto para varios

Sobre el rendimiento: getElementById es más rápido que querySelector('#id') porque no tiene que analizar el selector. La diferencia es de nanosegundos y carece de importancia salvo dentro de un bucle que se ejecute miles de veces. La regla sensata es usar querySelector/querySelectorAll por coherencia, guardar el resultado en una constante y no volver a buscar lo mismo dos veces. En 09-04 verás cómo medir esto de verdad; hasta entonces, prioriza la legibilidad.

  1. Buscar dentro de un elemento: el ámbito de la búsqueda

querySelector y querySelectorAll no son exclusivos de document: cualquier elemento los tiene, y entonces buscan solo entre sus descendientes. Esto es fundamental para escribir componentes que no interfieran entre sí.

const lista = document.querySelector('#lista-tareas');
const li = lista.querySelector('[data-id="2"]');

// Busca SOLO dentro de ese <li>:
const titulo = li.querySelector('.tarea__titulo');
console.log(titulo.textContent);   // 'Cartelería del taller de serigrafía'

Comparar los dos ámbitos deja clara la diferencia:

console.log(document.querySelectorAll('.tarea__titulo').length); // 3 · toda la página
console.log(li.querySelectorAll('.tarea__titulo').length);       // 1 · solo ese <li>

Un detalle que confunde a mucha gente: el selector se evalúa contra todo el documento, y luego se filtra por «que sea descendiente del elemento». Por eso esto devuelve resultados inesperados:

// Intuición: "los li hijos directos de la lista". Realidad: funciona, pero...
lista.querySelectorAll('#lista-tareas > li');   // ✓ 3 elementos
lista.querySelectorAll('> li');                 // ✗ SyntaxError: no es un selector válido

Un selector no puede empezar por un combinador. Si quieres solo los hijos directos desde el propio elemento, usa :scope:

lista.querySelectorAll(':scope > li');   // ✓ 3 elementos, sin repetir el id

En la práctica, para los hijos directos suele bastar lista.children, que ya conoces y es más rápido.

  1. closest() y matches()

Dos métodos pequeños con un impacto enorme en el código que viene.

elemento.closest(selector) sube por el árbol —empezando por el propio elemento— hasta encontrar el primer antepasado que cumpla el selector, y devuelve null si no encuentra ninguno. Es la versión robusta de encadenar parentElement:

const titulo = document.querySelector('.tarea__titulo');

console.log(titulo.closest('li'));            // <li class="tarea" data-id="1">…
console.log(titulo.closest('.tablero').id);   // ''  ← la section no tiene id, pero es ella
console.log(titulo.closest('[data-id]').dataset.id); // '1'
console.log(titulo.closest('table'));         // null ← no hay ninguna tabla por encima

Compara con titulo.parentElement.parentElement.parentElement: la versión con closest sobrevive a cualquier <div> que alguien añada por motivos de maquetación. Guarda bien este método: en 06-04 será la pieza central de la delegación de eventos.

elemento.matches(selector) no busca nada: pregunta si el elemento cumple un selector, y devuelve un booleano.

const li = document.querySelector('[data-id="1"]');

console.log(li.matches('li'));               // true
console.log(li.matches('.tarea'));           // true
console.log(li.matches('.tarea--hecha'));    // false
console.log(li.matches('#lista-tareas > li')); // true

Es el equivalente a classList.contains pero con toda la potencia de los selectores, y es ideal como predicado de un filter:

const abiertas = [...document.querySelectorAll('.tarea')]
  .filter((li) => !li.matches('.tarea--hecha'));

  1. De NodeList a array

Recordatorio breve de 06-01, porque lo vas a necesitar en cada lección: querySelectorAll devuelve una NodeList, no un array. Tiene length, corchetes, forEach y es iterable, pero no map, filter, reduce, sort ni find.

const nodos = document.querySelectorAll('.tarea');

// ✗ nodos.map(...)   → TypeError
const ids = [...nodos].map((li) => Number(li.dataset.id));
console.log(ids);                    // [1, 2, 6]

// Array.from con función de transformación, en una sola pasada:
const ids2 = Array.from(nodos, (li) => Number(li.dataset.id));

// Y con lo que sabes de 04-05, cualquier agregación:
const porResponsable = Object.groupBy(
  [...document.querySelectorAll('.tarea')],
  (li) => li.dataset.responsable
);

  1. textContent, innerText e innerHTML

Tres propiedades para leer y escribir el contenido de un elemento, y una diferencia entre ellas que importa muchísimo.

<li class="tarea" data-id="1">
  <span class="tarea__titulo">Rediseñar la <b>sala</b> polivalente</span>
  <span class="tarea__meta" hidden>Iván · 12 h</span>
</li>
const li = document.querySelector('[data-id="1"]');

console.log(li.textContent);
// '\n  Rediseñar la sala polivalente\n  Iván · 12 h\n'   ← TODO el texto, incluido el oculto

console.log(li.innerText);
// 'Rediseñar la sala polivalente'                        ← solo lo VISIBLE, normalizado

console.log(li.innerHTML);
// '\n  <span class="tarea__titulo">Rediseñar la <b>sala</b> polivalente</span>…'
Propiedad Qué lee Qué escribe Coste de lectura ¿Seguro al escribir?
textContent Todo el texto, incluido el oculto y el espaciado tal cual Texto plano; las etiquetas se ven literalmente Bajo
innerText Solo el texto visible, con el espaciado ya normalizado Texto plano (respeta saltos de línea) Alto: obliga a recalcular el diseño
innerHTML El marcado HTML interior Interpreta HTML: crea elementos reales Medio No

La conclusión práctica es doble.

Para escribir texto, usa siempre textContent. Es más rápido y, sobre todo, seguro:

const titulo = li.querySelector('.tarea__titulo');
titulo.textContent = 'Rediseñar la sala polivalente <urgente>';
// En pantalla se lee literalmente:  Rediseñar la sala polivalente <urgente>

innerHTML interpreta lo que le des, y ahí está el peligro. Si el texto viene de un usuario —el formulario de 06-07, un servidor, la URL—, quien lo escriba puede inyectar marcado y código. Eso se llama XSS (Cross-Site Scripting):

// Imagina que Iván escribe esto como título de una tarea:
const tituloMalicioso = '<img src=x onerror="alert(\'Tus datos son míos\')">';

li.innerHTML = tituloMalicioso;
// ✗ El navegador crea una imagen que falla, ejecuta el onerror y corre código ajeno
//   en el contexto de tu página: puede leer cookies, enviar datos, cambiar la interfaz.

li.textContent = tituloMalicioso;
// ✓ En pantalla aparece el texto literal '<img src=x onerror="…">'. Nada se ejecuta.

Un matiz técnico que a veces se malinterpreta: innerHTML = '<script>…</script>' no ejecuta ese <script>, pero eso no salva nada, porque atributos como onerror, onload u onmouseover sí se ejecutan, y el ejemplo de arriba es la demostración.

La regla que debes seguir el resto del curso:

textContent por defecto. innerHTML solo con marcado que hayas escrito tú, literal y sin interpolar datos externos. Si necesitas insertar estructura, créala con createElement (lección 06-05) o escapa el texto (lección 06-06).

Hay un tercer motivo, menos grave pero muy real, para evitar innerHTML: reasignarlo destruye y reconstruye todos los nodos interiores. Los manejadores de eventos que hubieras puesto en ellos desaparecen, el foco se pierde y cualquier estado del navegador (el scroll de un contenedor, el texto seleccionado) se va con ellos. Lo verás en detalle en 06-05 y 06-06.

Y sobre innerText: su lectura obliga al navegador a calcular qué es visible, lo que implica recalcular el diseño de la página. Es medible en bucles. Úsalo solo cuando de verdad necesites «lo que el usuario ve»; para todo lo demás, textContent.

  1. Atributos frente a propiedades

Este es uno de esos temas que parecen una sutileza y luego explican un bug entero.

  • Un atributo es lo que escribes en el HTML: <input value="8" id="horas">. Vive en el marcado.
  • Una propiedad es un campo del objeto DOM: input.value. Vive en memoria.

Al analizar el HTML, el navegador crea la propiedad a partir del atributo. Pero a partir de ahí pueden divergir, y de hecho lo hacen.

const input = document.querySelector('#horas');   // <input id="horas" value="8">

console.log(input.getAttribute('value'));   // '8'
console.log(input.value);                   // '8'

// El usuario escribe 12 en el campo…
console.log(input.getAttribute('value'));   // '8'   ← el atributo NO cambia: es el valor inicial
console.log(input.value);                   // '12'  ← la propiedad es el valor ACTUAL

La lectura correcta es: el atributo guarda el valor inicial (lo que había en el HTML); la propiedad guarda el valor actual. Para leer lo que el usuario tiene escrito, siempre input.value.

Los métodos de atributos:

li.getAttribute('data-id');        // '1'  ← siempre devuelve string o null
li.setAttribute('aria-busy', 'true');
li.hasAttribute('hidden');         // true / false
li.removeAttribute('hidden');
console.log(li.attributes);        // NamedNodeMap con todos los atributos

Y la tabla de divergencias que hay que memorizar:

Caso Atributo Propiedad Nota
value de un input Valor inicial (string) Valor actual (string) Usa la propiedad
checked de un checkbox Presencia = marcado por defecto Booleano del estado actual Usa la propiedad
disabled, hidden, required Atributos booleanos: basta con estar Booleano setAttribute('disabled','') = desactivado
class getAttribute('class') className (string) y classList (objeto) Usa classList
for de una <label> for htmlFor for es palabra reservada
href de un <a> Lo escrito: '/tareas' URL absoluta: 'https://…/tareas' Dependiendo de lo que necesites
data-id '1' (string) dataset.id'1' (string) Usa dataset

El caso de los atributos booleanos merece un aviso, porque es un error muy repetido:

const boton = document.querySelector('#marcar-hecha');

boton.setAttribute('disabled', 'false');   // ✗ ¡El botón queda DESACTIVADO!
// En HTML, un atributo booleano actúa por su mera presencia. El valor da igual.

boton.disabled = false;                    // ✓ Correcto: la propiedad es un booleano de verdad
boton.removeAttribute('disabled');         // ✓ También correcto

Criterio general: usa las propiedades. Recurre a getAttribute/setAttribute solo para atributos que no tienen propiedad equivalente —los aria-*, los data-* cuando no usas dataset, o atributos personalizados—.

  1. dataset y los atributos data-*

HTML permite inventarse atributos siempre que empiecen por data-. Son válidos, no interfieren con nada y existen exactamente para esto: guardar datos de tu aplicación en el elemento que los representa.

<li class="tarea" data-id="6" data-responsable="Iván" data-prioridad="alta" data-horas-estimadas="5">

Se leen y escriben a través de la propiedad dataset, que expone cada data-* como una clave en camelCase:

const li = document.querySelector('[data-id="6"]');

console.log(li.dataset.id);              // '6'
console.log(li.dataset.responsable);     // 'Iván'
console.log(li.dataset.horasEstimadas);  // '5'   ← data-horas-estimadas → horasEstimadas
console.log(li.dataset);                 // DOMStringMap { id: '6', responsable: 'Iván', … }

// Escribir crea o actualiza el atributo en el HTML:
li.dataset.estado = 'en-curso';          // añade data-estado="en-curso"
delete li.dataset.estado;                // lo elimina

La conversión de nombres funciona en los dos sentidos: data-fecha-limitedataset.fechaLimite. Y hay una trampa que cuesta horas si no se conoce:

console.log(typeof li.dataset.horasEstimadas);      // 'string'  ← ¡SIEMPRE string!
console.log(li.dataset.horasEstimadas + 1);         // '51'      ← concatenación, no suma
console.log(Number(li.dataset.horasEstimadas) + 1); // 6         ✓

Todo lo que sale del DOM es texto. Es exactamente la lección de 01-07, y volverá a aparecer con los formularios en 06-07. Convierte siempre de forma explícita, con Number().

El uso que hacemos aquí es la pieza clave del módulo entero: data-id es el puente entre la pantalla y el modelo.

import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';

const tablero = new Tablero('Taller Nómada', crearBacklog());

const li = document.querySelector('[data-id="6"]');
const tarea = tablero.buscarPorId(Number(li.dataset.id));

console.log(tarea.titulo);          // 'Presupuesto de la carpintería'
console.log(tarea.estaVencida());   // true

Ese Number(li.dataset.id) es la conversión de frontera: la página habla en strings, el modelo en números.

Qué guardar y qué no. data-id sí: es un identificador estable y mínimo. Duplicar todo el objeto en el elemento (data-titulo, data-etiquetas, data-revisor…) es mala idea, porque tendrías dos copias de la verdad que pueden desincronizarse. El modelo es la única fuente de verdad; el DOM guarda solo la clave para volver a él. Es el mismo principio que aplicarás en 06-06 al reconciliar la lista.

  1. Clases con classList

classList es un objeto especializado en manipular la lista de clases de un elemento, y sus métodos son exactamente los que necesitas:

const li = document.querySelector('[data-id="6"]');

li.classList.add('tarea--alta');                    // añade (si ya está, no hace nada)
li.classList.add('tarea--vencida', 'destacada');    // varias de golpe
li.classList.remove('destacada');                   // quita (si no está, no falla)
li.classList.toggle('plegada');                     // pone/quita; devuelve true si quedó puesta
li.classList.toggle('tarea--hecha', tarea.estado === 'hecha');  // ← con segundo argumento: fuerza
li.classList.replace('tarea--media', 'tarea--alta'); // sustituye; devuelve true si hubo cambio
console.log(li.classList.contains('tarea--alta'));   // true
console.log([...li.classList]);                      // ['tarea', 'tarea--alta', 'tarea--vencida']

El toggle con segundo argumento es especialmente valioso, porque convierte cuatro líneas en una:

// Antes
if (tarea.estado === 'hecha') li.classList.add('tarea--hecha');
else li.classList.remove('tarea--hecha');

// Después
li.classList.toggle('tarea--hecha', tarea.estado === 'hecha');

La alternativa antigua es className, que es la cadena completa, y es peligrosa:

li.className = 'tarea--hecha';
// ✗ Ha BORRADO 'tarea' y 'tarea--alta'. El elemento pierde todo su estilo base.

li.className += ' tarea--hecha';
// ✗ Funciona hasta que olvidas el espacio inicial y creas 'tarea--altatarea--hecha'.
//   Y añade duplicados si se ejecuta dos veces.

classList no tiene ninguno de esos problemas: es idempotente, no duplica y no destruye lo que no le has pedido tocar. Usa siempre classList.

  1. Estilos en línea con style (y por qué casi nunca)

La propiedad style da acceso a los estilos en línea del elemento —los del atributo style="…"—, con los nombres CSS en camelCase:

li.style.backgroundColor = '#fef3c7';   // background-color
li.style.borderLeftColor = 'crimson';   // border-left-color
li.style.display = 'none';
li.style.removeProperty('display');
li.style.setProperty('--color-alta', '#991b1b');   // variables CSS, con setProperty

Un aviso importante: style solo lee lo que está en línea, no lo que viene de la hoja de estilos.

// En estilos.css: .tarea { padding: 0.6rem 0.8rem; }
console.log(li.style.padding);                            // ''  ← vacío, no está en línea
console.log(getComputedStyle(li).padding);                // '9.6px 12.8px' ← el valor real

getComputedStyle(elemento) devuelve los estilos finales tras aplicar todas las reglas. Es de solo lectura y, como veremos en el apartado siguiente, obliga al navegador a calcular el diseño, así que no lo llames dentro de un bucle.

Por qué se prefieren las clases. Cambiar style desde JavaScript reparte la presentación entre dos ficheros: la mitad en el CSS y la mitad escondida en cadenas dentro del JavaScript. Cuando alguien quiera cambiar el rojo de las tareas de prioridad alta, buscará en el CSS y no lo encontrará. Además, los estilos en línea tienen una especificidad altísima y son muy difíciles de sobrescribir.

Enfoque Dónde vive el diseño Legibilidad Reutilizable Cuándo usarlo
classList.add('tarea--alta') En el CSS Alta Casi siempre
style.borderLeftColor = '…' Repartido Baja No Valores calculados en tiempo de ejecución

La excepción legítima son los valores que solo se conocen en ejecución: la anchura de una barra de progreso, la posición de un elemento arrastrado, un color que viene de datos. Incluso ahí, lo elegante es usar una variable CSS:

// La barra de carga de un responsable: 25 h de 45 abiertas
const barra = document.querySelector('#carga-ivan');
barra.style.setProperty('--porcentaje', `${(25 / 45) * 100}%`);
.barra::before { width: var(--porcentaje, 0%); }

Así el cómo se ve sigue viviendo en el CSS y el JavaScript solo aporta el dato.

  1. Leer geometría: getBoundingClientRect y offsetWidth

A veces necesitas saber dónde está o cuánto mide un elemento. El DOM ofrece dos familias:

const li = document.querySelector('[data-id="1"]');

const caja = li.getBoundingClientRect();
console.log(caja);
// DOMRect { x: 24, y: 210, width: 800, height: 41.6, top: 210, right: 824, bottom: 251.6, left: 24 }

console.log(li.offsetWidth, li.offsetHeight);   // 800 42  ← enteros redondeados, con borde
console.log(li.clientWidth);                    // 798      ← sin borde ni barra de scroll
console.log(li.scrollHeight);                   // 42       ← alto total del contenido

Diferencias que conviene tener claras:

Propiedad Incluye Tipo Relativo a
getBoundingClientRect() Borde, y cualquier transformación CSS Decimales La ventana visible (viewport)
offsetWidth / offsetHeight Contenido + relleno + borde Enteros El propio elemento
clientWidth / clientHeight Contenido + relleno Enteros El propio elemento
scrollWidth / scrollHeight Todo el contenido, aunque desborde Enteros El propio elemento

Y aquí llega la advertencia importante. Estas lecturas son síncronas y exactas: el navegador está obligado a darte la medida correcta ahora mismo. Si acabas de modificar el DOM y hay cambios de diseño pendientes de calcular, el navegador tiene que detenerlo todo y recalcular el diseño (lo que se llama reflow o layout) antes de responderte. Hacerlo una vez es irrelevante; hacerlo dentro de un bucle, alternando escritura y lectura, es catastrófico:

// ✗ Patrón tóxico: escribir y leer alternativamente fuerza un reflow por vuelta
for (const li of document.querySelectorAll('.tarea')) {
  li.classList.add('destacada');            // escribe: invalida el diseño
  console.log(li.offsetHeight);             // lee: obliga a recalcularlo YA
}

// ✓ Separa las fases: primero todas las lecturas, después todas las escrituras
const items = [...document.querySelectorAll('.tarea')];
const alturas = items.map((li) => li.offsetHeight);   // todas las lecturas
items.forEach((li) => li.classList.add('destacada')); // todas las escrituras

Este patrón se llama layout thrashing y tiene una lección entera dedicada: Manipulación Eficiente del DOM. De momento, quédate con la regla: agrupa lecturas, agrupa escrituras, y no las intercales.

  1. Nómada Tareas: pintar estado y prioridad

Ya tienes todas las piezas. Vamos a hacer que los tres <li> escritos a mano en index.html muestren los datos reales que salen del Tablero: la clase de prioridad, la tachadura si están hechas, el aviso de vencida y la línea de meta correcta. Es el primer código de la carpeta js/vista/.

Primero ampliamos el HTML para que cada <li> tenga la estructura definitiva y su data-id:

<ul id="lista-tareas" class="lista-tareas">
  <li class="tarea" data-id="1">
    <span class="tarea__titulo"></span>
    <span class="tarea__meta"></span>
  </li>
  <li class="tarea" data-id="3">
    <span class="tarea__titulo"></span>
    <span class="tarea__meta"></span>
  </li>
  <li class="tarea" data-id="6">
    <span class="tarea__titulo"></span>
    <span class="tarea__meta"></span>
  </li>
</ul>

Y ahora el módulo de vista. Fíjate en que no importa nada del DOM en el modelo: es la vista la que conoce las dos cosas.

// js/vista/pintar.js
import { HOY } from '../util/fechas.js';
import { marcaDeEstado } from '../util/formato.js';

/** Devuelve el sufijo de clase que corresponde a la prioridad. */
const CLASES_PRIORIDAD = Object.freeze({
  alta: 'tarea--alta',
  media: 'tarea--media',
  baja: 'tarea--baja'
});

/**
 * Vuelca los datos de una Tarea del modelo en su <li> de la página.
 * @param {HTMLElement} li  el <li> con data-id
 * @param {Tarea} tarea     la tarea del modelo
 */
export function pintarTarea(li, tarea, hoy = HOY) {
  // 1 · Texto, siempre con textContent (nunca innerHTML con datos)
  li.querySelector('.tarea__titulo').textContent = tarea.titulo;
  li.querySelector('.tarea__meta').textContent =
    `${marcaDeEstado(tarea.estado)} ${tarea.responsable ?? 'sin asignar'} · ` +
    `${tarea.horasEstimadas} h · ${tarea.estado}`;

  // 2 · Prioridad: quitamos las tres variantes y ponemos la que toca
  li.classList.remove(...Object.values(CLASES_PRIORIDAD));
  li.classList.add(CLASES_PRIORIDAD[tarea.prioridad] ?? 'tarea--media');

  // 3 · Estados booleanos: toggle con segundo argumento
  li.classList.toggle('tarea--hecha', tarea.estado === 'hecha');
  li.classList.toggle('tarea--vencida', tarea.estaVencida(hoy));

  // 4 · Accesibilidad y datos de apoyo
  li.dataset.estado = tarea.estado;
  li.dataset.responsable = tarea.responsable ?? '';
  li.setAttribute('aria-label',
    `${tarea.titulo}, prioridad ${tarea.prioridad}, ${tarea.estado}` +
    (tarea.estaVencida(hoy) ? ', vencida' : ''));
}

/** Recorre los <li> presentes en la página y los pinta desde el tablero. */
export function pintarLista(contenedor, tablero, hoy = HOY) {
  for (const li of contenedor.querySelectorAll('li[data-id]')) {
    const tarea = tablero.buscarPorId(Number(li.dataset.id));
    if (tarea === null) {           // guarda: el HTML podría referirse a un id inexistente
      console.warn(`No existe la tarea ${li.dataset.id} en el modelo.`);
      continue;
    }
    pintarTarea(li, tarea, hoy);
  }
}

/** Actualiza el párrafo de resumen con los números del tablero. */
export function pintarResumen(parrafo, tablero, hoy = HOY) {
  const { total, abiertas, horasAbiertas, vencidas, esfuerzo } = tablero.resumen(hoy);
  parrafo.textContent =
    `${total} tareas · ${abiertas} abiertas · ${horasAbiertas} h pendientes · ` +
    `${vencidas} vencida${vencidas === 1 ? '' : 's'} · esfuerzo ${esfuerzo}`;
}

Y el punto de entrada, que se queda en cuatro líneas:

// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { HOY } from './util/fechas.js';
import { pintarLista, pintarResumen } from './vista/pintar.js';

const tablero = new Tablero('Taller Nómada', crearBacklog());

const lista = document.querySelector('#lista-tareas');
const resumen = document.querySelector('#resumen');

pintarLista(lista, tablero, HOY);
pintarResumen(resumen, tablero, HOY);

En la pantalla ves ahora tres tareas con su franja de color según la prioridad, la de la carpintería marcada como vencida, y el resumen:

6 tareas · 5 abiertas · 45 h pendientes · 1 vencida · esfuerzo 124

Los números canónicos de Taller Nómada, por primera vez fuera de la consola. Merece la pena señalar tres decisiones de diseño de este código, porque marcan el estilo del resto del módulo:

  • La vista lee del modelo, nunca al revés. pintarTarea recibe una Tarea y escribe en el DOM. Tarea sigue sin saber que existe una página.
  • Nada de innerHTML. Todos los textos entran por textContent, así que un título con < o & no puede romper ni comprometer nada.
  • La clase se calcula, no se acumula. El remove(...Object.values(...)) antes del add garantiza que llamar dos veces a pintarTarea con prioridades distintas deja el elemento limpio. Si solo hicieras add, un <li> acabaría con tarea--alta y tarea--baja a la vez.

Lo que sigue faltando es evidente: la lista sigue teniendo tres <li> escritos a mano de un backlog de seis, y no ocurre absolutamente nada cuando haces clic. Ambas cosas se resuelven en las lecciones siguientes.

Errores Comunes y Consejos

  • getElementById('#lista') con almohadilla. Devuelve null en silencio. getElementById recibe un id pelado; querySelector recibe un selector con #. Si te lías, usa siempre querySelector.
  • No comprobar el null. document.querySelector('#no-existe').textContent = 'x' lanza TypeError: Cannot set properties of null. Ese error, cuando aparece nada más cargar la página, casi siempre significa que el id está mal escrito o que el elemento aún no existe.
  • Usar innerHTML con datos de usuario. Es el agujero de seguridad más común en aplicaciones web. textContent por defecto, y punto. Si necesitas estructura, créala con nodos (06-05).
  • setAttribute('disabled', 'false'). Deja el elemento desactivado, porque los atributos booleanos actúan por su presencia. Usa la propiedad: boton.disabled = false.
  • Olvidar que dataset devuelve strings. li.dataset.horasEstimadas + 1 da '51'. Convierte con Number() en la frontera entre el DOM y el modelo, siempre.
  • Machacar className. li.className = 'tarea--hecha' borra todas las demás clases. Usa classList.add/remove/toggle.
  • Repetir la misma búsqueda dentro de un bucle. document.querySelector('#lista') dentro de un forEach que corre mil veces busca mil veces. Guarda el resultado en una constante fuera del bucle.
  • Consejo: nombra las constantes de selección con criterio. Una convención muy extendida es prefijar los elementos del DOM: const $lista = document.querySelector('#lista-tareas'). No es obligatorio, pero deja claro de un vistazo qué variables son nodos.
  • Consejo: closest() es tu amigo. Cada vez que te sorprendas escribiendo .parentElement.parentElement, párate y usa closest('.loquesea'). El código resultante es más corto y no se rompe al cambiar la maquetación.
  • Consejo: agrupa lecturas y escrituras. Leer offsetWidth o getBoundingClientRect() justo después de escribir fuerza un recálculo del diseño. Primero mide todo, luego escribe todo.

Ejercicios

Ejercicio 1 · Un inventario de la página

Escribe una función inventario() que devuelva un objeto con: el número total de elementos con clase tarea, cuántas tienen prioridad alta, cuántas están vencidas y el array de id (como números) de las tareas abiertas. Usa querySelectorAll, conversión a array y los métodos de 04-05. No accedas al modelo: toda la información debe salir del DOM ya pintado.

Ejercicio 2 · Resaltar por responsable

Escribe resaltarResponsable(nombre) que añada la clase destacada a los <li> cuyo data-responsable coincida con nombre, y la quite de todos los demás. Debe funcionar llamándola varias veces seguidas con nombres distintos sin dejar rastros. Añade al CSS la regla necesaria para que destacada se note. Resuélvelo de dos formas: con classList.toggle y con matches.

Ejercicio 3 · De atributos a modelo, y vuelta

Dado el <li> de la carpintería, escribe un fragmento que: (a) lea su data-id, (b) recupere la Tarea del tablero, (c) la marque como en-curso usando el método del modelo, y (d) vuelva a pintar solo ese <li>. Comprueba en el DOM que ha cambiado la clase, el data-estado y el aria-label, y que la clase tarea--vencida sigue puesta. Explica por qué sigue puesta.

Soluciones

Ejercicio 1

function inventario() {
  const items = [...document.querySelectorAll('.tarea')];

  return {
    total: items.length,
    altas: items.filter((li) => li.matches('.tarea--alta')).length,
    vencidas: items.filter((li) => li.classList.contains('tarea--vencida')).length,
    idsAbiertos: items
      .filter((li) => li.dataset.estado !== 'hecha')
      .map((li) => Number(li.dataset.id))
  };
}

console.log(inventario());
// { total: 3, altas: 3, vencidas: 1, idsAbiertos: [1, 3, 6] }

Claves: el spread convierte la NodeList en array para poder encadenar filter y map; matches('.tarea--alta') y classList.contains('tarea--vencida') son equivalentes aquí, y usar los dos enseña que matches acepta selectores completos mientras contains solo admite un nombre de clase; y el Number(li.dataset.id) es imprescindible, porque sin él obtendrías ['1', '3', '6'].

Ejercicio 2

// Versión A · con toggle y su segundo argumento
function resaltarResponsable(nombre) {
  for (const li of document.querySelectorAll('.tarea')) {
    li.classList.toggle('destacada', li.dataset.responsable === nombre);
  }
}

// Versión B · con matches y un selector de atributo
function resaltarResponsableB(nombre) {
  document.querySelectorAll('.tarea')
    .forEach((li) => li.classList.remove('destacada'));
  document.querySelectorAll(`.tarea[data-responsable="${nombre}"]`)
    .forEach((li) => li.classList.add('destacada'));
}

resaltarResponsable('Iván');    // destaca las de Iván
resaltarResponsable('Lucía');   // quita las de Iván y destaca las de Lucía
.destacada { background: #fffbeb; }

La versión A es la mejor: una sola pasada y el toggle con segundo argumento hace innecesario limpiar antes. La versión B recorre dos veces y, además, interpola un valor dentro de un selector; si el nombre llevara comillas o caracteres especiales, el selector podría ser inválido. Si algún día necesitas construir un selector con datos, usa CSS.escape(nombre).

Ejercicio 3

import { pintarTarea } from './vista/pintar.js';

const li = document.querySelector('[data-id="6"]');
const tarea = tablero.buscarPorId(Number(li.dataset.id));   // (a) y (b)

tarea.cambiarEstado('en-curso');                            // (c) regla R6 del modelo
pintarTarea(li, tarea);                                     // (d)

console.log(li.className);              // 'tarea tarea--alta tarea--vencida'
console.log(li.dataset.estado);         // 'en-curso'
console.log(li.getAttribute('aria-label'));
// 'Presupuesto de la carpintería, prioridad alta, en-curso, vencida'

tarea--vencida sigue puesta porque la regla R10 del modelo define «vencida» como fecha límite pasada y tarea sin terminar. La fecha de la carpintería es 2026-09-05, anterior a HOY = '2026-09-20', y 'en-curso' no es 'hecha': la tarea sigue vencida. Solo desaparecería la clase al pasar a 'hecha'. Fíjate en que la vista no ha tenido que saber nada de esa regla: se ha limitado a preguntar tarea.estaVencida(hoy). La regla de negocio vive en el modelo, la decoración en la vista, y esa frontera es la que hace que este código se pueda probar sin navegador en el Módulo 8.

Conclusión

Ya sabes encontrar cualquier cosa en la página y cambiarla. Para seleccionar, el idioma son los selectores CSS y las herramientas son querySelector (el primero que coincida, o null) y querySelectorAll (una NodeList estática, posiblemente vacía), con getElementById como opción rápida para el contenedor raíz y los dos getElementsBy… relegados a código heredado por sus colecciones vivas. Sabes que ambos métodos existen también en los elementos, lo que te permite acotar la búsqueda a un subárbol; que :scope resuelve el caso de los hijos directos; y que closest() sube hasta el primer antepasado que cumpla un selector mientras matches() responde sí o no sobre un elemento concreto. Ese closest() es, de largo, el método más importante de la lección: es la pieza sobre la que se construirá la delegación de eventos.

Para manipular, tienes las reglas de criterio: textContent como forma por defecto de leer y escribir texto —barata y segura—, innerText solo cuando de verdad necesites lo visible pese a su coste de recálculo, y innerHTML bajo sospecha permanente por el riesgo de XSS y porque destruye los nodos interiores. Distingues atributos (el valor inicial del marcado) de propiedades (el valor actual en memoria), sabes que para value, checked y disabled mandan las propiedades, y que setAttribute('disabled', 'false') desactiva en lugar de activar. Manejas dataset para los data-*, con la advertencia de que todo llega como string y con el criterio de guardar solo el id en el DOM y dejar el resto de la verdad en el modelo. Usas classList con sus add, remove, toggle (¡con segundo argumento!), contains y replace en lugar de machacar className, y reservas style para los valores que solo existen en tiempo de ejecución, preferiblemente a través de variables CSS. Y conoces getBoundingClientRect, offsetWidth y compañía junto con su peligro: intercalar lecturas y escrituras fuerza recálculos de diseño.

En Nómada Tareas eso se ha traducido en js/vista/pintar.js con pintarTarea, pintarLista y pintarResumen: la primera capa de vista real del proyecto, que toma las Tarea del Tablero y las vuelca en los <li> de la página con sus clases de prioridad, su tachado, su aviso de vencida y su aria-label, mostrando en pantalla los 6 elementos, las 45 h abiertas y el esfuerzo 124 de siempre.

Pero la página sigue siendo un cartel: se ve, y no responde. Si Marta hace clic en una tarea no pasa nada; si Lucía quiere ver solo lo suyo, no hay forma de pedirlo. Una aplicación es, por definición, algo que reacciona a lo que hace la persona que la usa, y para eso hace falta el otro gran pilar del DOM: los eventos. Cómo se registra un manejador, qué información trae el objeto Event, por qué addEventListener es la única forma aceptable de las tres que existen, y por qué un <div> con un click deja fuera a quien navega con teclado, es Manejo de Eventos, donde Marta podrá marcar por fin una tarea como hecha con un clic.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados