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
- Los selectores CSS como idioma común
- Los métodos clásicos:
getElementByIdy compañía querySelectoryquerySelectorAll- Tabla comparativa de los métodos de selección
- Buscar dentro de un elemento: el ámbito de la búsqueda
closest()ymatches()- De
NodeLista array textContent,innerTexteinnerHTML- Atributos frente a propiedades
datasety los atributosdata-*- Clases con
classList - Estilos en línea con
style(y por qué casi nunca) - Leer geometría:
getBoundingClientRectyoffsetWidth - Nómada Tareas: pintar estado y prioridad
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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.
- Los métodos clásicos:
getElementById y compañía
getElementById y compañíaAntes 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:
getElementByIdno lleva#y los otros dos no llevan.: reciben nombres, no selectores. Confundirlo es un error clásico que devuelvenullsin 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 unforclásico y a la vez añades elementos, entras en un bucle infinito. - Si no hay coincidencias,
getElementByIddevuelvenully los otros devuelven una colección vacía, nonull. 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.
querySelector y querySelectorAll
querySelector y querySelectorAllEstos 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); // 3Cuatro 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:
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).
- 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 |
Sí | Sí | Casi nunca; heredado |
getElementsByTagName(t) |
Un nombre de etiqueta | HTMLCollection |
Sí | Sí | Casi nunca; heredado |
querySelector(sel) |
Selector CSS | Elemento o null |
— | Sí | Opción por defecto para un elemento |
querySelectorAll(sel) |
Selector CSS | NodeList estática |
No | Sí | 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.
- 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álidoUn selector no puede empezar por un combinador. Si quieres solo los hijos directos desde el propio elemento, usa :scope:
En la práctica, para los hijos directos suele bastar lista.children, que ya conoces y es más rápido.
closest() y matches()
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 encimaCompara 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')); // trueEs 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'));
- De
NodeList a array
NodeList a arrayRecordatorio 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
);
textContent, innerText e innerHTML
textContent, innerText e innerHTMLTres 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 | Sí |
innerText |
Solo el texto visible, con el espaciado ya normalizado | Texto plano (respeta saltos de línea) | Alto: obliga a recalcular el diseño | Sí |
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:
textContentpor defecto.innerHTMLsolo con marcado que hayas escrito tú, literal y sin interpolar datos externos. Si necesitas insertar estructura, créala concreateElement(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.
- 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 ACTUALLa 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 atributosY 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 correctoCriterio 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—.
dataset y los atributos data-*
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 eliminaLa conversión de nombres funciona en los dos sentidos: data-fecha-limite ↔ dataset.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()); // trueEse 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.
- Clases con
classList
classListclassList 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.
- Estilos en línea con
style (y por qué casi nunca)
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 setPropertyUn 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 realgetComputedStyle(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 | Sí | 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}%`);Así el cómo se ve sigue viviendo en el CSS y el JavaScript solo aporta el dato.
- Leer geometría:
getBoundingClientRect y offsetWidth
getBoundingClientRect y offsetWidthA 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 contenidoDiferencias 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 escriturasEste 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.
- 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:
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.
pintarTarearecibe unaTareay escribe en el DOM.Tareasigue sin saber que existe una página. - Nada de
innerHTML. Todos los textos entran portextContent, 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 deladdgarantiza que llamar dos veces apintarTareacon prioridades distintas deja el elemento limpio. Si solo hicierasadd, un<li>acabaría contarea--altaytarea--bajaa 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. Devuelvenullen silencio.getElementByIdrecibe un id pelado;querySelectorrecibe un selector con#. Si te lías, usa siemprequerySelector.- No comprobar el
null.document.querySelector('#no-existe').textContent = 'x'lanzaTypeError: 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
innerHTMLcon datos de usuario. Es el agujero de seguridad más común en aplicaciones web.textContentpor 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
datasetdevuelve strings.li.dataset.horasEstimadas + 1da'51'. Convierte conNumber()en la frontera entre el DOM y el modelo, siempre. - Machacar
className.li.className = 'tarea--hecha'borra todas las demás clases. UsaclassList.add/remove/toggle. - Repetir la misma búsqueda dentro de un bucle.
document.querySelector('#lista')dentro de unforEachque 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 usaclosest('.loquesea'). El código resultante es más corto y no se rompe al cambiar la maquetación. - Consejo: agrupa lecturas y escrituras. Leer
offsetWidthogetBoundingClientRect()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íaLa 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
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
