La lliçó anterior va acabar amb un index.html semàntic i amb la sensació que arribar a un element saltant de firstElementChild en nextElementSibling és incòmode i fràgil. Ho és. La manera real de treballar amb el DOM consisteix a descriure el que busques amb un selector CSS i deixar que el navegador el trobi per tu, i després canviar-lo: el seu text, els seus atributs, les seves classes. En aquesta lliçó aprendràs les dues meitats d'aquella feina, juntament amb les decisions de criteri que separen el codi professional del que funciona per casualitat: per què textContent és l'opció per defecte i innerHTML un risc de seguretat, per què un atribut i una propietat amb el mateix nom poden no valer el mateix, per què canviar una classe és sempre millor que tocar style, i per què llegir l'amplada d'un element pot ser sorprenentment car. Al final, les tasques de la teva pàgina mostraran l'estat i la prioritat reals que surten del Tauler.

Contingut

  1. Els selectors CSS com a idioma comú
  2. Els mètodes clàssics: getElementById i companyia
  3. querySelector i querySelectorAll
  4. Taula comparativa dels mètodes de selecció
  5. Buscar dins d'un element: l'àmbit de la cerca
  6. closest() i matches()
  7. De NodeList a array
  8. textContent, innerText i innerHTML
  9. Atributs davant de propietats
  10. dataset i els atributs data-*
  11. Classes amb classList
  12. Estils en línia amb style (i per què gairebé mai)
  13. Llegir geometria: getBoundingClientRect i offsetWidth
  14. Nómada Tasques: pintar estat i prioritat
  15. Errors Habituals i Consells
  16. Exercicis
  17. Conclusió

  1. Els selectors CSS com a idioma comú

Un selector CSS és una expressió que descriu un conjunt d'elements. Els coneixes del CSS que vas escriure a la lliçó anterior; el que importa ara és que el mateix idioma serveix per buscar des de JavaScript. Un repàs ràpid dels que faràs servir més:

Selector Selecciona Exemple a Nómada Tasques
p Tots els elements d'aquella etiqueta li
.classe Els que tinguin aquella classe .tasca--alta
#id El que tingui aquell id #llista-tasques
[attr] Els que tinguin aquell atribut [data-id]
[attr="valor"] Els que tinguin aquell atribut amb aquell valor exacte [data-id="6"]
[attr^="v"] Els atributs dels quals comencin per [href^="https"]
a, b Els que compleixin a o b .tasca--alta, .tasca--vencuda
a b Els b que siguin descendents d'a .tauler li
a > b Els b que siguin fills directes d'a #llista-tasques > li
a + b El b immediatament després d'un a h2 + p
a:first-child El que sigui primer fill li:first-child
a:not(sel) Els a que no compleixin el selector li:not(.tasca--feta)
a:has(b) Els a que continguin un b li:has(button[disabled])

Que els selectors es componguin és el que els fa potents. #llista-tasques > li.tasca--alta:not(.tasca--feta) descriu, en una línia, «els fills directes de la llista que siguin tasques de prioritat alta i no estiguin fetes». Escriure allò navegant l'arbre a mà serien deu línies.

  1. Els mètodes clàssics: getElementById i companyia

Abans que existissin els selectors a JavaScript hi havia tres mètodes, i encara es fan servir:

// Per id. Retorna l'element o null. Només existeix a 'document'.
const llista = document.getElementById('llista-tasques');   // sense '#'

// Per classe. Retorna una HTMLCollection VIVA (pot estar buida).
const altes = document.getElementsByClassName('tasca--alta');   // sense '.'

// Per etiqueta. També HTMLCollection viva.
const items = document.getElementsByTagName('li');

Tres detalls que cal tenir presents:

  • getElementById no porta # i els altres dos no porten .: reben noms, no selectors. Confondre-ho és un error clàssic que retorna null sense explicació.
  • Els dos getElements… retornen col·leccions vives, amb tot el que això implica (lliçó 06-01, apartat 6): si hi afegeixes un <li> amb la classe, apareix sol a la col·lecció; si recorres amb un for clàssic i alhora hi afegeixes elements, entres en un bucle infinit.
  • Si no hi ha coincidències, getElementById retorna null i els altres retornen una col·lecció buida, no null. Són dues maneres diferents de dir «res», i confondre-les provoca errors diferents.

getElementById continua sent perfectament vàlid i és el més ràpid de tots, perquè el navegador manté un índex intern d'ids. Els altres dos han quedat pràcticament desplaçats per querySelectorAll.

  1. querySelector i querySelectorAll

Aquests dos mètodes accepten qualsevol selector CSS i són els que faràs servir el 90 % del temps.

// El PRIMER element que coincideix, o null si no n'hi ha cap.
const llista = document.querySelector('#llista-tasques');
const primeraAlta = document.querySelector('.tasca--alta');
const fusteria = document.querySelector('[data-id="6"]');

// TOTS els que coincideixen, com a NodeList ESTÀTICA (possiblement buida).
const tasques = document.querySelectorAll('#llista-tasques > li');
console.log(tasques.length);   // 3

Quatre coses que convé interioritzar:

Retornen null o una llista buida, i cal comptar-hi. Aquest és el TypeError més freqüent de tot el mòdul:

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

La guarda correcta fa servir el que ja saps de 01-06 i del Mòdul 2:

const boto = document.querySelector('#desar');
if (boto === null) {
  console.warn('No he trobat el botó de desar.');
} else {
  boto.disabled = true;
}

// O amb encadenament opcional, quan l'absència és acceptable:
document.querySelector('#desar')?.setAttribute('disabled', '');

querySelectorAll retorna una NodeList estàtica. És una foto del moment: si després afegeixes tasques al DOM, aquella llista no creix. A la pràctica és un avantatge, perquè elimina les sorpreses de les col·leccions vives.

La NodeList sí que té forEach, encara que continuï sense tenir map ni filter:

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

El selector ha de ser vàlid, o el mètode llança una excepció en lloc de retornar null:

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

Compte amb un cas concret: els ids que comencen per un número són vàlids en HTML però trenquen el selector (#1 no és un selector legal). Si generes ids a partir de dades, prefixa amb lletra (tasca-1) o fes servir [data-id="1"], que no té aquell problema. I per a casos generals existeix CSS.escape(valor).

  1. Taula comparativa dels mètodes de selecció

Mètode Rep Retorna Viu? És als elements? Quan fer-lo servir
getElementById(id) Un id sense # Element o null No, només a document El més ràpid; perfecte per al contenidor arrel
getElementsByClassName(c) Un nom de classe HTMLCollection Gairebé mai; heretat
getElementsByTagName(t) Un nom d'etiqueta HTMLCollection Gairebé mai; heretat
querySelector(sel) Selector CSS Element o null Opció per defecte per a un element
querySelectorAll(sel) Selector CSS NodeList estàtica No Opció per defecte per a diversos

Sobre el rendiment: getElementById és més ràpid que querySelector('#id') perquè no ha d'analitzar el selector. La diferència és de nanosegons i no té cap importància tret que sigui dins d'un bucle que s'executi milers de vegades. La regla sensata és fer servir querySelector/querySelectorAll per coherència, desar el resultat en una constant i no tornar a buscar el mateix dues vegades. A 09-04 veuràs com mesurar això de debò; fins llavors, prioritza la llegibilitat.

  1. Buscar dins d'un element: l'àmbit de la cerca

querySelector i querySelectorAll no són exclusius de document: qualsevol element els té, i llavors busquen només entre els seus descendents. Això és fonamental per escriure components que no interfereixin entre si.

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

// Busca NOMÉS dins d'aquell <li>:
const titol = li.querySelector('.tasca__titol');
console.log(titol.textContent);   // 'Cartelleria del taller de serigrafia'

Comparar els dos àmbits deixa clara la diferència:

console.log(document.querySelectorAll('.tasca__titol').length); // 3 · tota la pàgina
console.log(li.querySelectorAll('.tasca__titol').length);       // 1 · només aquell <li>

Un detall que confon molta gent: el selector s'avalua contra tot el document, i després es filtra per «que sigui descendent de l'element». Per això això retorna resultats inesperats:

// Intuïció: "els li fills directes de la llista". Realitat: funciona, però...
llista.querySelectorAll('#llista-tasques > li');   // ✓ 3 elements
llista.querySelectorAll('> li');                   // ✗ SyntaxError: no és un selector vàlid

Un selector no pot començar per un combinador. Si vols només els fills directes des del mateix element, fes servir :scope:

llista.querySelectorAll(':scope > li');   // ✓ 3 elements, sense repetir l'id

A la pràctica, per als fills directes sol bastar llista.children, que ja coneixes i és més ràpid.

  1. closest() i matches()

Dos mètodes petits amb un impacte enorme en el codi que ve.

element.closest(selector) puja per l'arbre —començant pel mateix element— fins a trobar el primer avantpassat que compleixi el selector, i retorna null si no en troba cap. És la versió robusta d'encadenar parentElement:

const titol = document.querySelector('.tasca__titol');

console.log(titol.closest('li'));            // <li class="tasca" data-id="1">…
console.log(titol.closest('.tauler').id);    // ''  ← la section no té id, però és ella
console.log(titol.closest('[data-id]').dataset.id); // '1'
console.log(titol.closest('table'));         // null ← no hi ha cap taula per sobre

Compara amb titol.parentElement.parentElement.parentElement: la versió amb closest sobreviu a qualsevol <div> que algú hi afegeixi per motius de maquetació. Guarda't bé aquest mètode: a 06-04 serà la peça central de la delegació d'esdeveniments.

element.matches(selector) no busca res: pregunta si l'element compleix un selector, i retorna un booleà.

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

console.log(li.matches('li'));                  // true
console.log(li.matches('.tasca'));              // true
console.log(li.matches('.tasca--feta'));        // false
console.log(li.matches('#llista-tasques > li')); // true

És l'equivalent a classList.contains però amb tota la potència dels selectors, i és ideal com a predicat d'un filter:

const obertes = [...document.querySelectorAll('.tasca')]
  .filter((li) => !li.matches('.tasca--feta'));

  1. De NodeList a array

Recordatori breu de 06-01, perquè el necessitaràs a cada lliçó: querySelectorAll retorna una NodeList, no un array. Té length, claudàtors, forEach i és iterable, però no map, filter, reduce, sort ni find.

const nodes = document.querySelectorAll('.tasca');

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

// Array.from amb funció de transformació, en una sola passada:
const ids2 = Array.from(nodes, (li) => Number(li.dataset.id));

// I amb el que saps de 04-05, qualsevol agregació:
const perResponsable = Object.groupBy(
  [...document.querySelectorAll('.tasca')],
  (li) => li.dataset.responsable
);

  1. textContent, innerText i innerHTML

Tres propietats per llegir i escriure el contingut d'un element, i una diferència entre elles que importa moltíssim.

<li class="tasca" data-id="1">
  <span class="tasca__titol">Redissenyar la <b>sala</b> polivalent</span>
  <span class="tasca__meta" hidden>Iván · 12 h</span>
</li>
const li = document.querySelector('[data-id="1"]');

console.log(li.textContent);
// '\n  Redissenyar la sala polivalent\n  Iván · 12 h\n'   ← TOT el text, inclòs l'ocult

console.log(li.innerText);
// 'Redissenyar la sala polivalent'                        ← només el VISIBLE, normalitzat

console.log(li.innerHTML);
// '\n  <span class="tasca__titol">Redissenyar la <b>sala</b> polivalent</span>…'
Propietat Què llegeix Què escriu Cost de lectura Segur en escriure?
textContent Tot el text, inclòs l'ocult i l'espaiat tal com és Text pla; les etiquetes es veuen literalment Baix
innerText Només el text visible, amb l'espaiat ja normalitzat Text pla (respecta salts de línia) Alt: obliga a recalcular el disseny
innerHTML El marcatge HTML interior Interpreta HTML: crea elements reals Mitjà No

La conclusió pràctica és doble.

Per escriure text, fes servir sempre textContent. És més ràpid i, sobretot, segur:

const titol = li.querySelector('.tasca__titol');
titol.textContent = 'Redissenyar la sala polivalent <urgent>';
// A la pantalla es llegeix literalment:  Redissenyar la sala polivalent <urgent>

innerHTML interpreta el que li donis, i aquí hi ha el perill. Si el text ve d'un usuari —el formulari de 06-07, un servidor, la URL—, qui l'escrigui pot injectar marcatge i codi. Això s'anomena XSS (Cross-Site Scripting):

// Imagina que l'Iván escriu això com a títol d'una tasca:
const titolMalicios = '<img src=x onerror="alert(\'Les teves dades son meves\')">';

li.innerHTML = titolMalicios;
// ✗ El navegador crea una imatge que falla, executa l'onerror i corre codi aliè
//   en el context de la teva pàgina: pot llegir galetes, enviar dades, canviar la interfície.

li.textContent = titolMalicios;
// ✓ A la pantalla apareix el text literal '<img src=x onerror="…">'. No s'executa res.

Un matís tècnic que de vegades es malinterpreta: innerHTML = '<script>…</script>' no executa aquell <script>, però això no salva res, perquè atributs com onerror, onload o onmouseover sí que s'executen, i l'exemple de dalt n'és la demostració.

La regla que has de seguir la resta del curs:

textContent per defecte. innerHTML només amb marcatge que hagis escrit tu, literal i sense interpolar dades externes. Si necessites inserir estructura, crea-la amb createElement (lliçó 06-05) o escapa el text (lliçó 06-06).

Hi ha un tercer motiu, menys greu però molt real, per evitar innerHTML: reassignar-lo destrueix i reconstrueix tots els nodes interiors. Els gestors d'esdeveniments que hi haguessis posat desapareixen, el focus es perd i qualsevol estat del navegador (el desplaçament d'un contenidor, el text seleccionat) se'n va amb ells. Ho veuràs en detall a 06-05 i 06-06.

I sobre innerText: la seva lectura obliga el navegador a calcular què és visible, cosa que implica recalcular el disseny de la pàgina. És mesurable en bucles. Fes-lo servir només quan de debò necessitis «el que l'usuari veu»; per a tota la resta, textContent.

  1. Atributs davant de propietats

Aquest és un d'aquells temes que semblen una subtilesa i després expliquen un bug sencer.

  • Un atribut és el que escrius a l'HTML: <input value="8" id="hores">. Viu al marcatge.
  • Una propietat és un camp de l'objecte DOM: input.value. Viu en memòria.

En analitzar l'HTML, el navegador crea la propietat a partir de l'atribut. Però a partir d'aquí poden divergir, i de fet ho fan.

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

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

// L'usuari escriu 12 al camp…
console.log(input.getAttribute('value'));   // '8'   ← l'atribut NO canvia: és el valor inicial
console.log(input.value);                   // '12'  ← la propietat és el valor ACTUAL

La lectura correcta és: l'atribut guarda el valor inicial (el que hi havia a l'HTML); la propietat guarda el valor actual. Per llegir el que l'usuari té escrit, sempre input.value.

Els mètodes d'atributs:

li.getAttribute('data-id');        // '1'  ← sempre retorna string o null
li.setAttribute('aria-busy', 'true');
li.hasAttribute('hidden');         // true / false
li.removeAttribute('hidden');
console.log(li.attributes);        // NamedNodeMap amb tots els atributs

I la taula de divergències que cal memoritzar:

Cas Atribut Propietat Nota
value d'un input Valor inicial (string) Valor actual (string) Fes servir la propietat
checked d'un checkbox Presència = marcat per defecte Booleà de l'estat actual Fes servir la propietat
disabled, hidden, required Atributs booleans: n'hi ha prou de ser-hi Booleà setAttribute('disabled','') = desactivat
class getAttribute('class') className (string) i classList (objecte) Fes servir classList
for d'una <label> for htmlFor for és paraula reservada
href d'un <a> El que hi ha escrit: '/tasques' URL absoluta: 'https://…/tasques' Depenent del que necessitis
data-id '1' (string) dataset.id'1' (string) Fes servir dataset

El cas dels atributs booleans mereix un avís, perquè és un error molt repetit:

const boto = document.querySelector('#marcar-feta');

boto.setAttribute('disabled', 'false');   // ✗ El botó queda DESACTIVAT!
// En HTML, un atribut booleà actua per la seva mera presència. El valor és indiferent.

boto.disabled = false;                    // ✓ Correcte: la propietat és un booleà de debò
boto.removeAttribute('disabled');         // ✓ També correcte

Criteri general: fes servir les propietats. Recorre a getAttribute/setAttribute només per a atributs que no tenen propietat equivalent —els aria-*, els data-* quan no fas servir dataset, o atributs personalitzats—.

  1. dataset i els atributs data-*

L'HTML permet inventar-se atributs sempre que comencin per data-. Són vàlids, no interfereixen amb res i existeixen exactament per a això: guardar dades de la teva aplicació a l'element que les representa.

<li class="tasca" data-id="6" data-responsable="Iván" data-prioritat="alta" data-hores-estimades="5">

Es llegeixen i s'escriuen a través de la propietat dataset, que exposa cada data-* com una clau en camelCase:

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

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

// Escriure crea o actualitza l'atribut a l'HTML:
li.dataset.estat = 'en-curs';            // afegeix data-estat="en-curs"
delete li.dataset.estat;                 // l'elimina

La conversió de noms funciona en tots dos sentits: data-data-limitdataset.dataLimit. I hi ha un parany que costa hores si no es coneix:

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

Tot el que surt del DOM és text. És exactament la lliçó de 01-07, i tornarà a aparèixer amb els formularis a 06-07. Converteix sempre de manera explícita, amb Number().

L'ús que en fem aquí és la peça clau del mòdul sencer: data-id és el pont entre la pantalla i el model.

import { Tauler } from './model/tauler.js';
import { crearBacklog } from './dades/backlog.js';

const tauler = new Tauler('Taller Nómada', crearBacklog());

const li = document.querySelector('[data-id="6"]');
const tasca = tauler.cercarPerId(Number(li.dataset.id));

console.log(tasca.titol);           // 'Pressupost de la fusteria'
console.log(tasca.estaVencuda());   // true

Aquell Number(li.dataset.id) és la conversió de frontera: la pàgina parla en strings, el model en números.

Què guardar i què no. data-id sí: és un identificador estable i mínim. Duplicar tot l'objecte a l'element (data-titol, data-etiquetes, data-revisor…) és mala idea, perquè tindries dues còpies de la veritat que es poden desincronitzar. El model és l'única font de veritat; el DOM guarda només la clau per tornar-hi. És el mateix principi que aplicaràs a 06-06 en reconciliar la llista.

  1. Classes amb classList

classList és un objecte especialitzat a manipular la llista de classes d'un element, i els seus mètodes són exactament els que necessites:

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

li.classList.add('tasca--alta');                    // afegeix (si ja hi és, no fa res)
li.classList.add('tasca--vencuda', 'destacada');    // diverses de cop
li.classList.remove('destacada');                   // treu (si no hi és, no falla)
li.classList.toggle('plegada');                     // posa/treu; retorna true si hi ha quedat
li.classList.toggle('tasca--feta', tasca.estat === 'feta');  // ← amb segon argument: força
li.classList.replace('tasca--mitjana', 'tasca--alta'); // substitueix; retorna true si hi ha hagut canvi
console.log(li.classList.contains('tasca--alta'));  // true
console.log([...li.classList]);                     // ['tasca', 'tasca--alta', 'tasca--vencuda']

El toggle amb segon argument és especialment valuós, perquè converteix quatre línies en una:

// Abans
if (tasca.estat === 'feta') li.classList.add('tasca--feta');
else li.classList.remove('tasca--feta');

// Després
li.classList.toggle('tasca--feta', tasca.estat === 'feta');

L'alternativa antiga és className, que és la cadena completa, i és perillosa:

li.className = 'tasca--feta';
// ✗ Ha ESBORRAT 'tasca' i 'tasca--alta'. L'element perd tot el seu estil base.

li.className += ' tasca--feta';
// ✗ Funciona fins que t'oblides de l'espai inicial i crees 'tasca--altatasca--feta'.
//   I afegeix duplicats si s'executa dues vegades.

classList no té cap d'aquells problemes: és idempotent, no duplica i no destrueix el que no li has demanat tocar. Fes servir sempre classList.

  1. Estils en línia amb style (i per què gairebé mai)

La propietat style dona accés als estils en línia de l'element —els de l'atribut style="…"—, amb els noms CSS en camelCase:

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

Un avís important: style només llegeix el que està en línia, no el que ve del full d'estils.

// A estils.css: .tasca { padding: 0.6rem 0.8rem; }
console.log(li.style.padding);                            // ''  ← buit, no està en línia
console.log(getComputedStyle(li).padding);                // '9.6px 12.8px' ← el valor real

getComputedStyle(element) retorna els estils finals després d'aplicar totes les regles. És de només lectura i, com veurem a l'apartat següent, obliga el navegador a calcular el disseny, així que no el cridis dins d'un bucle.

Per què es prefereixen les classes. Canviar style des de JavaScript reparteix la presentació entre dos fitxers: la meitat al CSS i la meitat amagada en cadenes dins del JavaScript. Quan algú vulgui canviar el vermell de les tasques de prioritat alta, buscarà al CSS i no ho trobarà. A més, els estils en línia tenen una especificitat altíssima i són molt difícils de sobreescriure.

Enfocament On viu el disseny Llegibilitat Reutilitzable Quan fer-lo servir
classList.add('tasca--alta') Al CSS Alta Gairebé sempre
style.borderLeftColor = '…' Repartit Baixa No Valors calculats en temps d'execució

L'excepció legítima són els valors que només es coneixen en execució: l'amplada d'una barra de progrés, la posició d'un element arrossegat, un color que ve de dades. Fins i tot allà, l'elegant és fer servir una variable CSS:

// La barra de càrrega d'un responsable: 25 h de 45 obertes
const barra = document.querySelector('#carrega-ivan');
barra.style.setProperty('--percentatge', `${(25 / 45) * 100}%`);
.barra::before { width: var(--percentatge, 0%); }

Així el com es veu continua vivint al CSS i el JavaScript només hi aporta la dada.

  1. Llegir geometria: getBoundingClientRect i offsetWidth

De vegades necessites saber on és o quant mesura un element. El DOM ofereix dues famílies:

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

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

console.log(li.offsetWidth, li.offsetHeight);   // 800 42  ← enters arrodonits, amb vora
console.log(li.clientWidth);                    // 798      ← sense vora ni barra de desplaçament
console.log(li.scrollHeight);                   // 42       ← alçada total del contingut

Diferències que convé tenir clares:

Propietat Inclou Tipus Relatiu a
getBoundingClientRect() Vora, i qualsevol transformació CSS Decimals La finestra visible (viewport)
offsetWidth / offsetHeight Contingut + farciment + vora Enters El mateix element
clientWidth / clientHeight Contingut + farciment Enters El mateix element
scrollWidth / scrollHeight Tot el contingut, encara que desbordi Enters El mateix element

I aquí arriba l'advertència important. Aquestes lectures són síncrones i exactes: el navegador està obligat a donar-te la mesura correcta ara mateix. Si acabes de modificar el DOM i hi ha canvis de disseny pendents de calcular, el navegador ho ha d'aturar tot i recalcular el disseny (el que s'anomena reflow o layout) abans de respondre't. Fer-ho una vegada és irrellevant; fer-ho dins d'un bucle, alternant escriptura i lectura, és catastròfic:

// ✗ Patró tòxic: escriure i llegir alternativament força un reflow per volta
for (const li of document.querySelectorAll('.tasca')) {
  li.classList.add('destacada');            // escriu: invalida el disseny
  console.log(li.offsetHeight);             // llegeix: obliga a recalcular-lo JA
}

// ✓ Separa les fases: primer totes les lectures, després totes les escriptures
const items = [...document.querySelectorAll('.tasca')];
const alcades = items.map((li) => li.offsetHeight);   // totes les lectures
items.forEach((li) => li.classList.add('destacada')); // totes les escriptures

Aquest patró s'anomena layout thrashing i té una lliçó sencera dedicada: Manipulació Eficient del DOM. De moment, queda't amb la regla: agrupa lectures, agrupa escriptures, i no les intercalis.

  1. Nómada Tasques: pintar estat i prioritat

Ja tens totes les peces. Farem que els tres <li> escrits a mà a index.html mostrin les dades reals que surten del Tauler: la classe de prioritat, el ratllat si estan fetes, l'avís de vençuda i la línia de meta correcta. És el primer codi de la carpeta js/vista/.

Primer ampliem l'HTML perquè cada <li> tingui l'estructura definitiva i el seu data-id:

<ul id="llista-tasques" class="llista-tasques">
  <li class="tasca" data-id="1">
    <span class="tasca__titol"></span>
    <span class="tasca__meta"></span>
  </li>
  <li class="tasca" data-id="3">
    <span class="tasca__titol"></span>
    <span class="tasca__meta"></span>
  </li>
  <li class="tasca" data-id="6">
    <span class="tasca__titol"></span>
    <span class="tasca__meta"></span>
  </li>
</ul>

I ara el mòdul de vista. Fixa't que no s'importa res del DOM al model: és la vista la que coneix totes dues coses.

// js/vista/pintar.js
import { AVUI } from '../util/dates.js';
import { marcaEstat } from '../util/format.js';

/** Retorna el sufix de classe que correspon a la prioritat. */
const CLASSES_PRIORITAT = Object.freeze({
  alta: 'tasca--alta',
  mitjana: 'tasca--mitjana',
  baixa: 'tasca--baixa'
});

/**
 * Aboca les dades d'una Tasca del model al seu <li> de la pàgina.
 * @param {HTMLElement} li  el <li> amb data-id
 * @param {Tasca} tasca     la tasca del model
 */
export function pintarTasca(li, tasca, avui = AVUI) {
  // 1 · Text, sempre amb textContent (mai innerHTML amb dades)
  li.querySelector('.tasca__titol').textContent = tasca.titol;
  li.querySelector('.tasca__meta').textContent =
    `${marcaEstat(tasca.estat)} ${tasca.responsable ?? 'sense assignar'} · ` +
    `${tasca.horesEstimades} h · ${tasca.estat}`;

  // 2 · Prioritat: traiem les tres variants i posem la que toca
  li.classList.remove(...Object.values(CLASSES_PRIORITAT));
  li.classList.add(CLASSES_PRIORITAT[tasca.prioritat] ?? 'tasca--mitjana');

  // 3 · Estats booleans: toggle amb segon argument
  li.classList.toggle('tasca--feta', tasca.estat === 'feta');
  li.classList.toggle('tasca--vencuda', tasca.estaVencuda(avui));

  // 4 · Accessibilitat i dades de suport
  li.dataset.estat = tasca.estat;
  li.dataset.responsable = tasca.responsable ?? '';
  li.setAttribute('aria-label',
    `${tasca.titol}, prioritat ${tasca.prioritat}, ${tasca.estat}` +
    (tasca.estaVencuda(avui) ? ', vençuda' : ''));
}

/** Recorre els <li> presents a la pàgina i els pinta des del tauler. */
export function pintarLlista(contenidor, tauler, avui = AVUI) {
  for (const li of contenidor.querySelectorAll('li[data-id]')) {
    const tasca = tauler.cercarPerId(Number(li.dataset.id));
    if (tasca === null) {           // guarda: l'HTML podria referir-se a un id inexistent
      console.warn(`No existeix la tasca ${li.dataset.id} al model.`);
      continue;
    }
    pintarTasca(li, tasca, avui);
  }
}

/** Actualitza el paràgraf de resum amb els números del tauler. */
export function pintarResum(paragraf, tauler, avui = AVUI) {
  const { total, obertes, horesObertes, vencudes, esforc } = tauler.resum(avui);
  paragraf.textContent =
    `${total} tasques · ${obertes} obertes · ${horesObertes} h pendents · ` +
    `${vencudes} vençuda${vencudes === 1 ? '' : 'es'} · esforç ${esforc}`;
}

I el punt d'entrada, que es queda en quatre línies:

// js/app.js
import { Tauler } from './model/tauler.js';
import { crearBacklog } from './dades/backlog.js';
import { AVUI } from './util/dates.js';
import { pintarLlista, pintarResum } from './vista/pintar.js';

const tauler = new Tauler('Taller Nómada', crearBacklog());

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

pintarLlista(llista, tauler, AVUI);
pintarResum(resum, tauler, AVUI);

A la pantalla veus ara tres tasques amb la seva franja de color segons la prioritat, la de la fusteria marcada com a vençuda, i el resum:

6 tasques · 5 obertes · 45 h pendents · 1 vençuda · esforç 124

Els números canònics de Taller Nómada, per primera vegada fora de la consola. Val la pena assenyalar tres decisions de disseny d'aquest codi, perquè marquen l'estil de la resta del mòdul:

  • La vista llegeix del model, mai a l'inrevés. pintarTasca rep una Tasca i escriu al DOM. Tasca continua sense saber que existeix una pàgina.
  • Res d'innerHTML. Tots els textos entren per textContent, així que un títol amb < o & no pot trencar ni comprometre res.
  • La classe es calcula, no s'acumula. El remove(...Object.values(...)) abans de l'add garanteix que cridar dues vegades pintarTasca amb prioritats diferents deixa l'element net. Si només hi fessis add, un <li> acabaria amb tasca--alta i tasca--baixa alhora.

El que continua faltant és evident: la llista segueix tenint tres <li> escrits a mà d'un backlog de sis, i no passa absolutament res quan hi fas clic. Totes dues coses es resolen a les lliçons següents.

Errors Habituals i Consells

  • getElementById('#llista') amb coixinet. Retorna null en silenci. getElementById rep un id pelat; querySelector rep un selector amb #. Si t'hi emboliques, fes servir sempre querySelector.
  • No comprovar el null. document.querySelector('#no-existeix').textContent = 'x' llança TypeError: Cannot set properties of null. Aquell error, quan apareix tot just carregar la pàgina, gairebé sempre significa que l'id està mal escrit o que l'element encara no existeix.
  • Fer servir innerHTML amb dades d'usuari. És el forat de seguretat més comú en aplicacions web. textContent per defecte, i punt. Si necessites estructura, crea-la amb nodes (06-05).
  • setAttribute('disabled', 'false'). Deixa l'element desactivat, perquè els atributs booleans actuen per la seva presència. Fes servir la propietat: boto.disabled = false.
  • Oblidar que dataset retorna strings. li.dataset.horesEstimades + 1 dona '51'. Converteix amb Number() a la frontera entre el DOM i el model, sempre.
  • Esclafar className. li.className = 'tasca--feta' esborra totes les altres classes. Fes servir classList.add/remove/toggle.
  • Repetir la mateixa cerca dins d'un bucle. document.querySelector('#llista') dins d'un forEach que corre mil vegades busca mil vegades. Desa el resultat en una constant fora del bucle.
  • Consell: anomena les constants de selecció amb criteri. Una convenció molt estesa és prefixar els elements del DOM: const $llista = document.querySelector('#llista-tasques'). No és obligatori, però deixa clar d'un cop d'ull quines variables són nodes.
  • Consell: closest() és el teu amic. Cada vegada que et sorprenguis escrivint .parentElement.parentElement, atura't i fes servir closest('.elquesigui'). El codi resultant és més curt i no es trenca en canviar la maquetació.
  • Consell: agrupa lectures i escriptures. Llegir offsetWidth o getBoundingClientRect() just després d'escriure força un recàlcul del disseny. Primer mesura-ho tot, després escriu-ho tot.

Exercicis

Exercici 1 · Un inventari de la pàgina

Escriu una funció inventari() que retorni un objecte amb: el nombre total d'elements amb classe tasca, quantes tenen prioritat alta, quantes estan vençudes i l'array d'id (com a números) de les tasques obertes. Fes servir querySelectorAll, conversió a array i els mètodes de 04-05. No accedeixis al model: tota la informació ha de sortir del DOM ja pintat.

Exercici 2 · Ressaltar per responsable

Escriu ressaltarResponsable(nom) que afegeixi la classe destacada als <li> el data-responsable dels quals coincideixi amb nom, i la tregui de tots els altres. Ha de funcionar cridant-la diverses vegades seguides amb noms diferents sense deixar rastres. Afegeix al CSS la regla necessària perquè destacada es noti. Resol-ho de dues maneres: amb classList.toggle i amb matches.

Exercici 3 · D'atributs a model, i tornada

Donat el <li> de la fusteria, escriu un fragment que: (a) llegeixi el seu data-id, (b) recuperi la Tasca del tauler, (c) la marqui com a en-curs fent servir el mètode del model, i (d) torni a pintar només aquell <li>. Comprova al DOM que ha canviat la classe, el data-estat i l'aria-label, i que la classe tasca--vencuda continua posada. Explica per què continua posada.

Solucions

Exercici 1

function inventari() {
  const items = [...document.querySelectorAll('.tasca')];

  return {
    total: items.length,
    altes: items.filter((li) => li.matches('.tasca--alta')).length,
    vencudes: items.filter((li) => li.classList.contains('tasca--vencuda')).length,
    idsOberts: items
      .filter((li) => li.dataset.estat !== 'feta')
      .map((li) => Number(li.dataset.id))
  };
}

console.log(inventari());
// { total: 3, altes: 3, vencudes: 1, idsOberts: [1, 3, 6] }

Claus: el spread converteix la NodeList en array per poder encadenar filter i map; matches('.tasca--alta') i classList.contains('tasca--vencuda') són equivalents aquí, i fer servir tots dos ensenya que matches accepta selectors complets mentre que contains només admet un nom de classe; i el Number(li.dataset.id) és imprescindible, perquè sense ell obtindries ['1', '3', '6'].

Exercici 2

// Versió A · amb toggle i el seu segon argument
function ressaltarResponsable(nom) {
  for (const li of document.querySelectorAll('.tasca')) {
    li.classList.toggle('destacada', li.dataset.responsable === nom);
  }
}

// Versió B · amb matches i un selector d'atribut
function ressaltarResponsableB(nom) {
  document.querySelectorAll('.tasca')
    .forEach((li) => li.classList.remove('destacada'));
  document.querySelectorAll(`.tasca[data-responsable="${nom}"]`)
    .forEach((li) => li.classList.add('destacada'));
}

ressaltarResponsable('Iván');    // destaca les de l'Iván
ressaltarResponsable('Lucía');   // treu les de l'Iván i destaca les de la Lucía
.destacada { background: #fffbeb; }

La versió A és la millor: una sola passada i el toggle amb segon argument fa innecessari netejar abans. La versió B recorre dues vegades i, a més, interpola un valor dins d'un selector; si el nom portés cometes o caràcters especials, el selector podria ser invàlid. Si algun dia necessites construir un selector amb dades, fes servir CSS.escape(nom).

Exercici 3

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

const li = document.querySelector('[data-id="6"]');
const tasca = tauler.cercarPerId(Number(li.dataset.id));   // (a) i (b)

tasca.canviarEstat('en-curs');                             // (c) regla R6 del model
pintarTasca(li, tasca);                                    // (d)

console.log(li.className);              // 'tasca tasca--alta tasca--vencuda'
console.log(li.dataset.estat);          // 'en-curs'
console.log(li.getAttribute('aria-label'));
// 'Pressupost de la fusteria, prioritat alta, en-curs, vençuda'

tasca--vencuda continua posada perquè la regla R10 del model defineix «vençuda» com a data límit passada i tasca sense acabar. La data de la fusteria és 2026-09-05, anterior a AVUI = '2026-09-20', i 'en-curs' no és 'feta': la tasca continua vençuda. Només desapareixeria la classe en passar a 'feta'. Fixa't que la vista no ha hagut de saber res d'aquella regla: s'ha limitat a preguntar tasca.estaVencuda(avui). La regla de negoci viu al model, la decoració a la vista, i aquella frontera és la que fa que aquest codi es pugui provar sense navegador al Mòdul 8.

Conclusió

Ja saps trobar qualsevol cosa a la pàgina i canviar-la. Per seleccionar, l'idioma són els selectors CSS i les eines són querySelector (el primer que coincideixi, o null) i querySelectorAll (una NodeList estàtica, possiblement buida), amb getElementById com a opció ràpida per al contenidor arrel i els dos getElementsBy… relegats a codi heretat per les seves col·leccions vives. Saps que tots dos mètodes existeixen també als elements, cosa que et permet acotar la cerca a un subarbre; que :scope resol el cas dels fills directes; i que closest() puja fins al primer avantpassat que compleixi un selector mentre que matches() respon sí o no sobre un element concret. Aquell closest() és, de llarg, el mètode més important de la lliçó: és la peça sobre la qual es construirà la delegació d'esdeveniments.

Per manipular, tens les regles de criteri: textContent com a manera per defecte de llegir i escriure text —barata i segura—, innerText només quan de debò necessitis el visible malgrat el seu cost de recàlcul, i innerHTML sota sospita permanent pel risc d'XSS i perquè destrueix els nodes interiors. Distingeixes atributs (el valor inicial del marcatge) de propietats (el valor actual en memòria), saps que per a value, checked i disabled manen les propietats, i que setAttribute('disabled', 'false') desactiva en lloc d'activar. Manegues dataset per als data-*, amb l'advertència que tot arriba com a string i amb el criteri de guardar només l'id al DOM i deixar la resta de la veritat al model. Fas servir classList amb els seus add, remove, toggle (amb segon argument!), contains i replace en lloc d'esclafar className, i reserves style per als valors que només existeixen en temps d'execució, preferiblement a través de variables CSS. I coneixes getBoundingClientRect, offsetWidth i companyia juntament amb el seu perill: intercalar lectures i escriptures força recàlculs de disseny.

A Nómada Tasques això s'ha traduït en js/vista/pintar.js amb pintarTasca, pintarLlista i pintarResum: la primera capa de vista real del projecte, que pren les Tasca del Tauler i les aboca als <li> de la pàgina amb les seves classes de prioritat, el seu ratllat, el seu avís de vençuda i el seu aria-label, mostrant a la pantalla els 6 elements, les 45 h obertes i l'esforç 124 de sempre.

Però la pàgina continua sent un cartell: es veu, i no respon. Si la Marta fa clic en una tasca no passa res; si la Lucía vol veure només el que és seu, no hi ha manera de demanar-ho. Una aplicació és, per definició, alguna cosa que reacciona al que fa la persona que la fa servir, i per a això cal l'altre gran pilar del DOM: els esdeveniments. Com es registra un gestor, quina informació porta l'objecte Event, per què addEventListener és l'única forma acceptable de les tres que existeixen, i per què un <div> amb un click deixa fora qui navega amb teclat, és Gestió d'Esdeveniments, on la Marta podrà marcar per fi una tasca com a feta amb un clic.

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