Queden dos fronts de la línia base i aquest és el que domina la taula: la fila 5 continua en 310 ms per render i la fila 9 en 7.812 nodes, amb el Bottom-Up de 09-01 assenyalant que 600 crides a pintarTargeta costen 186 ms de temps propi i que hi ha 41 recàlculs d'estil on n'hi hauria d'haver un. Aquesta feina no és JavaScript lent —ja el vas optimitzar a 09-02— ni memòria retinguda —ja la vas alliberar a 09-03—: és el cost de parlar amb el DOM. Aquesta lliçó explica per què aquest diàleg és car: el pipeline de renderitzat del navegador i quines operacions desperten cadascuna de les seves fases; què és exactament un reflow i què el força de manera síncrona, tancant l'avís que va quedar obert a 06-02; com es reconeix i s'elimina el layout thrashing, que és d'on surten aquells 41 recàlculs; quant aporta de debò agrupar insercions amb DocumentFragment i replaceChildren (06-05) i actualitzar amb reconciliar en lloc de redibuixar (06-06), amb una valoració honesta de cada cosa; per què només transform i opacity s'animen de franc; què fan will-change, contain i content-visibility; i, finalment, la virtualització: pintar només les files que es veuen, implementada dues vegades —amb IntersectionObserver i amb una finestra calculada—, amb les seves contrapartides reals damunt la taula. En acabar, les files 5 i 9 estaran tancades.

Contingut

  1. Per què el DOM és car
  2. El pipeline de renderitzat, fase a fase
  3. Quina operació desperta quina fase
  4. Reflow, repaint i el càlcul d'estil síncron forçat
  5. La llista negra: quines lectures forcen un reflow
  6. Layout thrashing: el bucle que alterna lectura i escriptura
  7. El patró read-then-write, mesurat sobre les 600 targetes
  8. Agrupar insercions: DocumentFragment i replaceChildren
  9. Actualitzar en lloc de redibuixar: què aporta de debò reconciliar
  10. Escriure només el que canvia
  11. Animar barat: transform i opacity
  12. Aïllar la feina: will-change, contain i content-visibility
  13. Sincronitzar amb requestAnimationFrame (i no amb setTimeout)
  14. Virtualització I: sentinelles amb IntersectionObserver
  15. Virtualització II: la finestra calculada amb alçada fixa
  16. Les contrapartides de virtualitzar
  17. Delegació d'esdeveniments, ara amb números
  18. Les files 5 i 9, tancades
  19. Símptoma → causa probable → solució
  20. Errors Habituals i Consells
  21. Exercicis
  22. Conclusió

  1. Per què el DOM és car

Hi ha una frase que es repeteix molt i que, dita així, és falsa: «el DOM és lent». El DOM no és lent; és una altra cosa. I confondre les dues idees porta a optimitzacions equivocades.

Quan escrius li.textContent = 'Redissenyar la sala polivalent' no estàs assignant una propietat d'un objecte JavaScript. Estàs creuant una frontera: el teu motor de JavaScript (V8) està demanant alguna cosa al motor de renderitzat (Blink), que manté la seva pròpia representació del document en C++, amb els seus propis arbres d'estil i de disseny. Aquesta crida té tres costos diferents que convé separar:

Cost Què és Magnitud típica
Creuar la frontera El salt de JavaScript a la implementació nativa, amb conversió de tipus Nanosegons. Gairebé sempre irrellevant
Invalidar Marcar que l'estil, la geometria o els píxels d'una zona han quedat obsolets Molt barat, però s'acumula
Recalcular Refer de debò l'estil, el disseny o el pintat Mil·lisegons. Aquí hi ha tot

La clau, i és la idea central de la lliçó, és que invalidar i recalcular estan desacoblats. El navegador no recalcula res quan tu escrius: apunta que hi ha feina pendent i continua executant el teu codi. Recalcularà una sola vegada, just abans de pintar el fotograma següent, per moltes escriptures que hagis fet. És un mecanisme d'agrupació automàtica, i és excel·lent.

Tot el que fa lenta la manipulació del DOM consisteix, al capdavall, a trencar aquesta agrupació: obligar el navegador a recalcular enmig del teu codi, una vegada i una altra, quan ho hauria pogut fer una sola vegada al final.

// Això NO provoca 600 recàlculs. Provoca 600 invalidacions i UN recàlcul.
for (const li of targetes) {
  li.classList.add('tasca--ressaltada');
}
// ← aquí, abans del fotograma següent, un únic Recalculate Style
// ✗ Això SÍ que provoca 600 recàlculs, i és 40× més lent
for (const li of targetes) {
  li.classList.add('tasca--ressaltada');
  console.log(li.offsetHeight);        // ← lectura: obliga a recalcular ARA
}

La diferència entre els dos bucles és una sola línia. Entendre per què aquesta línia costa tant exigeix mirar el pipeline.

  1. El pipeline de renderitzat, fase a fase

Per convertir HTML, CSS i JavaScript en píxels, el navegador executa una seqüència fixa de fases. S'anomena pipeline de renderitzat i s'executa, com a molt, una vegada per fotograma.

flowchart LR
    JS["1 · JavaScript<br/>el teu codi muta<br/>el DOM i el CSSOM"] --> E["2 · Estil<br/><i>Recalculate Style</i><br/>quines regles apliquen<br/>a cada element"]
    E --> L["3 · Layout<br/><i>Reflow</i><br/>on va cada caixa<br/>i quant fa"]
    L --> P["4 · Pintat<br/><i>Paint</i><br/>omplir píxels<br/>a cada capa"]
    P --> C["5 · Composició<br/><i>Composite</i><br/>la GPU apila<br/>les capes"]
    C --> F(["Fotograma<br/>a la pantalla"])

Què fa exactament cada fase:

1 · JavaScript. El teu codi s'executa i modifica l'arbre del DOM, les classes, els estils en línia o el contingut. També poden desencadenar canvis el CSS (una animació, un :hover) o la Web Animations API, sense que hi hagi JavaScript pel mig.

2 · Estil (Recalculate Style). El navegador decideix, per a cada element afectat, quines regles CSS li apliquen i quin és el valor final de cada propietat. El cost creix amb el nombre d'elements invalidats i amb la complexitat dels selectors. A DevTools apareix en lila.

3 · Layout (també reflow). Amb els estils ja resolts, el navegador calcula la geometria: posició i mida de cada caixa. És la fase més cara perquè és global per naturalesa: canviar l'amplada d'un element pot desplaçar tots els seus germans, el seu pare i mitja pàgina. També en lila.

4 · Pintat (Paint). S'omplen els píxels: fons, textos, vores, ombres. No es dibuixa directament a la pantalla, sinó en una o diverses capes (layers). A DevTools, verd.

5 · Composició (Composite). Les capes s'apilen en l'ordre correcte i s'envien a la pantalla. Aquesta fase la fa la GPU i és baratíssima. També verd.

I aquí hi ha l'observació que governa la meitat de les decisions d'aquesta lliçó: al pipeline s'hi pot entrar pel mig. No tots els canvis obliguen a recórrer les cinc fases.

flowchart TD
    A["Canvies width, top, font-size…"] --> B["Estil → Layout → Pintat → Composició"]
    C["Canvies color, background-color,<br/>box-shadow…"] --> D["Estil → Pintat → Composició<br/><b>(se salta Layout)</b>"]
    E["Canvies transform, opacity"] --> F["Estil → Composició<br/><b>(se salta Layout i Pintat)</b>"]
    style B fill:#fdd,stroke:#c00
    style D fill:#ffd,stroke:#c90
    style F fill:#dfd,stroke:#090

Com menys fases entrin en joc, més barat és el canvi. Aquesta és tota la teoria de l'optimització visual, i d'ella se'n deriven les regles pràctiques dels apartats 11 i 12.

  1. Quina operació desperta quina fase

Traduït a operacions concretes del teu codi, la taula queda així. Val la pena tenir-la a mà.

El que fas Estil Layout Pintat Composició Cost relatiu
element.remove() / append() Alt
classList.add() que canvia la mida Alt
style.width / style.top / style.margin Alt
textContent = un altre text Alt
style.fontSize Alt
classList.add() que només canvia el color Mitjà
style.backgroundColor / boxShadow Mitjà
style.visibility Mitjà
style.transform / style.opacity Baix
Escriure en un node fora de l'arbre Gairebé zero
dataset.x = ... sense selector CSS que el faci servir Baix

Tres conseqüències que ja es poden aplicar a Nómada Tasques avui mateix:

  • Canviar el text d'una targeta és car, perquè el text pot canviar d'amplada i això és geometria. Actualitzar .tasca__meta en 600 targetes quan només n'han canviat dues és feina llençada.
  • Construir fora de l'arbre és de franc. Tot el que facis sobre un <li> acabat de crear i encara no inserit no costa res: ni estil, ni layout, ni pintat. És l'argument de fons de DocumentFragment (06-05).
  • display: none i visibility: hidden no són el mateix. El primer treu l'element del layout (i el canvi és car, però després aquell element deixa de costar); el segon el manté ocupant lloc i només evita el pintat.

  1. Reflow, repaint i el càlcul d'estil síncron forçat

Fixem el vocabulari, perquè es fa servir malament constantment:

  • Reflow (o layout): recalcular la geometria. És la fase 3.
  • Repaint: tornar a omplir píxels sense recalcular geometria. És la fase 4 sense la 3.
  • Càlcul d'estil síncron forçat (forced synchronous layout): que el navegador hagi d'executar les fases 2 i 3 enmig del teu JavaScript, perquè li has demanat una dada que només et pot donar si estan al dia.

El tercer és el que ens interessa. Torna al mecanisme de l'apartat 1: quan escrius, el navegador només invalida. Però quan llegeixes una propietat geomètrica, el navegador té un contracte per complir: t'ha de retornar el valor correcte en aquell instant. Si hi ha invalidacions pendents, no li queda altre remei que aturar-ho tot, recalcular estil i disseny, i llavors respondre't.

const li = $('[data-id="3"]');

li.style.width = '400px';            // 1 · invalida. No recalcula res. Barat
console.log(li.offsetWidth);         // 2 · LLEGEIX geometria → recàlcul forçat AQUÍ, síncron

DevTools ho assenyala de manera explícita: al diagrama de flames apareix un bloc lila dins del teu bloc groc, amb un triangle d'avís i el text «Forced reflow is a likely performance bottleneck». Si el veus, tens un problema de l'apartat 6.

Un matís important que estalvia falsos diagnòstics: un recàlcul forçat aïllat no és cap problema. Mesurar una vegada l'alçada d'una columna per col·locar un menú costa uns 1–2 ms al portàtil de referència amb CPU 4×, i no ho notaràs. El problema apareix quan aquest recàlcul s'executa dins d'un bucle, perquè cada volta invalida el que la volta anterior acabava de calcular. Això té nom propi, i és l'apartat 6.

  1. La llista negra: quines lectures forcen un reflow

Aquesta és la llista que tanca l'avís de 06-02. No cal memoritzar-la sencera; cal reconèixer la família: tot el que retorna una mesura, una posició o un estil ja resolt.

Categoria Membres Notes
Geometria de l'element offsetTop, offsetLeft, offsetWidth, offsetHeight, offsetParent Enters arrodonits
Geometria del client clientTop, clientLeft, clientWidth, clientHeight Sense vores ni barres
Desplaçament scrollTop, scrollLeft, scrollWidth, scrollHeight Escriure scrollTop també força
Rectangles getBoundingClientRect(), getClientRects() Decimals, inclou transform
Estil resolt getComputedStyle(el) i llegir qualsevol de les seves propietats El més traïdor
Text renderitzat innerText textContent no força; innerText
Focus i desplaçament focus(), scrollIntoView(), scrollBy(), scrollTo() Necessiten saber on és l'element
Finestra window.scrollY, innerWidth, innerHeight, getComputedStyle
Altres element.checkVisibility(), Range.getBoundingClientRect()

Tres avisos que gairebé ningú no té presents:

getComputedStyle és pitjor del que sembla. No força el reflow en cridar-lo, sinó en llegir una propietat de l'objecte retornat, i força tant el recàlcul d'estil com el de disseny si la propietat depèn de la geometria. Pitjor encara: a la pràctica és impossible predir quines propietats ho necessiten, així que la regla segura és tractar-lo sencer com una lectura cara.

focus() és una lectura disfressada d'escriptura. El navegador necessita saber si l'element és visible i on és per poder desplaçar-s'hi. A 06-06 es restaurava el focus després del render; si això passa enmig d'un bucle d'escriptures, tens un recàlcul forçat per volta.

textContent no força; innerText sí. innerText retorna el text tal com es veu, així que necessita saber què està amagat pel CSS: això és estil, i de vegades disseny. textContent retorna el text de l'arbre i no consulta res. És un motiu més, a banda dels de 06-02, per fer servir textContent per defecte.

  1. Layout thrashing: el bucle que alterna lectura i escriptura

Ja tenim el mecanisme. Anem al cas real de Nómada Tasques, que és d'on surten els 41 recàlculs d'estil i els 38 layout del Bottom-Up de 09-01.

Al tauler de 600 tasques hi ha 40 vençudes. La targeta d'una tasca vençuda porta una insígnia amb el text «vençuda fa N dies», i si el títol és llarg la insígnia surt de la caixa. La solució que s'hi va posar en el seu moment va ser mesurar el títol i, si no hi cabia, reduir la targeta a una línia. Aquest és el codi, tal qual, a la TaulerVista de 09-03:

// ✗ js/vista/tauler-vista.js — el bucle tòxic
#ajustarAlcades(visibles) {
  for (const tasca of visibles) {
    if (!tasca.estaVencuda(this.#estat.avui)) continue;     // només les 40 vençudes

    const li = this.#nodesPerId.get(tasca.id);
    if (li === undefined) continue;

    const alt = li.querySelector('.tasca__titol').offsetHeight;     // ← LECTURA
    li.style.setProperty('--alt-titol', `${alt}px`);                // ← ESCRIPTURA
    li.classList.toggle('tasca--titol-llarg', alt > 22);            // ← ESCRIPTURA
  }
}

Mira-t'ho amb el model de l'apartat 4. Volta 1: es llegeix offsetHeight, el navegador recalcula (no hi ha res pendent la primera vegada, així que és barat) i respon. Després s'escriu una variable CSS i es canvia una classe: invalidat. Volta 2: es llegeix offsetHeight un altre cop, però ara sí que hi ha feina pendent, així que el navegador ha de recalcular estil i disseny de tot el subarbre invalidat abans de respondre. I així quaranta vegades.

flowchart TD
    subgraph MAL["✗ Alternant: 40 recàlculs"]
        direction TB
        R1["llegir offsetHeight"] --> W1["escriure classe"]
        W1 -->|"invalidat"| R2["llegir offsetHeight<br/><b>→ recàlcul forçat</b>"]
        R2 --> W2["escriure classe"]
        W2 -->|"invalidat"| R3["llegir offsetHeight<br/><b>→ recàlcul forçat</b>"]
        R3 --> D1["… × 40"]
    end
    subgraph BE["✓ Agrupant: 1 recàlcul"]
        direction TB
        RR["llegir les 40 alçades<br/><b>→ 1 recàlcul</b>"] --> WW["escriure les 40 classes"]
        WW --> FF["el navegador recalcula<br/>una vegada, abans de pintar"]
    end
    style R2 fill:#fdd,stroke:#c00
    style R3 fill:#fdd,stroke:#c00
    style RR fill:#dfd,stroke:#090

Quaranta lectures forçades, més una del scrollTop que es guarda per conservar el desplaçament (06-06): 41 recàlculs d'estil. I 38 layout, tres menys perquè en tres voltes l'escriptura anterior no va arribar a invalidar la geometria. Els números del Bottom-Up encaixen exactament:

Entrada del Bottom-Up Aparicions Temps Per aparició
Recalculate Style 41 74 ms 1,80 ms
Layout 38 63 ms 1,66 ms
Total del thrashing 137 ms dels 310 ms del render

Gairebé la meitat del render se'n va en quaranta lectures d'offsetHeight. I el més important: no és un problema de quant codi executes, sinó de en quin ordre l'executes. La mateixa quantitat de feina, reordenada, costa una fracció.

Aquest és el patró que a 06-02 es va anomenar layout thrashing i es va remetre aquí. Ja saps reconèixer-lo: una lectura de la llista negra de l'apartat 5 dins d'un bucle que també escriu.

  1. El patró read-then-write, mesurat sobre les 600 targetes

La solució no és llegir menys ni escriure menys: és separar les dues fases. Primer totes les lectures, després totes les escriptures. El navegador recalcula una vegada al principi del bloc de lectures i una vegada, pel seu compte, abans de pintar.

// ✓ js/vista/tauler-vista.js — read-then-write
#ajustarAlcades(visibles) {
  const vencudes = visibles.filter((t) => t.estaVencuda(this.#estat.avui));

  // ── FASE 1 · LLEGIR. Cap escriptura aquí dins. Un sol recàlcul forçat ──
  const mesures = vencudes.map((tasca) => {
    const li = this.#nodesPerId.get(tasca.id);
    return li === undefined
      ? null
      : { li, alt: li.querySelector('.tasca__titol').offsetHeight };
  }).filter(Boolean);

  // ── FASE 2 · ESCRIURE. Cap lectura aquí dins. Només invalidacions ──
  for (const { li, alt } of mesures) {
    li.style.setProperty('--alt-titol', `${alt}px`);
    li.classList.toggle('tasca--titol-llarg', alt > 22);
  }
}

El canvi és purament estructural: mateixes lectures, mateixes escriptures, mateix resultat visual. El mesurament, amb banc() de 09-01 (5 d'escalfament, 15 repeticions, CPU 4×, 600 tasques de les quals 40 vençudes):

Variant Recàlculs forçats Recalculate Style Layout #ajustarAlcades
Alternant lectura i escriptura 41 74 ms 63 ms 141 ms
Read-then-write 1 2,4 ms 1,7 ms 4,3 ms

Trenta-tres vegades més ràpid sense canviar ni una sola operació. I l'efecte sobre la fila 5:

Mesura Abans Després
render() complet, 600 tasques 310 ms 179 ms

De 310 a 179 ms amb una reordenació. És, de bon tros, la millor relació entre benefici i esforç de tota la lliçó.

Un advertiment honest sobre aquest patró: és fràgil. Res no impedeix que d'aquí a sis mesos algú fiqui una lectura a la fase d'escriptura, i el rendiment s'esfondrarà sense que cap prova falli. Hi ha tres defenses raonables:

Primera: un comentari que expliqui la raó, no el que fa el codi. // FASE 1 · LLEGIR. Cap escriptura aquí dins. és exactament això.

Segona: encapsular el patró perquè la separació no depengui de la disciplina de qui hi editi.

// js/vista/dom.js

/**
 * Executa primer TOTES les lectures i després TOTES les escriptures,
 * perquè el navegador faci com a molt un recàlcul forçat.
 *
 * @param {Function} llegir    Ha de retornar les dades mesurades. No ha d'escriure.
 * @param {Function} escriure  Rep el que ha retornat `llegir`. No ha de llegir geometria.
 */
export function llegirIEscriure(llegir, escriure) {
  const mesures = llegir();
  escriure(mesures);
  return mesures;
}

Tercera: una prova que compti els recàlculs. No es pot comptar directament des de Jest amb jsdom (que no fa layout), però sí des de Cypress o Puppeteer, observant les entrades de rendiment del navegador. És matèria de l'apartat 20, als consells.

  1. Agrupar insercions: DocumentFragment i replaceChildren

A 06-05 vas aprendre DocumentFragment amb una promesa: «el mesurament seriós és el tema de 09-04». Toca complir-la, i la resposta serà menys espectacular del que la majoria dels tutorials suggereixen.

L'argument clàssic és que inserir 600 elements un a un provoca 600 reflows. Això era cert el 2008 i avui és fals, pel mecanisme de l'apartat 1: el navegador invalida i agrupa. Mesurem-ho, amb les tres variants, sobre el pintat inicial de les 600 targetes:

// A · Inserció directa, una a una
for (const tasca of visibles) llista.append(pintarTargeta(tasca, null, avui));

// B · DocumentFragment
const fragment = document.createDocumentFragment();
for (const tasca of visibles) fragment.append(pintarTargeta(tasca, null, avui));
llista.append(fragment);

// C · replaceChildren amb spread (la forma adoptada a 06-05)
llista.replaceChildren(...visibles.map((t) => pintarTargeta(t, null, avui)));

// D · Inserció directa AMB una lectura de geometria pel mig
for (const tasca of visibles) {
  llista.append(pintarTargeta(tasca, null, avui));
  llista.scrollHeight;                   // ← una sola línia ho canvia tot
}
Variant 600 targetes Davant d'A
A · append un a un 131 ms
B · DocumentFragment 120 ms 1,09×
C · replaceChildren(...) 119 ms 1,10×
D · append + lectura per volta 2.140 ms 0,06×

Llegeix-ho amb cura, perquè les conclusions són dues i van en direccions diferents.

El fragment aporta un 9 %. Real, mesurable i de franc, però no és el que arregla res. Els navegadors moderns ja agrupen la feina de disseny, així que la diferència entre 600 insercions i una es redueix al cost de les crides mateixes i a algunes invalidacions que es poden fusionar millor. Nou per cent no és menyspreable, però qui es pensi que DocumentFragment és «l'» optimització del DOM està mirant al lloc equivocat.

La lectura al bucle multiplica per setze. La variant D és la mateixa feina amb una línia de més, i costa 2,1 segons. Aquí hi ha l'ordre de magnitud, i és el mateix de l'apartat 6: l'enemic no és tocar el DOM moltes vegades, és intercalar-hi lectures.

Dit tot això, continua havent-hi dos motius sòlids per fer servir replaceChildren(...):

  • Llegibilitat. Una línia que diu «el contingut d'aquesta llista és exactament això» es llegeix millor que un bucle amb un append.
  • Atomicitat visual. Buidar i omplir en dues operacions separades pot deixar, en casos rars amb feina asíncrona pel mig, un fotograma amb la llista buida. replaceChildren no.

I un tercer motiu que sí que és de rendiment pur, tot i que menys conegut: quan el contenidor és fora de l'arbre o dins d'un content-visibility: hidden (apartat 12), tota la feina de construcció és de franc, i allà el fragment sí que és l'eina natural.

Tècnica Benefici real Quan fer-la servir
DocumentFragment ~9 % en insercions massives Construcció complexa fora de l'arbre
replaceChildren(...) ~10 % i molta llegibilitat Reemplaçament complet d'una llista
innerHTML = cadena Ràpid però insegur (06-05) Mai amb dades d'usuari
reconciliar() Depèn del canvi (apartat 9) Actualitzacions parcials
No tocar el DOM 100 % Apartat 10

  1. Actualitzar en lloc de redibuixar: què aporta de debò reconciliar

A 06-06 vas construir reconciliar(contenidor, dades, clau, pintar), que reutilitza els nodes existents en lloc de destruir-los i recrear-los. Es va justificar per correcció: conservar el focus, el desplaçament i les transicions. Ara toca la pregunta de rendiment: quant estalvia?

La resposta honesta és: depèn enterament de quant canviï, i la variabilitat és enorme.

Mesurament sobre les 600 tasques, comparant replaceChildren(...) (recrear-ho tot) amb reconciliar (reutilitzar), en quatre escenaris reals de Nómada Tasques:

Escenari Canvis replaceChildren reconciliar Millora
La Marta marca una tasca com a feta 1 targeta es mou de columna 119 ms 3,1 ms 38×
Arriba un lot de 12 actualitzacions per WebSocket 12 targetes canvien 119 ms 9,4 ms 13×
L'Iván escriu «seri» al cercador queden 47 targetes de 600 119 ms 21 ms 5,7×
La Lucía canvia l'ordre a «per data» 600 targetes es reordenen 119 ms 142 ms 0,84×

Fixa't en l'última fila: reconciliar pot ser més lent. Quan canvia l'ordre de tots els elements, la implementació senzilla de 06-06 fa un insertBefore per cada node —600 moviments a l'arbre viu— més 600 actualitzacions de contingut, i això costa més que construir la llista sencera de zero fora de l'arbre. És exactament el límit que 06-06 va anunciar: «no detecta moviments de manera òptima».

La conclusió pràctica no és «no facis servir reconciliar», sinó una cosa més matisada:

reconciliar guanya de pallissa en el cas freqüent —canvis petits sobre una llista estable— i perd en el cas rar d'una reordenació completa. Com que el cas freqüent és el que domina l'experiència percebuda, continua sent l'elecció correcta. I el cas rar deixa d'importar quan només hi hagi 48 nodes a la pantalla, que és el que aconsegueix la virtualització de l'apartat 15.

Aquest últim matís és important: les optimitzacions interactuen. Amb virtualització, la reordenació completa afecta 48 targetes en lloc de 600, i la fila dolenta de la taula desapareix sola.

  1. Escriure només el que canvia

Tornem a l'entrada que encapçala el Bottom-Up: pintarTargeta, 600 crides, 186 ms de temps propi. Ja no queda thrashing, així que aquest temps és feina real. On se'n va?

Mira la funció de 06-06 amb ulls de rendiment. Per cada targeta fa, sense condicions:

  • 4 escriptures a dataset
  • 1 classList.remove(...) amb tres classes i 3 classList.add/toggle
  • 3 assignacions de textContent
  • 1 replaceChildren de la llista d'etiquetes, que destrueix i recrea els <li> d'etiqueta
  • 2 escriptures de disabled i 2 setAttribute('aria-label', ...)

Són 16 escriptures per targeta, 9.600 en total, per actualitzar una llista en què normalment no ha canviat res. I encara que el navegador agrupi, cada escriptura invalida alguna cosa, i cada invalidació amplia el subarbre que caldrà recalcular després.

La correcció és la més avorrida i la més eficaç de la lliçó: comprovar abans d'escriure.

// js/vista/dom.js

/** Assigna només si el valor és diferent. Evita invalidar sense necessitat. */
export function posarText(node, valor) {
  if (node.textContent !== valor) node.textContent = valor;
}

/** Igual per a atributs, amb el cas d'esborrat inclòs. */
export function posarAtribut(node, nom, valor) {
  if (valor === null || valor === false) {
    if (node.hasAttribute(nom)) node.removeAttribute(nom);
  } else if (node.getAttribute(nom) !== String(valor)) {
    node.setAttribute(nom, valor);
  }
}

/** Igual per a dataset. */
export function posarDada(node, clau, valor) {
  const text = String(valor);
  if (node.dataset[clau] !== text) node.dataset[clau] = text;
}

I pintarTargeta reescrita amb elles, més una sortida primerenca que és la que de debò mana:

// js/vista/targeta.js
import { $ } from './dom.js';
import { posarText, posarAtribut, posarDada } from './dom.js';
import { AVUI } from '../util/dates.js';
import { marcaEstat } from '../util/format.js';

const CLASSES_PRIORITAT = ['tasca--alta', 'tasca--mitjana', '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'
});

/** Empremta del que es veu d'una tasca. Si no canvia, la targeta ja és correcta. */
function empremta(tasca, avui) {
  return `${tasca.estat}|${tasca.prioritat}|${tasca.titol}|${tasca.responsable}` +
         `|${tasca.horesEstimades}|${tasca.etiquetes.join(',')}|${tasca.estaVencuda(avui)}`;
}

export function pintarTargeta(tasca, existent = null, avui = AVUI) {
  const li = existent ?? $('#plantilla-tasca').content.firstElementChild.cloneNode(true);
  const nova = empremta(tasca, avui);

  // ── Sortida primerenca: res visible no ha canviat. Zero escriptures al DOM ──
  if (existent !== null && li.dataset.empremta === nova) return li;
  li.dataset.empremta = nova;

  const vencuda = tasca.estaVencuda(avui);

  posarDada(li, 'id', tasca.id);
  posarDada(li, 'estat', tasca.estat);
  posarDada(li, 'responsable', tasca.responsable ?? '');
  posarDada(li, 'prioritat', tasca.prioritat);

  // classList ja comprova per si mateix: add d'una classe present no invalida
  li.classList.remove(...CLASSES_PRIORITAT);
  li.classList.add(`tasca--${tasca.prioritat}`);
  li.classList.toggle('tasca--feta', tasca.estat === 'feta');
  li.classList.toggle('tasca--vencuda', vencuda);

  posarText($('.tasca__titol', li), tasca.titol);
  posarText($('.tasca__meta', li),
    `${marcaEstat(tasca.estat)} ${tasca.responsable ?? 'sense assignar'} · ` +
    `${tasca.horesEstimades} h` + (vencuda ? ` · vençuda fa ${Math.abs(tasca.diesRestants)} d` : ''));

  // Les etiquetes gairebé mai canvien: reconciliar en lloc de recrear
  const contenidor = $('.tasca__etiquetes', li);
  if (contenidor.dataset.valor !== tasca.etiquetes.join(',')) {
    contenidor.dataset.valor = tasca.etiquetes.join(',');
    contenidor.replaceChildren(...tasca.etiquetes.map((text) => {
      const item = document.createElement('li');
      item.className = 'etiqueta';
      item.textContent = text;
      return item;
    }));
  }

  const avancar = $('[data-accio="avancar"]', li);
  posarText(avancar, ETIQUETA[tasca.estat]);
  avancar.disabled = SEGUENT[tasca.estat] === null;
  posarAtribut(avancar, 'aria-label', `${ETIQUETA[tasca.estat]}: ${tasca.titol}`);

  const reobrir = $('[data-accio="reobrir"]', li);
  reobrir.disabled = tasca.estat !== 'en-curs';
  posarAtribut(reobrir, 'aria-label', `Tornar a pendent: ${tasca.titol}`);

  return li;
}

Tres comentaris sobre aquest codi:

  • L'empremta és la clau. És una cadena barata de construir (uns 0,004 ms) que resumeix tot el que es veu. Comparar-la costa una fracció del que costa escriure setze propietats. És, conceptualment, la mateixa idea que el comptador de versió de 09-02: una manera barata de saber si cal treballar.
  • disabled s'assigna sense comprovar a propòsit: assignar una propietat booleana que ja té aquell valor no invalida res als motors actuals, i la comprovació costaria més que l'estalvi. La regla és protegir el que invalida —text, atributs, dataset amb selectors CSS associats—, no tot per sistema.
  • classList.add d'una classe ja present tampoc no invalida. classList és un DOMTokenList i comprova la pertinença abans de modificar. Aquesta és la raó que a 06-02 s'insistís a fer servir classList en lloc d'esclafar className: a més de més llegible, és més barat.

Mesurament sobre el render complet, després d'aplicar l'apartat 7:

Mesura Després de read-then-write Amb sortida primerenca i escriptura condicional
pintarTargeta, temps propi (600) 149 ms 101 ms
Escriptures al DOM per render sense canvis 9.600 0
render() complet 179 ms 142 ms

I una fila que no és a la línia base però que és la que més es nota en l'ús diari: el render després d'un canvi d'una sola tasca passa de 3,1 ms a 0,8 ms, perquè 599 targetes surten per la porta primerenca.

  1. Animar barat: transform i opacity

Canviem de terreny. Fins aquí hem parlat d'actualitzar contingut; ara, de moure'l. Nómada Tasques anima l'entrada d'una targeta nova i el lliscament d'una targeta en canviar de columna. Així estava feta l'entrada:

/* ✗ css/estils.css — anima geometria: layout a cada fotograma */
.tasca--entrant {
  animation: entrar 240ms ease-out;
}

@keyframes entrar {
  from { height: 0;    margin-top: -12px; opacity: 0; }
  to   { height: 108px; margin-top: 0;    opacity: 1; }
}

Cada fotograma d'aquesta animació canvia height i margin-top, que són a la fila vermella de la taula de l'apartat 3: estil, layout, pintat i composició. I com que el <li> és dins d'un contenidor amb display: grid, aquest layout no afecta només la targeta: recol·loca tota la columna.

La versió correcta expressa el mateix efecte visual amb les dues úniques propietats que la GPU pot animar pel seu compte:

/* ✓ Només transform i opacity: se salta layout i pintat */
.tasca--entrant {
  animation: entrar 240ms cubic-bezier(0.2, 0, 0.2, 1);
}

@keyframes entrar {
  from { transform: translateY(-12px) scaleY(0.96); opacity: 0; }
  to   { transform: none;                           opacity: 1; }
}

/* Accessibilitat: respectar la preferència del sistema (07-06) */
@media (prefers-reduced-motion: reduce) {
  .tasca--entrant { animation: none; }
}

La taula de referència, ampliant la de l'apartat 3 amb les propietats que més s'animen:

Propietat animada Estil Layout Pintat Composició Veredicte
width, height Evita-la
top, left, right, bottom Evita-la
margin, padding Evita-la
font-size, line-height Evita-la
border-width Evita-la
color, background-color Acceptable
box-shadow, border-radius Acceptable, però el pintat d'ombres és car
visibility Acceptable
transform Fes-la servir
opacity Fes-la servir
filter Composta, però la GPU pot patir amb blur grans

Mesurament: animar l'entrada simultània de 24 targetes (el que passa en treure un filtre), gravant amb el carril Frames de DevTools i CPU 4×:

Animació Fotogrames perduts FPS mitjà Feina per fotograma
height + margin-top 38 de 58 22 fps Estil + layout + pintat + composició
transform + opacity 0 de 58 60 fps Només composició

L'explicació de per què la segona surt de franc mereix un paràgraf. Quan una animació afecta només transform i opacity, el navegador pot promoure l'element a la seva pròpia capa de composició: la pinta una vegada, i a cada fotograma es limita a dir-li a la GPU «aquesta capa, desplaçada 8 píxels i amb opacitat 0,7». No hi ha estil per recalcular, no hi ha geometria per refer, no hi ha píxels per omplir. El fil principal ni se n'assabenta: l'animació continua funcionant encara que el teu JavaScript estigui ocupat.

D'aquí una conseqüència pràctica que sorprèn: una animació CSS ben feta sobreviu a una tasca llarga de JavaScript; una feta amb requestAnimationFrame, no. Si pots expressar l'efecte en CSS o amb la Web Animations API (element.animate(...), de 07-06), fes-ho, i reserva requestAnimationFrame per al que exigeix càlcul per fotograma.

I el truc que resol el 90 % dels casos en què «necessito animar la posició i no puc fer servir transform»: la tècnica FLIP (First, Last, Invert, Play). Es mesura la posició inicial, s'aplica el canvi de cop, es mesura la final, i s'anima amb transform des de la diferència fins a zero.

// js/vista/animar.js

/**
 * Anima el moviment d'un node entre dues posicions fent servir només transform.
 * @param {HTMLElement} node
 * @param {Function} canviar  Funció que aplica el canvi de layout de cop.
 */
export function flip(node, canviar) {
  const primera = node.getBoundingClientRect();      // F · una lectura, fora de bucles
  canviar();                                          // L · el canvi real, sense animar
  const ultima = node.getBoundingClientRect();        // L · una lectura més

  const dx = primera.left - ultima.left;              // I · invertir
  const dy = primera.top - ultima.top;
  if (dx === 0 && dy === 0) return null;

  return node.animate(                                // P · reproduir, només composició
    [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
    { duration: 220, easing: 'cubic-bezier(0.2, 0, 0.2, 1)' }
  );
}

Compte amb una cosa: flip fa dues lectures de geometria. Va bé per a un node, i és exactament l'error de l'apartat 6 si el crides dins d'un bucle sobre 600 targetes. Per animar-ne diverses, primer es llegeixen totes les posicions inicials, després s'aplica el canvi, després es llegeixen totes les finals, i només llavors s'anima. Read-then-write un altre cop.

  1. Aïllar la feina: will-change, contain i content-visibility

Aquestes tres propietats CSS són eines de rendiment pur: no canvien l'aspecte de res, sinó quanta feina es pot estalviar el navegador.

12.1 will-change

Avisa el navegador que una propietat canviarà aviat, perquè prepari el que calgui —típicament, promoure l'element a la seva pròpia capa— abans que comenci l'animació i no al primer fotograma.

.tasca--arrossegant { will-change: transform; }

I ara les tres regles que fan que will-change ajudi en lloc de perjudicar:

  1. Fes-lo servir amb moderació extrema. Cada capa consumeix memòria de GPU. Posar will-change: transform a les 600 targetes pot consumir centenars de megabytes de vídeo i fer l'aplicació més lenta. És el mal ús més freqüent.
  2. Posa'l i treu-lo. El correcte és afegir-lo just abans (amb una classe, o en fer :hover sobre l'ancestre) i retirar-lo en acabar. Deixar-lo permanentment al CSS és exactament el que no s'ha de fer.
  3. No el facis servir per «arreglar» animacions lentes. Si la teva animació toca height, will-change: height no la salvarà: el problema és la propietat, no la preparació.
// Patró correcte: activar just abans, retirar en acabar
targeta.style.willChange = 'transform';
const animacio = flip(targeta, () => columna.append(targeta));
animacio?.finished.finally(() => { targeta.style.willChange = 'auto'; });

12.2 contain

Promet al navegador que el que passa dins d'un element no afecta el de fora. Amb aquesta promesa, el navegador pot limitar l'abast d'un recàlcul al subarbre en lloc de propagar-lo a tota la pàgina.

Valor Què promet
layout La geometria interna no afecta l'externa
paint Res no es dibuixa fora dels límits de l'element
size La mida de l'element no depèn dels seus fills (cal donar-li mida explícita)
style Certs efectes d'estil (comptadors) no surten del subarbre
content Drecera de layout + paint + style
strict Drecera de content + size
/* Cada targeta és una illa: canviar-ne l'interior no recalcula la columna sencera */
.tasca { contain: layout paint; }

Amb això, actualitzar el .tasca__meta d'una targeta ja no pot desplaçar les 599 restants, i el navegador ho sap per endavant. Al mesurament del render després d'un canvi d'una tasca, contain: layout paint abaixa el Layout d'1,9 ms a 0,3 ms.

L'avís: contain: size és perillós si no dónes dimensions a l'element, perquè llavors el navegador el tractarà com si fes zero i la targeta desapareixerà. És l'error clàssic amb aquesta propietat.

12.3 content-visibility

És la més potent de les tres i la que més s'acosta a la virtualització sense escriure JavaScript. content-visibility: auto diu al navegador: «si aquest element no és a prop de l'àrea visible, no en facis l'estil, ni el layout, ni el pintat».

.tasca {
  content-visibility: auto;
  contain-intrinsic-size: auto 108px;   /* mida estimada mentre està omès */
}

contain-intrinsic-size és obligatori a la pràctica: sense ell, el navegador tracta l'element omès com si fes 0 px d'alt, i la barra de desplaçament salta i s'encongeix de manera grotesca a mesura que et desplaces. Amb la paraula clau auto, el navegador recorda la mida real un cop l'ha calculada i fa servir aquest valor a partir de llavors, cosa que elimina gairebé del tot el salt.

Mesurament sobre el render complet:

Mesura Sense content-visibility Amb content-visibility: auto
render() complet, 600 tasques 142 ms 78 ms
Layout del pintat inicial 41 ms 6 ms
Paint del pintat inicial 33 ms 5 ms
Nodes del document 7.812 7.812

Dues lectures, i la segona és la important.

És un estalvi enorme per dues línies de CSS. De 142 a 78 ms sense tocar JavaScript, sense trencar res, sense contrapartides d'accessibilitat. Si només poguessis aplicar una tècnica d'aquesta lliçó, seria aquesta.

Però no tanca la fila 9. Els 7.812 nodes continuen allà: content-visibility evita treballar amb ells, no els elimina. I els nodes costen memòria (uns 2,4 MB al monticle, mesurat amb el panell Memory de 09-03), cost de recol·lecció, i cost a cada querySelectorAll que els recorri. Per baixar de 7.812 a 1.500 cal no crear-los, i això ja és virtualització.

Comparació honesta de les tres, per tenir-les clares:

Eina Què estalvia Cost Risc
will-change El primer fotograma d'una animació Memòria de GPU per capa Abusar-ne i empitjorar
contain Propagació de recàlculs Cap si no fas servir size size sense dimensions trenca el disseny
content-visibility Estil, layout i pintat del que no és visible Cap de rellevant El salt del scroll sense contain-intrinsic-size

  1. Sincronitzar amb requestAnimationFrame (i no amb setTimeout)

A 07-06 vas fer servir requestAnimationFrame per animar. Aquí interessa per un altre motiu: és el moment correcte per fer feina visual, i fer servir setTimeout al seu lloc produeix dos defectes diferents.

requestAnimationFrame(callback) programa el callback perquè s'executi just abans del pintat següent. Això significa tres garanties que setTimeout no dóna:

setTimeout(fn, 16) requestAnimationFrame(fn)
Quan s'executa Quan el bucle d'esdeveniments hi arribi, ~16 ms després Just abans del pintat següent
Se sincronitza amb la pantalla? No. A 120 Hz o 144 Hz va desincronitzat Sí, sigui quina sigui la freqüència
Pestanya amagada Continua executant-se i gastant bateria Es posa en pausa
Diverses crides a la mateixa tasca S'executen per separat S'agrupen al mateix fotograma
Risc Fotogrames dobles o perduts Cap

El defecte més subtil de setTimeout per a feina visual s'anomena fotograma doble: si el teu callback s'executa a mig camí entre dos pintats, el navegador pot pintar el mateix estat dues vegades i saltar-se el canvi següent, produint l'estrebada característica de les animacions fetes amb temporitzadors.

A Nómada Tasques hi ha un cas on això importa de debò: el gestor de scroll que decideix quines targetes cal pintar a la finestra virtual de l'apartat 15. Amb throttle(fn, 100) de 09-02 el desplaçament va a salts; amb requestAnimationFrame, va suau. El patró correcte és l'anomenat rAF coalescit:

// js/util/temps.js

/**
 * Executa `fn` com a molt UNA vegada per fotograma, amb els últims arguments.
 * És el `throttle` correcte per a feina visual: se sincronitza amb la pantalla
 * en lloc de fer-ho amb un nombre fix de mil·lisegons.
 */
export function perFotograma(fn) {
  let pendent = 0;
  let ultims = null;

  const embolcallada = function (...args) {
    ultims = args;
    if (pendent !== 0) return;                   // ja n'hi ha un de programat: no acumular
    pendent = requestAnimationFrame(() => {
      pendent = 0;
      fn.apply(this, ultims);
    });
  };

  embolcallada.cancelar = () => {                // neteja obligatòria (09-03)
    cancelAnimationFrame(pendent);
    pendent = 0;
  };

  return embolcallada;
}
// js/vista/tauler-vista.js
import { perFotograma } from '../util/temps.js';

#enDesplacar = perFotograma(() => this.#recalcularFinestra());

constructor({ contenidor, ... }) {
  // …
  this.#contenidor.addEventListener('scroll', this.#enDesplacar, {
    passive: true,                          // no cridarem preventDefault (09-02)
    signal: this.#controlador.signal        // neteja amb un sol abort() (09-03)
  });
}

Mesurament del desplaçament de la columna de pendents amb 600 tasques, 5 segons de scroll continu, CPU 4×:

Gestor de scroll Execucions Fotogrames perduts FPS mitjà
Sense estrangular 312 71 34 fps
throttle(fn, 100) 51 24 48 fps
perFotograma(fn) 58 2 59 fps

Fixa't que perFotograma s'executa més vegades que el throttle de 100 ms i tot i així perd dotze vegades menys fotogrames. No és qüestió d'executar poc: és qüestió d'executar en el moment correcte.

I la regla que resumeix l'apartat, amb el seu matís:

requestAnimationFrame per a feina visual, setTimeout/debounce per a feina no visual. Filtrar el tauler en escriure és no visual (el resultat és un càlcul): debounce. Recol·locar la finestra virtual en desplaçar-se és visual: requestAnimationFrame.

  1. Virtualització I: sentinelles amb IntersectionObserver

Arribem a l'assumpte de fons. Amb 600 tasques hi ha 7.812 nodes al document, i a la pantalla hi caben sis targetes per columna. Estem mantenint gairebé vuit mil nodes per mostrar-ne divuit.

Virtualitzar és pintar només el que es veu (més un petit marge) i fingir la resta. Hi ha dues famílies d'implementació, i convé veure-les totes dues perquè resolen problemes diferents.

La primera, més senzilla, és el desplaçament infinit amb sentinella: es pinten les primeres N targetes i, quan l'usuari arriba al final, se'n pinten unes altres N. S'implementa amb l'IntersectionObserver de 07-06, que avisa quan un element entra a l'àrea visible sense necessitat d'escoltar scroll.

// js/vista/llista-progressiva.js
import { $ } from './dom.js';
import { crearElement } from './dom.js';

const LOT = 30;                   // quantes targetes s'afegeixen cada vegada

/**
 * Pinta una llista llarga per lots: els primers LOT elements, i la resta
 * a mesura que l'usuari s'acosta al final.
 */
export class LlistaProgressiva {
  #contenidor;
  #pintar;
  #dades = [];
  #pintades = 0;
  #sentinella;
  #observador;

  constructor({ contenidor, pintar }) {
    this.#contenidor = contenidor;
    this.#pintar = pintar;

    // El sentinella és un element buit al final de la llista
    this.#sentinella = crearElement('li', {
      classes: 'sentinella', 'aria-hidden': 'true'
    });

    this.#observador = new IntersectionObserver((entrades) => {
      if (entrades[0].isIntersecting) this.#seguentLot();
    }, {
      root: contenidor.closest('.columna'),
      rootMargin: '400px'          // pintar ABANS que el buit es vegi (07-06)
    });
  }

  establir(dades) {
    this.#dades = dades;
    this.#pintades = 0;
    this.#contenidor.replaceChildren(this.#sentinella);
    this.#observador.observe(this.#sentinella);
    this.#seguentLot();
  }

  #seguentLot() {
    const fins = Math.min(this.#pintades + LOT, this.#dades.length);
    if (fins === this.#pintades) {
      this.#observador.unobserve(this.#sentinella);   // ja no queda res
      this.#sentinella.remove();
      return;
    }

    const fragment = document.createDocumentFragment();
    for (let i = this.#pintades; i < fins; i += 1) {
      fragment.append(this.#pintar(this.#dades[i], null));
    }
    this.#sentinella.before(fragment);                // inserir ABANS del sentinella
    this.#pintades = fins;
  }

  /** Neteja obligatòria: un observador viu reté els seus nodes (09-03). */
  destruir() {
    this.#observador.disconnect();
    this.#sentinella.remove();
    this.#dades = [];
  }
}

Quatre detalls del codi que mereixen atenció:

  • rootMargin: '400px' fa que el sentinella «entri» quatre-cents píxels abans de ser visible. Sense aquest marge, l'usuari veu el buit durant un instant. És el mateix truc de precàrrega de 07-06.
  • El sentinella va al final i s'insereix abans d'ell, no després: així sempre queda l'últim i l'observador el pot continuar fent servir.
  • unobserve + remove en esgotar les dades evita que l'observador continuï viu sense motiu, i evita el node separat de 09-03.
  • destruir() amb disconnect() és obligatori: un IntersectionObserver que observa un node el manté abastable.

Mesurament del pintat inicial amb aquesta tècnica:

Mesura Tot de cop Progressiva per lots de 30
Temps fins a la primera targeta visible 142 ms 11 ms
Nodes en arrencar 7.812 972
Nodes després de desplaçar-se fins al final 7.812 7.812
Complexitat afegida Baixa

La primera fila és excel·lent: la percepció millora radicalment perquè les primeres targetes apareixen gairebé immediatament. Però mira la tercera: els nodes s'acumulen. La llista progressiva redueix el cost inicial, no el cost sostingut. Per a la Marta, que es passa el matí desplaçant-se pel tauler, acabarem un altre cop en 7.812 nodes.

La fila 9 demana una altra cosa.

  1. Virtualització II: la finestra calculada amb alçada fixa

La virtualització de debò manté al DOM només els elements de la finestra visible, creant els que hi entren i eliminant els que en surten. El nombre de nodes es torna constant, independent de la mida de la llista.

La idea, quan totes les files fan el mateix, és purament aritmètica:

flowchart TB
    subgraph V["Contenidor amb scroll · alt 640 px"]
        direction TB
        E1["Espaiador superior<br/>alt = primer × 116 px"]
        W["<b>Finestra pintada</b><br/>16 targetes reals<br/>(6 visibles + 5 de marge a dalt i a baix)"]
        E2["Espaiador inferior<br/>alt = (600 − últim) × 116 px"]
        E1 --> W --> E2
    end
    S["scrollTop"] -->|"primer = floor(scrollTop / 116) − 5"| W

Els dos espaiadors són la peça clau: un <li> buit a dalt i un altre a baix, amb l'alçada exacta que ocuparien les targetes que no s'han pintat. Gràcies a ells, la barra de desplaçament té la mida correcta i la posició dins de la llista és fidel, encara que només existeixin setze targetes.

// js/vista/llista-virtual.js
import { crearElement } from './dom.js';
import { perFotograma } from '../util/temps.js';

/**
 * Manté al DOM només les files visibles d'una llista llarga.
 * Requereix que TOTES les files facin el mateix (`alt`).
 */
export class LlistaVirtual {
  #contenidor;                       // el <ul class="llista-tasques">
  #finestreta;                       // l'element amb scroll (la columna)
  #pintar;                           // (dada, nodeExistent) => HTMLElement
  #clau;                             // (dada) => string|number
  #alt;                              // alçada d'una fila, en px, amb la seva separació
  #marge;                            // files extra a dalt i a baix
  #dades = [];
  #adalt;                            // espaiador superior
  #abaix;                            // espaiador inferior
  #rang = { inici: -1, final: -1 };
  #controlador = new AbortController();
  #enDesplacar;

  constructor({ contenidor, finestreta, pintar, clau, alt = 116, marge = 5 }) {
    this.#contenidor = contenidor;
    this.#finestreta = finestreta;
    this.#pintar = pintar;
    this.#clau = clau;
    this.#alt = alt;
    this.#marge = marge;

    this.#adalt = crearElement('li', { classes: 'espaiador', 'aria-hidden': 'true' });
    this.#abaix = crearElement('li', { classes: 'espaiador', 'aria-hidden': 'true' });

    // Feina visual → un recàlcul per fotograma, mai més (apartat 13)
    this.#enDesplacar = perFotograma(() => this.#sincronitzar());

    this.#finestreta.addEventListener('scroll', this.#enDesplacar, {
      passive: true, signal: this.#controlador.signal
    });
    window.addEventListener('resize', this.#enDesplacar, {
      passive: true, signal: this.#controlador.signal
    });
  }

  /** Canvia les dades de la llista i repinta la finestra. */
  establir(dades) {
    this.#dades = dades;
    this.#rang = { inici: -1, final: -1 };       // força el repintat complet
    this.#sincronitzar();
  }

  #sincronitzar() {
    // ── FASE 1 · LLEGIR. Dues lectures, cap escriptura entremig ──
    const desplacament = this.#finestreta.scrollTop;
    const altVisible = this.#finestreta.clientHeight;

    // ── Aritmètica pura, sense tocar el DOM ──
    const total = this.#dades.length;
    const primeraVisible = Math.floor(desplacament / this.#alt);
    const quantesHiCaben = Math.ceil(altVisible / this.#alt);

    const inici = Math.max(0, primeraVisible - this.#marge);
    const final = Math.min(total, primeraVisible + quantesHiCaben + this.#marge);

    if (inici === this.#rang.inici && final === this.#rang.final) return;  // res a fer
    this.#rang = { inici, final };

    // ── FASE 2 · ESCRIURE ──
    this.#adalt.style.height = `${inici * this.#alt}px`;
    this.#abaix.style.height = `${Math.max(0, total - final) * this.#alt}px`;

    const visibles = this.#dades.slice(inici, final);
    const existents = new Map(
      [...this.#contenidor.children]
        .filter((n) => !n.classList.contains('espaiador'))
        .map((n) => [n.dataset.id, n])
    );

    const fragment = document.createDocumentFragment();
    for (const dada of visibles) {
      const k = String(this.#clau(dada));
      fragment.append(this.#pintar(dada, existents.get(k) ?? null));
      existents.delete(k);
    }

    // Una sola operació sobre l'arbre viu: espaiador + finestra + espaiador
    this.#contenidor.replaceChildren(this.#adalt, fragment, this.#abaix);

    // El que quedava a `existents` ja no és a la finestra: se n'ha anat amb el
    // replaceChildren, i com que no es guarda enlloc, és escombraria recol·lectable (09-03)
  }

  /** Nodes realment presents, sense comptar els dos espaiadors. */
  get pintats() { return this.#contenidor.children.length - 2; }

  destruir() {
    this.#controlador.abort();
    this.#enDesplacar.cancelar();
    this.#dades = [];
    this.#contenidor.replaceChildren();
  }
}

I el CSS que fa possible l'aritmètica:

/* css/estils.css */
.columna {
  height: 640px;
  overflow-y: auto;
  contain: layout paint;          /* la columna és una illa (apartat 12) */
}

.llista-tasques { margin: 0; padding: 0; list-style: none; }

.tasca {
  box-sizing: border-box;
  height: 108px;                  /* ALÇADA FIXA: és el contracte de la virtualització */
  margin-bottom: 8px;             /* 108 + 8 = 116 px, l'`alt` de LlistaVirtual */
  overflow: hidden;
  contain: layout paint;
}

.espaiador { padding: 0; margin: 0; }

I el cablejat a la vista, substituint la crida a reconciliar:

// js/vista/tauler-vista.js
import { LlistaVirtual } from './llista-virtual.js';
import { pintarTargeta } from './targeta.js';

#llistes = new Map();         // estat → LlistaVirtual

#prepararColumnes() {
  this.#contenidor.replaceChildren(...COLUMNES.map(({ estat, titol }) => {
    const columna = $('#plantilla-columna').content.firstElementChild.cloneNode(true);
    // …igual que a 06-06: dataset, títol, aria-labelledby, aria-live…

    this.#llistes.set(estat, new LlistaVirtual({
      contenidor: $('.llista-tasques', columna),
      finestreta: columna,
      clau: (tasca) => tasca.id,
      pintar: (tasca, node) => pintarTargeta(tasca, node, this.#estat.avui),
      alt: 116,
      marge: 5
    }));

    return columna;
  }));
}

render() {
  const visibles = this.#visibles();
  const perEstat = Object.groupBy(visibles, (t) => t.estat);

  for (const { estat } of COLUMNES) {
    const columna = $(`.columna[data-estat="${estat}"]`, this.#contenidor);
    const delEstat = perEstat[estat] ?? [];
    const hores = delEstat.reduce((suma, t) => suma + t.horesEstimades, 0);

    posarText($('.columna__comptador', columna),
      `${delEstat.length} ${delEstat.length === 1 ? 'tasca' : 'tasques'} · ${hores} h`);

    this.#llistes.get(estat).establir(delEstat);     // ← ja no hi ha reconciliar aquí
    $('.columna__buida', columna).hidden = delEstat.length > 0;
  }

  // …resum (amb memòria cau de 09-02) i emissió de l'esdeveniment, igual que a 06-06…
}

destruir() {
  for (const llista of this.#llistes.values()) llista.destruir();
  this.#llistes.clear();
  this.#controlador.abort();
  // …resta de la neteja de 09-03…
}

El mesurament, i aquí és on es tanca la lliçó:

Mesura Sense virtualitzar Amb LlistaVirtual
render() complet, 600 tasques 142 ms (78 amb content-visibility) 31 ms
Targetes al DOM 600 48 (16 × 3 columnes)
Nodes del document 7.812 1.194
Memòria del monticle 12,8 MB 6,1 MB
Recol·locació en desplaçar 1,7 ms per fotograma
Fotogrames perduts en 5 s de scroll 24 2

Els 1.194 nodes surten d'un compte que convé entendre: 612 nodes de la carcassa de la pàgina (capçalera, filtres, formulari, resum, plantilles, les tres columnes amb els seus títols i comptadors) + 48 targetes × 12 nodes cadascuna = 576 + 6 espaiadors. Total, 1.194. La fila 9 demanava ≤ 1.500.

I una propietat que és la més valuosa de totes: aquest número ja no depèn de la mida del tauler. Amb 6.000 tasques serien els mateixos 1.194 nodes i els mateixos 31 ms. La virtualització no fa l'aplicació un 20 % més ràpida: canvia la seva complexitat d'O(n) a O(1) en el nombre d'elements pintats, que és just la mena de canvi que 09-02 va identificar com l'única que importa de debò.

  1. Les contrapartides de virtualitzar

Seria deshonest acabar l'apartat anterior sense la lletra petita. Virtualitzar trenca coses, i cal saber quines i com es mitiguen.

El que es trenca Per què Mitigació
Cercar amb Ctrl+F El navegador només troba text que existeix al DOM El cercador de l'aplicació ha de ser bo; i content-visibility que és cercable amb hidden-matchable
Lectors de pantalla Anuncien «llista de 16 elements» en lloc de 600 role="list" + aria-setsize="600" i aria-posinset a cada fila
Tabulació amb teclat No es pot tabular fins a una fila que no existeix Navegació amb fletxes gestionada per codi, i desplaçar en enfocar
Imprimir Només s'imprimeix la finestra Un mode «imprimir-ho tot» que desactivi la virtualització
Enllaços a una tasca concreta La tasca pot no estar pintada L'encaminador (07-06) ha de calcular el scrollTop i desplaçar-se abans de buscar el node
Alçades variables L'aritmètica suposa files iguals Mesurar i desar les alçades a la memòria cau, o forçar alçada fixa amb overflow: hidden
Complexitat del codi 90 línies més i un mode de fallada nou Proves dedicades i un destruir() correcte

Les dues primeres mereixen desenvolupament, perquè són les que més se subestimen.

Accessibilitat. Una llista virtualitzada mal feta és una regressió d'accessibilitat seriosa. El mínim imprescindible:

// A LlistaVirtual, en pintar cada fila
li.setAttribute('aria-setsize', String(this.#dades.length));   // «de 600»
li.setAttribute('aria-posinset', String(inici + i + 1));       // «el 137»
<ul class="llista-tasques" role="list" aria-live="polite"></ul>

Amb això, un lector de pantalla anuncia «tasca 137 de 600» encara que al DOM només n'hi hagi setze. I els espaiadors porten aria-hidden="true" perquè no s'anunciïn com a elements buits de la llista.

L'alternativa més barata. Abans de virtualitzar, pregunta't si content-visibility: auto ja et basta. La comparació completa:

content-visibility: auto Virtualització
Esforç 2 línies de CSS ~90 línies de JS i proves
render() 78 ms 31 ms
Nodes 7.812 1.194
Memòria 12,8 MB 6,1 MB
Ctrl+F Funciona Trencat
Lectors de pantalla Sense canvis Requereix aria-setsize
Alçades variables Sense problema Requereix feina extra
Escala a 60.000 files No (l'arbre pesa)

Regla de decisió: fes servir content-visibility: auto per defecte; virtualitza només quan el nombre de nodes sigui el problema, no el temps de pintat. A Nómada Tasques virtualitzem perquè la fila 9 de la línia base ho exigeix explícitament i perquè el tauler continuarà creixent. Si la línia base només hagués demanat baixar de 310 a 50 ms, content-visibility més els apartats 7 i 10 gairebé haurien bastat, amb una desena part del codi.

I ja que estem sent honestos: la implementació de LlistaVirtual de més amunt és deliberadament senzilla. No admet alçades variables, no gestiona el desplaçament suau cap a un element concret, no reposa el focus en sortir i tornar a entrar a la finestra. Cadascuna d'aquestes coses és una bona estona de feina. És exactament el tipus de problema que al Mòdul 10 veuràs resolt de fàbrica.

  1. Delegació d'esdeveniments, ara amb números

A 06-04 vas aprendre delegació i es va justificar per elegància: un gestor al contenidor que esbrina l'origen amb closest('[data-accio]'). A 09-03 es va justificar per memòria: un closure en lloc de 600. Falta la justificació de temps, i amb la virtualització de l'apartat anterior es torna estructural.

Mesurament del pintat inicial de 600 targetes amb tres accions cadascuna:

Un gestor per botó Delegació a .tauler
Crides a addEventListener 1.800 1
Cost només de registrar 38 ms 0,004 ms
Memòria dels oients 1,2 MB ~0 kB
En crear una targeta nova Cal registrar-ne les 3 Funciona sola
En eliminar una targeta Cal retirar-los Res a retirar
Cost per clic 0,002 ms 0,014 ms (el closest)

La penúltima fila és la raó profunda per la qual la delegació no és opcional quan hi ha virtualització: les targetes entren i surten del DOM constantment en desplaçar-se. Amb gestors individuals caldria registrar i retirar oients a cada fotograma de scroll, cosa que seria tan absurda com sona.

I sobre l'última fila: sí, cada clic delegat costa set vegades més que un de directe. Són dotze microsegons, passen una vegada cada uns quants segons, i a canvi estalvies 38 ms a l'arrencada i una família sencera de fuites. És exactament el tipus de comparació que 09-02 va ensenyar a fer: comparar magnituds absolutes, no factors.

Un últim apunt tècnic. Amb la llista virtualitzada, el gestor delegat continua funcionant sense canvis perquè closest puja per l'arbre i el contenidor .tauler no es destrueix mai:

// js/vista/controlador.js — sense ni un sol canvi respecte a 06-04
$('.tauler').addEventListener('click', (esdeveniment) => {
  const boto = esdeveniment.target.closest('button[data-accio]');
  if (boto === null) return;

  const id = Number(boto.closest('[data-id]').dataset.id);
  // …resta igual…
}, { signal: controlador.signal });

Que una tècnica triada per claredat continuï sent correcta després de tres lliçons d'optimització no és casualitat: és la mateixa observació de 09-02 sobre class Tasca. Les decisions de disseny bones solen resultar també les ràpides.

  1. Les files 5 i 9, tancades

Recapitulem el camí complet, perquè la suma és el que dóna la mesura real de la lliçó.

Pas Què es va fer render() Nodes
Línia base (09-01) 310 ms 7.812
1 Read-then-write a #ajustarAlcades (apartat 7) 179 ms 7.812
2 Empremta i escriptura condicional a pintarTargeta (apartat 10) 142 ms 7.812
3 contain: layout paint a .tasca i .columna (apartat 12) 138 ms 7.812
4 content-visibility: auto (apartat 12) 78 ms 7.812
5 LlistaVirtual (apartat 15) 31 ms 1.194

I les files de la línia base que queden tancades:

# Mesura Línia base Objectiu Després Estat
5 render() complet, 600 tasques 310 ms ≤ 50 ms 31 ms
9 Nodes DOM del document 7.812 ≤ 1.500 1.194
2 INP en escriure al cercador 480 ms → 96 ms (09-02) ≤ 200 ms 42 ms

La fila 2 millora de propina: el debounce de 09-02 havia deixat l'INP en 96 ms, dels quals 78 eren el render. Amb el render en 31 ms, la interacció completa —tecla, filtratge, render, pintat— baixa a 42 ms, molt per sota del llindar dels 100 ms de resposta instantània de 09-01.

I dues xifres de context que no són a la taula però que importen:

  • La memòria del monticle baixa de 12,8 MB a 6,1 MB, perquè 6.618 nodes que ja no existeixen no ocupen res. Un altre cop l'efecte de 09-03: arreglar una cosa en millora una altra.
  • Tot això és independent de la mida del tauler. Amb 6.000 tasques, render() continua costant 31 ms i el document continua tenint 1.194 nodes. L'única cosa que creix és el filtratge i l'ordenació, que són O(n) i O(n log n) sobre dades en memòria: uns 8 ms amb 6.000 tasques.

  1. Símptoma → causa probable → solució

Aquesta taula és el resum operatiu de la lliçó. Quan alguna cosa vagi malament al DOM, comença per aquí.

Símptoma observat Què veus a DevTools Causa probable Solució
Un render curt es torna llarguíssim amb volum Molts blocs liles intercalats al groc, amb triangle d'avís Layout thrashing Read-then-write (7)
«Forced reflow is a likely performance bottleneck» L'avís literal Lectura de la llista negra dins d'un bucle Read-then-write (7)
El render triga el mateix encara que no canviï res pintarTargeta amb molt self time S'escriu sense comprovar Empremta i escriptura condicional (10)
Una animació va a estrebades Carril Frames en vermell; lila per fotograma S'anima top, width o height transform i opacity, o FLIP (11)
Una animació es congela en fer una altra cosa Bloc groc llarg durant l'animació L'animació depèn del fil principal CSS o Web Animations en lloc de rAF (11)
El desplaçament va a salts Gestor de scroll amb molt self time scroll sense estrangular o amb throttle fix perFotograma amb rAF (13)
Tot s'alenteix en créixer la llista Layout i Paint enormes al pintat inicial Es pinten elements que ningú no veu content-visibility (12) o virtualització (15)
El comptador de nodes puja i no baixa Corba Nodes creixent a Performance Nodes que s'acumulen (o una fuita) Virtualització (15) o revisar 09-03
Canviar una targeta recol·loca la pàgina sencera Layout amb molts nodes afectats Sense aïllament contain: layout paint (12)
Inserir elements va lent Molts Layout al bucle d'inserció Lectura de geometria intercalada Construir fora de l'arbre (8)
El primer pintat triga molt però després va bé Un sol bloc enorme en arrencar Es pinta tot de cop Llista progressiva (14) o virtualització (15)
En desplaçar-se, la barra de scroll fa salts content-visibility sense mida intrínseca contain-intrinsic-size: auto Xpx (12)
L'aplicació consumeix molta memòria de vídeo Moltes capes al panell Layers will-change aplicat a massa elements Aplicar-lo i retirar-lo (12)

Errors Habituals i Consells

  • Creure que «el DOM és lent». El DOM no és lent: recalcular estil i disseny ho és, i només quan ho forces fora de temps. Creuar la frontera JS↔DOM costa nanosegons.
  • Llegir geometria dins d'un bucle que escriu. És l'error de la lliçó. Quaranta lectures d'offsetHeight costen 137 ms; les mateixes quaranta, agrupades, costen 4,3 ms.
  • No reconèixer getComputedStyle com a lectura cara. És la més traïdora de la llista negra, perquè no sembla una mesura.
  • Fer servir innerText per costum. Força recàlcul; textContent no. Tret que necessitis específicament el text visible, fes servir textContent.
  • Cridar focus() enmig d'un bucle d'escriptures. És una lectura disfressada: força el disseny per saber on és l'element.
  • Creure que DocumentFragment és la gran optimització. Aporta un 9 %. El que aporta un 1.600 % és no llegir geometria al bucle.
  • Escriure sempre, sense comprovar. Assignar el mateix textContent que ja hi havia invalida igualment. Una empremta barata evita 9.600 escriptures per render.
  • Animar top, left, width o height. Cada fotograma passa per layout. Fes servir transform i opacity, i FLIP quan el canvi sigui de posició real.
  • Posar will-change a tot per si de cas. Cada element promogut consumeix memòria de GPU. Activa'l just abans i retira'l en acabar.
  • Fer servir contain: size sense donar dimensions. L'element es tracta com si fos de mida zero i desapareix.
  • Fer servir content-visibility: auto sense contain-intrinsic-size. La barra de desplaçament es torna boja. Amb auto Xpx, el navegador recorda la mida real.
  • Fer servir setTimeout per a feina visual. No se sincronitza amb la pantalla, continua corrent en pestanyes amagades i produeix fotogrames dobles. requestAnimationFrame.
  • Fer servir throttle(fn, 100) a scroll. Millor que res, pitjor que requestAnimationFrame: s'executa menys vegades i perd més fotogrames.
  • Oblidar { passive: true } a scroll, wheel i touchstart. El navegador ha d'esperar a veure si crides preventDefault.
  • Virtualitzar sense arreglar l'accessibilitat. Sense aria-setsize i aria-posinset, una llista de 600 s'anuncia com de 16. És una regressió seriosa.
  • Virtualitzar quan n'hi havia prou amb content-visibility. Dues línies de CSS davant de noranta de JavaScript amb un mode de fallada nou. Mesura abans de decidir.
  • Virtualitzar amb alçades variables sense mesurar-les. L'aritmètica suposa files iguals; si no ho són, la barra de desplaçament menteix.
  • Consell: mesura el nombre de nodes, no només el temps. document.getElementsByTagName('*').length a la consola, o la corba Nodes del panell Performance. És un indicador primerenc que el temps no dóna.
  • Consell: busca el triangle d'avís al diagrama de flames. DevTools et diu literalment on és el recàlcul forçat, amb la pila de crides que el va provocar.
  • Consell: fes servir el panell Rendering de DevTools. Paint flashing acoloreix en verd el que es repinta —si parpelleja tota la pantalla en canviar una targeta, tens un problema d'aïllament—, i Layout Shift Regions assenyala els salts de contingut, que seran protagonistes a 09-05.
  • Consell: escriu una prova que compti escriptures. A Jest amb jsdom no hi ha layout, però sí que pots espiar Element.prototype.setAttribute i afirmar que un render sense canvis no escriu res. És barata i protegeix l'apartat 10.
  • Consell: els espaiadors de la llista virtual han de portar aria-hidden="true". Si no, s'anuncien com a elements buits de la llista.

Exercicis

Exercici 1 — Caçar el thrashing. Aquest codi col·loca una barra de progrés a cada columna del tauler, proporcional a les hores obertes. Amb les tres columnes i 600 tasques triga 96 ms, i DevTools marca l'avís de recàlcul forçat.

function pintarBarres(columnes) {
  for (const columna of columnes) {
    const barra = columna.querySelector('.columna__barra');
    const ample = columna.clientWidth;                          // (a)
    const alt = getComputedStyle(barra).height;                 // (b)

    barra.style.width = `${ample * 0.8}px`;                     // (c)
    barra.classList.toggle('columna__barra--alta', ample > 320); // (d)

    const etiqueta = columna.querySelector('.columna__comptador');
    etiqueta.style.top = `${barra.getBoundingClientRect().bottom + 4}px`;   // (e)
    etiqueta.textContent = `${Math.round(parseFloat(alt))} px de barra`;    // (f)
  }
}

Respon: (1) quines línies són lectures de la llista negra i quines escriptures; (2) quants recàlculs forçats provoca amb tres columnes i per què; (3) reescriu-lo amb read-then-write; (4) estima la millora sabent que un recàlcul forçat sobre aquest arbre costa uns 11 ms; i (5) assenyala una segona millora, independent de l'ordre, que redueixi encara més el cost.

Exercici 2 — content-visibility o virtualització? Per a cadascuna d'aquestes quatre pantalles de Nómada Tasques, decideix entre «no fer res», «content-visibility: auto» i «virtualitzar», justificant-ho amb les contrapartides de l'apartat 16.

  1. El tauler canònic del Taller Nómada: 6 tasques en tres columnes.
  2. L'historial de canvis: 4.200 línies de text d'una sola alçada, que la Marta consulta amb Ctrl+F constantment.
  3. La vista d'impressió de l'informe trimestral: 600 files que cal enviar a la impressora d'una vegada.
  4. El selector d'etiquetes: 6 elements amb alçada variable segons la longitud del nom.

Exercici 3 — Animar el canvi de columna. Quan l'Iván prem «Començar», la targeta salta de la columna «Pendents» a «En curs» sense transició. Escriu l'animació correcta: (a) explica per què no es pot resoldre amb una animació CSS de top i left; (b) implementa el moviment amb la funció flip de l'apartat 11, integrada al flux estat → render → esdeveniment de 06-06; (c) indica com evitar el layout thrashing si canvien diverses targetes alhora; i (d) afegeix el respecte a prefers-reduced-motion i explica per què l'animació ha de continuar funcionant encara que el fil principal estigui ocupat.

Solucions

Solució 1

(1) Classificació de les línies:

Línia Tipus Motiu
(a) columna.clientWidth Lectura Geometria del client
(b) getComputedStyle(barra).height Lectura Estil resolt, i depèn del disseny
(c) barra.style.width = … Escriptura Invalida estil i disseny
(d) classList.toggle(...) Escriptura Invalida estil (i disseny, perquè la classe canvia la mida)
(e) barra.getBoundingClientRect() Lectura dins d'una escriptura Rectangle
(f) etiqueta.textContent = … Escriptura Invalida estil, disseny i pintat

(2) Recàlculs forçats. Per cada volta hi ha dos blocs lectura→escriptura→lectura: (a)(b) llegeixen, (c)(d) invaliden, (e) torna a llegir —forçat—, (f) invalida un altre cop. A la volta següent, (a) llegeix amb invalidacions pendents —forçat— i (b) pot aprofitar el recàlcul acabat de fer. Amb tres columnes: la primera volta fa 1 recàlcul natural + 1 de forçat a (e); les voltes 2 i 3 en fan 2 de forçats cadascuna. Total: 5 recàlculs forçats i uns 55 ms només en això. L'estructura és la de l'apartat 6: el bucle alterna.

(3) Reescriptura:

function pintarBarres(columnes) {
  // ── FASE 1 · LLEGIR-HO TOT. Un sol recàlcul forçat, al principi ──
  const mesures = columnes.map((columna) => {
    const barra = columna.querySelector('.columna__barra');
    return {
      columna,
      barra,
      etiqueta: columna.querySelector('.columna__comptador'),
      ample: columna.clientWidth,
      altBarra: parseFloat(getComputedStyle(barra).height)
    };
  });

  // ── FASE 2 · ESCRIURE-HO TOT. Cap lectura aquí dins ──
  for (const { barra, etiqueta, ample, altBarra } of mesures) {
    barra.style.width = `${ample * 0.8}px`;
    barra.classList.toggle('columna__barra--alta', ample > 320);

    // La posició de l'etiqueta es CALCULA, no es torna a llegir del DOM
    etiqueta.style.top = `${altBarra + 4}px`;
    etiqueta.textContent = `${Math.round(altBarra)} px de barra`;
  }
}

El canvi clau, més enllà de separar fases, és que la línia (e) desapareix: la posició inferior de la barra es dedueix de l'alçada ja mesurada en lloc de tornar-la a preguntar al DOM. La millor lectura de geometria és la que no es fa.

(4) Millora estimada. De 5 recàlculs forçats a 1: s'estalvien 4 × 11 ms = 44 ms, i el temps total passa de 96 ms a uns 52 ms. Mesurat al portàtil de referència amb CPU 4×, el resultat real va ser 96 → 49 ms, una mica millor de l'estimat perquè el recàlcul restant treballa sobre un arbre menys invalidat.

(5) Segona millora, independent de l'ordre. Substituir l'estil en línia per variables CSS i deixar que el CSS faci el càlcul. Així s'escriu una sola propietat personalitzada per columna i el navegador resol amplades i posicions sense que el JavaScript mesuri res:

columna.style.setProperty('--proporcio', String(obertes / total));
.columna__barra { width: calc(var(--proporcio, 0) * 80%); }
.columna__comptador { top: calc(var(--alt-barra, 24px) + 4px); }

Amb això no queda cap lectura de geometria, i el cost cau a 1,2 ms. És la lliçó de fons de 06-02 portada a l'extrem: deixa que el CSS faci la geometria.

Solució 2

# Pantalla Decisió Justificació
1 Tauler canònic, 6 tasques No fer res 78 nodes i un render d'1,4 ms. Qualsevol tècnica d'aquesta lliçó afegiria complexitat sense benefici mesurable. És l'aplicació literal de la regla de 09-01: si no ho has mesurat com a problema, no ho optimitzis
2 Historial de 4.200 línies amb Ctrl+F content-visibility: auto Virtualitzar trencaria Ctrl+F, que és el cas d'ús principal d'aquella pantalla. Com que les línies tenen alçada uniforme, contain-intrinsic-size: auto 28px deixa el desplaçament perfecte, i l'estalvi de layout i pintat és pràcticament el mateix. Els 4.200 nodes són acceptables si no creixen sense límit
3 Vista d'impressió, 600 files No fer res (i desactivar qualsevol optimització) La impressió necessita tot el contingut al DOM. Si el tauler està virtualitzat, cal desvirtualitzar abans d'imprimir, escoltant window.matchMedia('print') o l'esdeveniment beforeprint. És la contrapartida de la taula de l'apartat 16
4 Selector de 6 etiquetes, alçada variable No fer res Sis elements. I encara que en fossin sis-cents, l'alçada variable trenca l'aritmètica de LlistaVirtual, que exigeix files iguals; caldria mesurar i desar les alçades a la memòria cau, amb la qual cosa content-visibility seria l'opció sensata

La regla que travessa les quatre respostes: la tècnica correcta depèn de com es fa servir la pantalla, no de quants elements té. Les 4.200 línies de l'historial i les 600 files de l'informe tenen problemes de mida semblants i solucions oposades, perquè en una mana Ctrl+F i en l'altra mana la impressora.

Solució 3

(a) Per què no val animar top i left. Dos motius, i el segon és el que impedeix la solució:

  1. Totes dues són a la fila vermella de l'apartat 11: cada fotograma passa per estil, layout, pintat i composició. Amb 24 targetes movent-se es perden 38 de 58 fotogrames.
  2. I sobretot: la targeta no es mou dins d'un contenidor, canvia de contenidor. Passa de l'<ul> de «Pendents» a l'<ul> de «En curs». Una animació CSS no pot interpolar entre dues posicions en arbres diferents, perquè el canvi de pare és instantani i l'estat inicial deixa d'existir. Per això cal FLIP: mesurar abans, aplicar el canvi real, mesurar després, i animar la diferència amb transform.

(b) Implementació integrada al flux de 06-06:

// js/vista/controlador.js
import { flip } from './animar.js';
import { $ } from './dom.js';

$('.tauler').addEventListener('click', (esdeveniment) => {
  const boto = esdeveniment.target.closest('button[data-accio]');
  if (boto === null) return;

  const li = boto.closest('[data-id]');
  const id = Number(li.dataset.id);
  const tasca = tauler.cercarPerId(id);
  const desti = seguentEstat(tasca, boto.dataset.accio);
  if (desti === null) return;

  // FLIP embolcalla el cicle complet: el canvi d'estat I el render
  li.style.willChange = 'transform';
  const animacio = flip(li, () => {
    tauler.canviarEstat(id, desti);    // 1 · canvia l'ESTAT
    vista.render();                     // 2 · redibuixa: el <li> canvia de columna
  });

  animacio?.finished.finally(() => { li.style.willChange = 'auto'; });
}, { signal: controlador.signal });

Això funciona perquè la reconciliació per data-id de 06-06 reutilitza el mateix node: insertBefore el mou al nou <ul> en lloc de destruir-lo i crear-ne un altre (06-05). Si el render recreés la targeta, el node mesurat a la fase «First» ja no existiria i FLIP no tindria res a animar. És un cas preciós en què una decisió de correcció presa tres mòduls abans fa possible una optimització visual.

(c) Diverses targetes alhora. L'error seria cridar flip en un bucle: cada crida fa dos getBoundingClientRect, així que vint-i-quatre targetes serien 48 lectures alternades amb 24 canvis de layout. La versió correcta agrupa les tres fases:

export function flipDiverses(nodes, canviar) {
  // F · llegir totes les posicions inicials: UN recàlcul
  const primeres = new Map(nodes.map((n) => [n, n.getBoundingClientRect()]));

  canviar();                                   // el canvi real, de cop

  // L · llegir totes les finals: UN recàlcul
  const ultimes = new Map(nodes.map((n) => [n, n.getBoundingClientRect()]));

  // I + P · animar: només escriptures, només composició
  return nodes.map((n) => {
    const a = primeres.get(n);
    const b = ultimes.get(n);
    const dx = a.left - b.left;
    const dy = a.top - b.top;
    if (dx === 0 && dy === 0) return null;
    return n.animate(
      [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
      { duration: 220, easing: 'cubic-bezier(0.2, 0, 0.2, 1)' }
    );
  }).filter(Boolean);
}

Dos recàlculs forçats en total, en lloc de 48. És el patró de l'apartat 7 aplicat a l'animació.

(d) prefers-reduced-motion i per què l'animació sobreviu al fil ocupat:

const senseMoviment = window.matchMedia('(prefers-reduced-motion: reduce)');

export function flip(node, canviar) {
  if (senseMoviment.matches) { canviar(); return null; }   // ← accessibilitat primer
  // …resta igual…
}

Respectar prefers-reduced-motion (07-06) no és un detall estètic: per a les persones amb trastorns vestibulars, el moviment a la pantalla pot provocar mareig real. L'animació ha de desaparèixer, no alentir-se.

I la raó per la qual aquesta animació sobreviu a un fil principal ocupat és la de l'apartat 11: element.animate() amb transform i opacity s'executa al fil de composició, no al principal. Un cop llançada, si el teu JavaScript es queda 300 ms fent alguna cosa, l'animació continua a 60 fps. Amb requestAnimationFrame no: cada fotograma depèn que el fil principal estigui lliure, així que l'animació es congelaria. És un argument decisiu per preferir CSS o la Web Animations API sempre que l'efecte es pugui expressar de manera declarativa.

Conclusió

Les dues files que dominaven la taula estan tancades. render() ha passat de 310 ms a 31 ms i el document de 7.812 nodes a 1.194, amb l'INP del cercador baixant de propina de 96 a 42 ms i el monticle de 12,8 a 6,1 MB. I el més valuós: aquestes xifres ja no depenen de la mida del tauler.

Entens per què el DOM és car, que no és pel que gairebé tothom es pensa. Creuar la frontera entre JavaScript i el motor de renderitzat costa nanosegons; el que costa mil·lisegons és recalcular, i el navegador ja evita recalcular de més agrupant totes les teves escriptures fins just abans del fotograma següent. Coneixes el pipeline de renderitzat —JavaScript, estil, layout, pintat, composició— i saps que s'hi pot entrar pel mig: canviar width recorre les cinc fases, canviar background-color se salta el layout, i canviar transform o opacity se salta també el pintat i ho resol la GPU sola.

Saps què és un càlcul d'estil síncron forçat i tens la llista negra que el provoca —offsetHeight i família, clientWidth, scrollTop, getBoundingClientRect, getComputedStyle, innerText, focus(), scrollIntoView()—, amb la qual cosa queda tancat l'avís que 06-02 va deixar obert. I reconeixes el layout thrashing a simple vista: una d'aquestes lectures dins d'un bucle que a més escriu. A Nómada Tasques eren quaranta lectures d'offsetHeight sobre les targetes vençudes —els 41 recàlculs d'estil i 38 layout del Bottom-Up de 09-01— i costaven 137 ms dels 310. El patró read-then-write els va reduir a un de sol i el render a 179 ms, sense canviar ni una operació: només l'ordre.

Tens les magnituds reals de cada tècnica, que és el que evita perdre el temps amb l'equivocada. DocumentFragment i replaceChildren aporten un 9–10 %, no l'ordre de magnitud que la llegenda els atribueix; el que multiplica per setze és intercalar una lectura al bucle d'inserció. reconciliar de 06-06 guanya 38× en el cas freqüent —una targeta que canvia— i perd davant de replaceChildren quan es reordena la llista sencera, límit que la lliçó mateixa va anunciar. I l'optimització més avorrida va resultar ser de les millors: no escriure el que no ha canviat, amb una empremta barata que estalvia 9.600 escriptures per render i abaixa pintarTargeta de 149 a 101 ms.

Saps animar barat: només transform i opacity, perquè són les úniques que es resolen en composició; amb FLIP per als moviments que canvien la geometria de debò, amb will-change posat i retirat en lloc de permanent, i amb l'observació decisiva que una animació CSS o de la Web Animations API sobreviu a un fil principal ocupat mentre que una de requestAnimationFrame no. Coneixes les tres eines d'aïllament: contain per prometre que el de dins no afecta el de fora, content-visibility: auto amb el seu contain-intrinsic-size obligatori —dues línies de CSS que van abaixar el render de 142 a 78 ms—, i will-change amb el seu cost en memòria de GPU. I saps quan toca requestAnimationFrame en lloc de setTimeout: per a tota feina visual, amb el patró perFotograma que s'executa més vegades que un throttle de 100 ms i tot i així perd dotze vegades menys fotogrames, perquè el que importa no és executar poc sinó executar en el moment correcte.

Has implementat la virtualització dues vegades: la llista progressiva amb sentinelles d'IntersectionObserver, que fa aparèixer la primera targeta en 11 ms però acumula nodes en desplaçar-se, i la finestra calculada amb alçada fixa i espaiadors, que manté 48 targetes al DOM sigui quina sigui la mida del tauler. I n'has vist la lletra petita sense adorns: trenca Ctrl+F, exigeix aria-setsize i aria-posinset per no destrossar l'experiència amb lector de pantalla, complica la impressió i els enllaços directes, obliga a files d'alçada uniforme i afegeix noranta línies amb un mode de fallada nou. D'aquí la regla de decisió: content-visibility per defecte, virtualització només quan el problema siguin els nodes. Aquí ho era, perquè la fila 9 ho exigia explícitament. Finalment, la delegació d'esdeveniments de 06-04 ha rebut la seva tercera justificació —38 ms de registre davant de 0,004 ms, i l'única manera sensata de conviure amb targetes que entren i surten del DOM a cada fotograma— confirmant un altre cop que les decisions de disseny bones solen resultar també les ràpides.

Queda un front, i és l'únic que no pots arreglar escrivint millor JavaScript, perquè passa abans que s'executi la primera línia del teu codi. Les files 1, 3 i 10 continuen intactes: 4,1 s d'LCP, 0,21 de CLS i 214 kB en 28 peticions abans que aparegui la primera targeta. Tot el que has optimitzat en tres lliçons —l'índex Map, la memòria cau, el worker, la neteja de fuites, la finestra virtual— no serveix de res durant els quatre segons en què la Lucía mira una pantalla en blanc des del mòbil. Allà mana una altra cosa: què descarrega el navegador, en quin ordre i quant d'això es fa servir realment. És el deute que 01-03 i 06-01 van deixar apuntat en parlar de defer i type="module", i el que 05-04 va ajornar explícitament en esmentar el tree shaking i els empaquetadors. Li toca ara, i tanca el mòdul: Càrrega Diferida i Divisió de Codi.

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