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
- Per què el DOM és car
- El pipeline de renderitzat, fase a fase
- Quina operació desperta quina fase
- Reflow, repaint i el càlcul d'estil síncron forçat
- La llista negra: quines lectures forcen un reflow
- Layout thrashing: el bucle que alterna lectura i escriptura
- El patró read-then-write, mesurat sobre les 600 targetes
- Agrupar insercions:
DocumentFragmentireplaceChildren - Actualitzar en lloc de redibuixar: què aporta de debò
reconciliar - Escriure només el que canvia
- Animar barat:
transformiopacity - Aïllar la feina:
will-change,containicontent-visibility - Sincronitzar amb
requestAnimationFrame(i no ambsetTimeout) - Virtualització I: sentinelles amb
IntersectionObserver - Virtualització II: la finestra calculada amb alçada fixa
- Les contrapartides de virtualitzar
- Delegació d'esdeveniments, ara amb números
- Les files 5 i 9, tancades
- Símptoma → causa probable → solució
- Errors Habituals i Consells
- Exercicis
- Conclusió
- 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.
- 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.
- 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__metaen 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 deDocumentFragment(06-05). display: noneivisibility: hiddenno 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.
- 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íncronDevTools 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.
- 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 sí |
| 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.
- 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.
- 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.
- Agrupar insercions:
DocumentFragment i replaceChildren
DocumentFragment i replaceChildrenA 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 | 1× |
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.
replaceChildrenno.
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 |
- Actualitzar en lloc de redibuixar: què aporta de debò
reconciliar
reconciliarA 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:
reconciliarguanya 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.
- 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 3classList.add/toggle - 3 assignacions de
textContent - 1
replaceChildrende la llista d'etiquetes, que destrueix i recrea els<li>d'etiqueta - 2 escriptures de
disabledi 2setAttribute('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.
disableds'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,datasetamb selectors CSS associats—, no tot per sistema.classList.addd'una classe ja present tampoc no invalida.classListés unDOMTokenListi comprova la pertinença abans de modificar. Aquesta és la raó que a 06-02 s'insistís a fer servirclassListen lloc d'esclafarclassName: 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.
- Animar barat:
transform i opacity
transform i opacityCanviem 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.
- Aïllar la feina:
will-change, contain i content-visibility
will-change, contain i content-visibilityAquestes 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.
I ara les tres regles que fan que will-change ajudi en lloc de perjudicar:
- Fes-lo servir amb moderació extrema. Cada capa consumeix memòria de GPU. Posar
will-change: transforma 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. - Posa'l i treu-lo. El correcte és afegir-lo just abans (amb una classe, o en fer
:hoversobre l'ancestre) i retirar-lo en acabar. Deixar-lo permanentment al CSS és exactament el que no s'ha de fer. - No el facis servir per «arreglar» animacions lentes. Si la teva animació toca
height,will-change: heightno 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 |
- Sincronitzar amb
requestAnimationFrame (i no amb setTimeout)
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:
requestAnimationFrameper a feina visual,setTimeout/debounceper 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.
- Virtualització I: sentinelles amb
IntersectionObserver
IntersectionObserverArribem 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+removeen esgotar les dades evita que l'observador continuï viu sense motiu, i evita el node separat de 09-03.destruir()ambdisconnect()és obligatori: unIntersectionObserverque 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.
- 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ò.
- 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 sí 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»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) | Sí |
Regla de decisió: fes servir
content-visibility: autoper 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-visibilitymé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.
- 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.
- 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.
- 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'
offsetHeightcosten 137 ms; les mateixes quaranta, agrupades, costen 4,3 ms. - No reconèixer
getComputedStylecom a lectura cara. És la més traïdora de la llista negra, perquè no sembla una mesura. - Fer servir
innerTextper costum. Força recàlcul;textContentno. Tret que necessitis específicament el text visible, fes servirtextContent. - 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
textContentque ja hi havia invalida igualment. Una empremta barata evita 9.600 escriptures per render. - Animar
top,left,widthoheight. Cada fotograma passa per layout. Fes servirtransformiopacity, i FLIP quan el canvi sigui de posició real. - Posar
will-changea 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: sizesense donar dimensions. L'element es tracta com si fos de mida zero i desapareix. - Fer servir
content-visibility: autosensecontain-intrinsic-size. La barra de desplaçament es torna boja. Ambauto Xpx, el navegador recorda la mida real. - Fer servir
setTimeoutper a feina visual. No se sincronitza amb la pantalla, continua corrent en pestanyes amagades i produeix fotogrames dobles.requestAnimationFrame. - Fer servir
throttle(fn, 100)ascroll. Millor que res, pitjor querequestAnimationFrame: s'executa menys vegades i perd més fotogrames. - Oblidar
{ passive: true }ascroll,wheelitouchstart. El navegador ha d'esperar a veure si cridespreventDefault. - Virtualitzar sense arreglar l'accessibilitat. Sense
aria-setsizeiaria-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('*').lengtha 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.setAttributei 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.
- El tauler canònic del Taller Nómada: 6 tasques en tres columnes.
- L'historial de canvis: 4.200 línies de text d'una sola alçada, que la Marta consulta amb Ctrl+F constantment.
- La vista d'impressió de l'informe trimestral: 600 files que cal enviar a la impressora d'una vegada.
- 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__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ó:
- 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.
- 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 ambtransform.
(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
- 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
