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
- Els selectors CSS com a idioma comú
- Els mètodes clàssics:
getElementByIdi companyia querySelectoriquerySelectorAll- Taula comparativa dels mètodes de selecció
- Buscar dins d'un element: l'àmbit de la cerca
closest()imatches()- De
NodeLista array textContent,innerTextiinnerHTML- Atributs davant de propietats
dataseti els atributsdata-*- Classes amb
classList - Estils en línia amb
style(i per què gairebé mai) - Llegir geometria:
getBoundingClientRectioffsetWidth - Nómada Tasques: pintar estat i prioritat
- Errors Habituals i Consells
- Exercicis
- Conclusió
- 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.
- Els mètodes clàssics:
getElementById i companyia
getElementById i companyiaAbans 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:
getElementByIdno porta#i els altres dos no porten.: reben noms, no selectors. Confondre-ho és un error clàssic que retornanullsense 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 unforclàssic i alhora hi afegeixes elements, entres en un bucle infinit. - Si no hi ha coincidències,
getElementByIdretornanulli els altres retornen una col·lecció buida, nonull. 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.
querySelector i querySelectorAll
querySelector i querySelectorAllAquests 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); // 3Quatre 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:
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).
- 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 |
Sí | Sí | Gairebé mai; heretat |
getElementsByTagName(t) |
Un nom d'etiqueta | HTMLCollection |
Sí | Sí | Gairebé mai; heretat |
querySelector(sel) |
Selector CSS | Element o null |
— | Sí | Opció per defecte per a un element |
querySelectorAll(sel) |
Selector CSS | NodeList estàtica |
No | Sí | 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.
- 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àlidUn selector no pot començar per un combinador. Si vols només els fills directes des del mateix element, fes servir :scope:
A la pràctica, per als fills directes sol bastar llista.children, que ja coneixes i és més ràpid.
closest() i matches()
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 sobreCompara 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'));
- De
NodeList a array
NodeList a arrayRecordatori 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
);
textContent, innerText i innerHTML
textContent, innerText i innerHTMLTres 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 | Sí |
innerText |
Només el text visible, amb l'espaiat ja normalitzat | Text pla (respecta salts de línia) | Alt: obliga a recalcular el disseny | Sí |
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:
textContentper defecte.innerHTMLnomés amb marcatge que hagis escrit tu, literal i sense interpolar dades externes. Si necessites inserir estructura, crea-la ambcreateElement(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.
- 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 ACTUALLa 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 atributsI 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é correcteCriteri 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—.
dataset i els atributs data-*
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'eliminaLa conversió de noms funciona en tots dos sentits: data-data-limit ↔ dataset.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()); // trueAquell 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.
- Classes amb
classList
classListclassList é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.
- Estils en línia amb
style (i per què gairebé mai)
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 setPropertyUn 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 realgetComputedStyle(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 | Sí | 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}%`);Així el com es veu continua vivint al CSS i el JavaScript només hi aporta la dada.
- Llegir geometria:
getBoundingClientRect i offsetWidth
getBoundingClientRect i offsetWidthDe 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 contingutDiferè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 escripturesAquest 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.
- 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:
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.
pintarTascarep unaTascai escriu al DOM.Tascacontinua sense saber que existeix una pàgina. - Res d'
innerHTML. Tots els textos entren pertextContent, 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'addgaranteix que cridar dues vegadespintarTascaamb prioritats diferents deixa l'element net. Si només hi fessisadd, un<li>acabaria ambtasca--altaitasca--baixaalhora.
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. Retornanullen silenci.getElementByIdrep un id pelat;querySelectorrep un selector amb#. Si t'hi emboliques, fes servir semprequerySelector.- No comprovar el
null.document.querySelector('#no-existeix').textContent = 'x'llançaTypeError: 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
innerHTMLamb dades d'usuari. És el forat de seguretat més comú en aplicacions web.textContentper 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
datasetretorna strings.li.dataset.horesEstimades + 1dona'51'. Converteix ambNumber()a la frontera entre el DOM i el model, sempre. - Esclafar
className.li.className = 'tasca--feta'esborra totes les altres classes. Fes servirclassList.add/remove/toggle. - Repetir la mateixa cerca dins d'un bucle.
document.querySelector('#llista')dins d'unforEachque 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 servirclosest('.elquesigui'). El codi resultant és més curt i no es trenca en canviar la maquetació. - Consell: agrupa lectures i escriptures. Llegir
offsetWidthogetBoundingClientRect()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íaLa 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
- Què és JavaScript?
- Configuració del teu Entorn de Desenvolupament
- El teu Primer Programa en JavaScript
- Sintaxi i Conceptes Bàsics de JavaScript
- Variables i Tipus de Dades
- Operadors Bàsics
- Conversió de Tipus i Comparacions
- El Projecte del Curs: Nómada Tasques
Mòdul 2: Estructures de Control
- Sentències Condicionals
- Bucles: for, while, do-while
- Sentències Switch
- Control del Flux: break, continue i Bucles Imbricats
- Gestió d'Errors amb try-catch
Mòdul 3: Funcions
- Definició i Crida de Funcions
- Expressions de Funció i Funcions Fletxa
- Paràmetres i Valors de Retorn
- Àmbit i Closures
- Hoisting i el Context d'Execució
- Funcions d'Ordre Superior
- Recursivitat
Mòdul 4: Objectes i Arrays
- Introducció als Objectes
- Mètodes d'Objecte i la Paraula Clau
this - Arrays: Conceptes Bàsics i Mètodes
- Iteració sobre Arrays
- Cercar, Ordenar i Agregar Dades: find, sort i reduce
- Desestructuració d'Arrays
- Desestructuració d'Objectes, Spread i Rest
- JSON i Còpies d'Objectes
Mòdul 5: Objectes i Funcions Avançades
- Prototips i Herència
- Classes i Programació Orientada a Objectes
- Encapsulació: Getters, Setters i Camps Privats
- Mòduls i Importació/Exportació
- JavaScript Asíncron: Callbacks
- Promeses i Async/Await
- El Bucle d'Esdeveniments i la Cua de Microtasques
- Iteradors i Generadors
Mòdul 6: El Model d'Objectes del Document (DOM)
- Introducció al DOM
- Selecció i Manipulació d'Elements del DOM
- Gestió d'Esdeveniments
- Propagació, Delegació i Esdeveniments Personalitzats
- Creació i Eliminació d'Elements del DOM
- Renderitzat de Llistes i Plantilles HTML
- Gestió i Validació de Formularis
Mòdul 7: APIs del Navegador i Temes Avançats
- Emmagatzematge Local i de Sessió
- Fetch API i AJAX
- Peticions Robustes: Errors, Timeouts i AbortController
- WebSockets
- Service Workers i Aplicacions Web Progressives (PWAs)
- APIs del Navegador Essencials
- Introducció a WebAssembly
Mòdul 8: Proves i Depuració
- Depuració de JavaScript
- Qualitat de Codi: ESLint, Prettier i Convencions
- Proves Unitàries amb Jest
- Dobles de Prova: Mocks, Stubs i Spies
- Proves d'Integració
- Proves d'Extrem a Extrem amb Cypress
Mòdul 9: Rendiment i Optimització
- Mesurar Abans d'Optimitzar: DevTools i Web Vitals
- Optimització del Rendiment de JavaScript
- Gestió de Memòria
- Manipulació Eficient del DOM
- Càrrega Diferida i Divisió de Codi
Mòdul 10: Frameworks i Llibreries de JavaScript
- Per Què Existeixen els Frameworks
- Introducció a React
- Gestió d'Estat amb Redux
- Conceptes Bàsics de Vue.js
- Conceptes Bàsics d'Angular
- Triar el Framework Adequat
