La fila 8 de la línia base de 09-01 diu +37,7 MB retinguts després de 200 filtratges. És la més silenciosa de les deu i, a la llarga, la més greu: un problema de velocitat es nota i es mesura; un problema de memòria es manifesta com «la pestanya es posa estranya al cap d'una estona» i com a tancaments inexplicables en mòbils. La Marta deixa Nómada Tasques oberta tota la jornada; al cap de tres hores de filtrar, ordenar i marcar tasques, l'aplicació consumeix més d'un gigabyte i el navegador la mata. Aquesta lliçó explica com funciona la memòria en JavaScript —pila, monticle, referències i el recol·lector d'escombraries per abastabilitat—, quines són les cinc causes clàssiques de fuita al navegador amb el seu exemple concret a Nómada Tasques, com es diagnostica una fuita amb el panell Memory de DevTools fent servir el patró de les tres instantànies, què són i quan serveixen de debò WeakMap, WeakSet, WeakRef i FinalizationRegistry, i com es construeix una neteja sistemàtica amb mètodes destruir() i AbortController que fa que les fuites deixin d'aparèixer. En acabar hauràs trobat i arreglat la fuita real de la vista.
Contingut
- Per què la memòria importa en una aplicació de llarga vida
- Pila, monticle i referències
- El recol·lector d'escombraries: abastabilitat
- Per què no n'hi ha prou de comptar referències
- Generacional i marcatge-escombratge
- Què és exactament una fuita de memòria
- Les cinc causes clàssiques
- Nodes separats i per què la delegació els evita
- Diagnòstic amb DevTools: el panell Memory
- El patró de les tres instantànies
- Trobar la fuita real de Nómada Tasques
- Arreglar-la
- Referències febles:
WeakMap,WeakSet,WeakRefiFinalizationRegistry - Neteja sistemàtica:
destruir()iAbortController - Detectar fuites en integració contínua
- Errors Habituals i Consells
- Exercicis
- Conclusió
- Per què la memòria importa en una aplicació de llarga vida
En una pàgina web tradicional cada navegació descarta tot: si alguna cosa es filtra, es neteja en canviar de pàgina. Nómada Tasques és una aplicació d'una sola pàgina —té encaminador propi (vista/encaminador.js), service worker i estat persistent— i pot estar oberta vuit hores sense recarregar-se mai. Allà, una fuita petita es converteix en un problema gran per acumulació.
Els símptomes, en ordre d'aparició:
| Símptoma | Causa habitual |
|---|---|
| L'aplicació va bé en obrir-la i malament al cap d'una estona | Fuita acumulativa |
| Estrebades periòdiques cada pocs segons | Recol·leccions cada vegada més cares |
| El desplaçament es torna irregular | Monticle gran: cada recol·lecció roba fotogrames |
| «Aw, Snap» / pestanya tancada pel sistema | Límit de memòria assolit |
| Només passa en mòbils | El límit allà és molt més baix |
I una precisió important des del principi: fer servir molta memòria no és una fuita. Un tauler de 600 tasques ocupa el que ocupa. Una fuita és memòria que l'aplicació ja no necessita però que no pot alliberar. La diferència es mesura, i aquest capítol ensenya a mesurar-la.
- Pila, monticle i referències
JavaScript guarda les dades en dos llocs.
La pila (stack) és una estructura petita i rapidíssima on viuen el context d'execució de cada crida (03-05) i els valors primitius: números, cadenes curtes, booleans, null, undefined, símbols. S'allibera sola en sortir de la funció: no hi ha res a gestionar.
El monticle (heap) és una regió gran on viuen els objectes: objectes literals, arrays, funcions, instàncies de classe, nodes del DOM. El que guarda una variable no és l'objecte, sinó una referència a la seva posició al monticle.
let hores = 12; // primitiu: el valor és a la pila
let tasca = { id: 1, hores: 12 }; // objecte al monticle; `tasca` guarda una referència
let altra = tasca; // es copia la REFERÈNCIA, no l'objecte
altra.hores = 20;
console.log(tasca.hores); // 20 ← és el mateix objecte (04-08)
tasca = null; // es deixa anar una referència…
console.log(altra.hores); // 20 ← …però `altra` el manté viuAquest últim bloc és tota la teoria que cal: un objecte viu mentre algú pugui arribar-hi. La pregunta rellevant mai no és «he posat aquesta variable a null?», sinó «queda algun camí que porti fins a aquest objecte?».
- El recol·lector d'escombraries: abastabilitat
En C reserves i alliberes a mà. En JavaScript hi ha un recol·lector d'escombraries (garbage collector, GC) que allibera automàticament allò que ja no es pot abastar. El seu criteri és l'abastabilitat (reachability).
El motor manté un conjunt d'arrels (roots): punts de partida que sempre estan vius.
| Arrel | Exemple |
|---|---|
| L'objecte global | window, globalThis |
| La pila de crides activa | Variables locals de les funcions en curs |
L'arbre del DOM abastable des de document |
document.body i els seus descendents |
| Els mòduls ES carregats i el seu estat | El const tauler d'app.js (05-04) |
| Gestors registrats i temporitzadors actius | El que reté un setInterval pendent |
Un objecte és abastable si existeix una cadena de referències des d'alguna arrel fins a ell. El que no és abastable és escombraria i s'allibera. Punt.
flowchart LR
R(("Arrels")) --> A["app.js: tauler"]
A --> B["Tasca 1"]
A --> C["Tasca 2"]
R --> D["document"]
D --> E["div.tauler"]
E --> F["li[data-id='1']"]
G["Tasca antiga"] -.-> H["Objecte orfe"]
style G fill:#fdd,stroke:#c00
style H fill:#fdd,stroke:#c00
Al diagrama, Tasca antiga i Objecte orfe es referencien entre ells però cap arrel no hi arriba: són escombraries, encara que tinguin referències. Aquesta illa és exactament el cas que enfonsa l'estratègia de l'apartat següent.
- Per què no n'hi ha prou de comptar referències
L'estratègia més simple imaginable seria portar en cada objecte un comptador de quantes referències hi apunten i alliberar-lo quan arribés a zero. És el que feien alguns sistemes antics, i no funciona, per un motiu concret: els cicles.
function crearCicle() {
const tasca = { id: 1, titol: 'Redissenyar la sala polivalent' };
const revisio = { revisor: 'Marta' };
tasca.revisio = revisio; // tasca → revisio
revisio.tasca = tasca; // revisio → tasca ← cicle
return null; // ningú de fora no en guarda cap dels dos
}
crearCicle();
// Amb recompte de referències: cadascun té 1 referència → no s'alliberen mai. FUITA.
// Amb abastabilitat: cap arrel no hi arriba → escombraries. S'alliberen.Els cicles no són rars: apareixen tan bon punt un node del DOM guarda una referència a un objecte que al seu torn guarda el node, en qualsevol estructura pare-fill amb enllaç cap amunt, i en pràcticament qualsevol graf. Per això tots els motors moderns fan servir abastabilitat, no recompte.
La conseqüència pràctica és alliberadora: no t'has de preocupar pels cicles. El que sí que has de vigilar és el contrari: referències des d'una arrel viva cap a alguna cosa que ja no necessites. Un gestor a window, una entrada en un Map de mòdul, un setInterval corrent.
- Generacional i marcatge-escombratge
L'algorisme base és marcatge i escombratge (mark and sweep): partir de les arrels, marcar tot el que és abastable, i escombrar el que no està marcat. Fer-ho sobre un monticle de centenars de megabytes a cada recol·lecció seria caríssim, així que els motors hi afegeixen una observació estadística coneguda com a hipòtesi generacional: la majoria dels objectes moren joves.
D'aquí la divisió del monticle:
flowchart TD
subgraph N["Generació jove (new space) · uns pocs MB"]
direction LR
A["Viver"] -->|"sobreviu a una<br/>recol·lecció menor"| B["Intermedi"]
end
B -->|"sobreviu a dues:<br/>promoció"| C
subgraph O["Generació vella (old space) · centenars de MB"]
C["Objectes longeus:<br/>tauler, vista, mòduls"]
end
N -.->|"recol·lecció MENOR<br/>freqüent i molt ràpida (~1 ms)"| N
O -.->|"recol·lecció MAJOR<br/>rara i cara (desenes de ms)"| O
| Recol·lecció menor (scavenge) | Recol·lecció major (mark-compact) | |
|---|---|---|
| Zona | Generació jove | Tot el monticle |
| Freqüència | Molt alta (diverses per segon) | Baixa |
| Cost típic | < 1–2 ms | Desenes de ms, de vegades més |
| Impacte visible | Cap | Pot robar fotogrames |
Tres conseqüències pràctiques per al rendiment:
- Crear molts objectes efímers és barat. L'objecte que crees en un
mapi descartes de seguida mor a la generació jove i la seva recol·lecció és gairebé de franc. Això desmunta un altre mite: «evita crear objectes als bucles» és, en general, un consell obsolet. - Retenir objectes és car. El que sobreviu es promociona a la generació vella, i aquesta sí que exigeix recol·leccions majors cares. Una fuita no només consumeix memòria: encareix totes les recol·leccions futures.
- Els motors moderns recol·lecten en paral·lel i de manera incremental, així que les pauses són molt menors que fa uns anys. Però no són zero, i al carril Main del panell Performance apareixen com a blocs grisos etiquetats Minor GC / Major GC.
Un últim apunt: no pots forçar la recol·lecció des del teu codi. No existeix cap API per fer-ho (Chrome ofereix un botó de paperera al panell Memory i la bandera --expose-gc a Node, i tots dos són eines de diagnòstic, no de producció). L'única cosa que controles és quines referències mantens.
- Què és exactament una fuita de memòria
Una fuita de memòria és memòria que l'aplicació ja no necessita però que continua sent abastable, i per tant el recol·lector no pot alliberar.
Les dues meitats importen. Si no és abastable, s'allibera i no hi ha fuita encara que el consum sigui alt. Si és abastable però sí que la necessites, és ús legítim. La fuita és la intersecció: abastable i innecessari.
El diagnòstic es redueix sempre a la mateixa pregunta: quina cadena de referències, des de quina arrel, manté viu això? DevTools la respon literalment, amb un camí anomenat retainers.
- Les cinc causes clàssiques
Gairebé totes les fuites del navegador cauen en cinc categories. Anem amb les cinc, cadascuna amb el seu cas a Nómada Tasques.
7.1 Variables globals accidentals
En mode no estricte, assignar a un identificador no declarat crea una propietat de window (01-04). I window és una arrel: el que pengi d'allà no s'allibera mai.
// ✗ Sense 'use strict' ni mòduls, això crea window.cacheTasques
function pintar(tasques) {
cacheTasques = tasques; // ← falta const/let. Adéu a 600 tasques, per sempre
}Nómada Tasques està fora de perill gairebé per accident: els mòduls ES són estrictes per defecte (05-04), així que aquesta línia llançaria ReferenceError. I no-undef d'ESLint (08-02) ho caça abans i tot d'executar res. Però la variant deliberada continua sent possible i és igual de perjudicial:
// ✗ Igual de dolent, i perfectament legal
window.__depuracioTauler = tauler; // «només per depurar»…Aquest window.__depuracioTauler, posat un dia per inspeccionar des de la consola i mai retirat, manté viu el tauler sencer encara que la vista es destrueixi. Si necessites una cosa així, posa-la darrere d'una comprovació d'entorn i treu-la en la neteja.
7.2 Temporitzadors mai cancel·lats
Un setInterval pendent és una arrel: manté viva la seva funció i tot el que el seu closure captura. El CanalTauler de 07-04 té un heartbeat amb BATEC_MS = 25000, i aquí hi ha la versió amb la fallada:
// ✗ js/dades/temps-real.js — versió amb fuita
#enObrir() {
this.#canviarEstat(ESTATS.OBERT);
// Cada reconnexió arrenca UN ALTRE interval. Els anteriors continuen vius.
this.#batec.interval = setInterval(() => {
this.#enviar({ tipus: 'ping' });
}, CanalTauler.BATEC_MS);
}En una jornada amb xarxa inestable hi pot haver quaranta reconnexions: quaranta intervals actius, quaranta pings cada 25 segons, quaranta closures retenint el canal (i amb ell, tot el que el canal referencia). I a més un error funcional: el servidor rep quaranta pings on n'espera un.
// ✓ Cancel·lar SEMPRE abans de crear, i cancel·lar en tancar
#aturarBatec() {
clearInterval(this.#batec.interval);
clearTimeout(this.#batec.vigilant);
this.#batec.interval = null;
this.#batec.vigilant = null;
}
#enObrir() {
this.#canviarEstat(ESTATS.OBERT);
this.#aturarBatec(); // ← idempotent: segur de cridar-lo sempre
this.#batec.interval = setInterval(() => {
this.#enviar({ tipus: 'ping' });
}, CanalTauler.BATEC_MS);
}
#enTancar() {
this.#aturarBatec();
// …reconnexió amb retrocés exponencial, com a 07-04…
}La regla general: per cada setInterval ha d'existir un clearInterval a la ruta de neteja, i la funció que atura ha de ser idempotent per poder cridar-la sense por.
setTimeout és menys perillós perquè s'autolimita, però un temporitzador de 30 segons reté el seu closure durant 30 segons encara que la vista ja s'hagi destruït, i el seu callback s'executarà sobre un estat inexistent. Cancel·la'ls també.
7.3 Gestors sobre nodes eliminats
Aquesta és la que tanca l'avís de 06-05 sobre innerHTML = ''.
// ✗ Un gestor per targeta, i esborrat amb innerHTML
function pintarTot(tasques) {
contenidor.innerHTML = ''; // els nodes se'n van de l'arbre…
for (const tasca of tasques) {
const li = crearTargeta(tasca);
li.addEventListener('click', () => obrirDetall(tasca)); // ← closure amb la tasca
contenidor.append(li);
}
}La creença estesa és que innerHTML = '' neteja els gestors. No és cert i no és fals: depèn. El navegador allibera un node eliminat i els seus gestors tan bon punt el node deixa de ser abastable. El problema és que la funció gestora és un closure que captura tasca, i si qualsevol altra cosa continua referenciant aquest node —un array de nodes, un Map de memòria cau, una variable de la consola, un observador— el node, el gestor, el closure i la tasca es queden tots vius, fora de l'arbre però en memòria. Això s'anomena node separat (detached node).
Amb 600 targetes i 200 filtratges són fins a 120.000 nodes i 120.000 closures potencialment retinguts. Allà hi ha bona part dels 37,7 MB.
La solució de fons no és netejar millor, sinó no registrar 600 gestors: és la delegació de 06-04.
7.4 Closures que retenen més del que sembla
A 03-04 va quedar dit que un closure manté viu l'entorn on va néixer, i allà es remetia a aquesta lliçó. Aquí hi ha la part incòmoda: el closure no captura només el que fa servir; captura l'entorn, i el motor només pot descartar allò que demostri que no es fa servir.
// ✗ El gestor només necessita l'id, però l'entorn té molt més
function preparar(tasques) {
const totesLesTasques = tasques; // 600 objectes
const historic = carregarHistoric(); // 40.000 registres, 9 MB
const id = tasques[0].id;
document.addEventListener('tasca:canviada', () => {
console.log(`Ha canviat alguna cosa mentre miràvem ${id}`); // només fa servir `id`
});
}Aquest gestor viu a document, que és una arrel. I encara que només faci servir id, l'entorn on va néixer conté totesLesTasques i historic. Els motors fan anàlisis per descartar variables no utilitzades, però aquesta anàlisi té límits coneguts: n'hi ha prou que al mateix àmbit hi hagi un eval, un debugger, o una altra funció que sí que faci servir historic (totes les funcions d'un mateix àmbit comparteixen l'objecte d'entorn) perquè es retingui tot.
// ✓ Extreure només el necessari i crear el gestor en un àmbit petit
function preparar(tasques) {
const historic = carregarHistoric();
processar(historic); // es fa servir aquí i s'acaba
registrarAvis(tasques[0].id); // ← àmbit nou i mínim
}
function registrarAvis(id) {
document.addEventListener('tasca:canviada', () => {
console.log(`Ha canviat alguna cosa mentre miràvem ${id}`);
});
}La regla: crea els closures de llarga vida a l'àmbit més petit possible, passant-los només les dades que necessiten.
7.5 Memòries cau que creixen sense límit
A 09-02 vas memoïtzar formatarData i es va dir que la tercera condició era el límit de mida. Aquest n'és el motiu:
// ✗ Memoïtzació sobre una clau il·limitada
const cercarMemoitzat = memoitzar((text) => [...tauler].filter(
(t) => t.titol.toLowerCase().includes(text.toLowerCase())
));
// L'usuari escriu: 's', 'se', 'ser', 'seri', ... una clau nova per pulsació,
// i cada valor és un array amb fins a 600 tasques. Creix per sempre.Amb 200 cerques diferents i arrays de resultats, la memòria cau sola pot pesar desenes de megabytes. I memoitzar fa servir un Map de mòdul: una arrel viva.
Dues solucions correctes. La primera, una memòria cau amb sostre i política d'expulsió (LRU, least recently used):
// js/util/cache-lru.js
/**
* Memòria cau amb mida màxima. En omplir-se expulsa l'entrada utilitzada fa més temps.
* Aprofita que Map conserva l'ordre d'inserció.
*/
export class CacheLRU {
#maxim;
#mapa = new Map();
constructor(maxim = 100) { this.#maxim = maxim; }
get(clau) {
if (!this.#mapa.has(clau)) return undefined;
const valor = this.#mapa.get(clau);
this.#mapa.delete(clau); // reinserir-la la mou al final: passa a ser la més recent
this.#mapa.set(clau, valor);
return valor;
}
set(clau, valor) {
if (this.#mapa.has(clau)) this.#mapa.delete(clau);
this.#mapa.set(clau, valor);
if (this.#mapa.size > this.#maxim) {
this.#mapa.delete(this.#mapa.keys().next().value); // la més antiga
}
}
get mida() { return this.#mapa.size; }
netejar() { this.#mapa.clear(); }
}La segona, quan la clau és un objecte, un WeakMap (apartat 13), que allibera l'entrada automàticament quan la clau deixa de ser abastable.
I aquí es tanca l'avís de 09-02 sobre l'índex #perId del Tauler: si eliminar(id) treu de l'array però no del Map, la tasca continua sent abastable des de l'índex. No és només un error de dades: és una fuita.
- Nodes separats i per què la delegació els evita
Un node separat (detached DOM node) és un element que ja no és a l'arbre del document però al qual continua apuntant algú des de JavaScript. No es pinta, no es pot seleccionar amb querySelector, no serveix per a res… i ocupa memòria, arrossegant a més tots els seus descendents.
// ✗ Guardar nodes en una estructura de llarga vida
const nodesPerId = new Map();
function pintar(tasques) {
contenidor.replaceChildren();
for (const tasca of tasques) {
const li = crearTargeta(tasca);
nodesPerId.set(tasca.id, li); // ← el Map reté el node encara que surti de l'arbre
contenidor.append(li);
}
}Un detall que sorprèn tothom: n'hi ha prou de retenir un fill per retenir tot l'arbre al qual pertanyia, perquè cada node apunta al seu parentNode. Guardar un <span> d'una targeta pot retenir la targeta sencera, la llista i la columna.
La delegació de 06-04 elimina d'arrel la causa més freqüent:
| Un gestor per targeta | Delegació al contenidor | |
|---|---|---|
| Gestors amb 600 tasques | 600 (o 1.800, amb tres accions) | 1 |
| Closures retinguts | 600 | 1 |
| En eliminar una targeta | Cal retirar-ne el gestor | No hi ha res a retirar |
| Targetes creades després | Cal registrar-les | Funcionen soles |
| Risc de node separat | Alt | Pràcticament nul |
Per això l'app.js de 06-06 registra un únic click a .tauler i esbrina la targeta amb closest('[data-id]'). Allò es va justificar per claredat; ara té també una justificació de memòria mesurable.
- Diagnòstic amb DevTools: el panell Memory
Prou teoria: mirem el monticle real. El panell Memory de DevTools ofereix tres eines, i cadascuna respon a una pregunta diferent.
| Eina | Pregunta que respon | Cost |
|---|---|---|
| Heap snapshot (instantània del monticle) | Què hi ha viu ara i qui ho reté? | Pausa la pàgina uns segons |
| Allocation instrumentation on timeline (assignacions en el temps) | Quan s'assigna i què sobreviu? | Alt: alenteix molt |
| Allocation sampling (mostreig) | Quina funció assigna més memòria? | Baix: serveix en sessions llargues |
A més, a la pestanya Performance hi ha dos instruments complementaris: la casella Memory, que dibuixa les corbes de monticle de JS, nodes del DOM, oients d'esdeveniments i documents al llarg de la gravació, i l'eina Detached Elements (a More tools), que llista directament els nodes separats.
Com llegir una instantània del monticle. En obrir-la veus una taula de constructors amb quatre columnes:
| Columna | Significat |
|---|---|
| Constructor | Tipus de l'objecte: Tasca, Array, HTMLLIElement, (closure), system / Context |
| Distance | Distància en salts des de l'arrel. Un número petit i inesperat és sospitós |
| Shallow Size | Memòria de l'objecte en si, sense el que referencia |
| Retained Size | La important: memòria que s'alliberaria si aquest objecte desaparegués |
Retained Size és la que assenyala el culpable. Un Map de 200 entrades té un shallow size ridícul, però si cada entrada reté un array de 600 tasques el seu retained size serà de desenes de megabytes.
I a sota, la secció Retainers: la cadena de qui referencia qui fins a arribar a una arrel. Aquest plafó és literalment la resposta a «per què continua viu això?», i és on acaba tota investigació de fuites.
Tres vistes seleccionables a dalt a l'esquerra:
- Summary: agrupat per constructor. La vista per defecte.
- Comparison: compara dues instantànies i mostra els objectes creats i eliminats entre totes dues, amb
# New,# Deletedi# Delta. És la vista que resol el cas. - Containment: el graf complet des de les arrels. Útil per explorar a mà.
Un truc imprescindible: al filtre de la vista Summary, escriu Detached. Apareixen tots els nodes separats agrupats, amb la seva mida retinguda.
- El patró de les tres instantànies
Aquest és el procediment estàndard per confirmar una fuita. S'anomena de les tres instantànies i funciona perquè separa el soroll del creixement real.
flowchart LR
A["Obrir en incògnit<br/>i arribar a l'estat base"] --> B["Paperera:<br/>forçar recol·lecció"]
B --> C["Instantània 1"]
C --> D["Repetir l'acció<br/>sospitosa N vegades"]
D --> E["Tornar a l'estat base"]
E --> F["Paperera"]
F --> G["Instantània 2"]
G --> H["Repetir l'acció<br/>N vegades més"]
H --> I["Tornar a l'estat base<br/>+ paperera"]
I --> J["Instantània 3"]
J --> K["Comparar la 3 amb la 1"]
Les claus del mètode:
- Tornar sempre al mateix estat abans de cada instantània. Si acabes amb un filtre posat i abans no el tenies, la diferència no significa res.
- Forçar la recol·lecció amb la icona de paperera abans de cada instantània. Sense això veuràs escombraries pendents de recollir i creuràs que hi ha fuita on no n'hi ha. (Prendre una instantània ja força una recol·lecció, però prémer la paperera ho fa explícit i evita dubtes.)
- Comparar la 3 amb la 1, no la 2 amb la 1. La primera ronda d'una acció crea legítimament estructures que es reutilitzaran després (memòries cau inicialitzades, plantilles compilades). El que apareix entre la 2 i la 3 és creixement pur, i comparar la 3 contra la 1 ho fa evident.
- Si el nombre d'objectes creix linealment amb les repeticions, és una fuita. Si creix i s'estabilitza, és una memòria cau amb sostre: ús legítim.
I un procediment complementari molt més barat per a la primera ullada, que a més respon a «hi ha fuita, sí o no?» en un minut:
- Pestanya Performance, casella Memory marcada.
- Grava mentre repeteixes l'acció 20 vegades.
- Mira la corba JS Heap: el normal és un patró en dents de serra que torna a la mateixa base. Preocupant és una serra la base de la qual puja esglaó a esglaó.
- Mira les corbes Nodes i Listeners: si pugen i no baixen, ja saps de quin tipus és la fuita.
- Trobar la fuita real de Nómada Tasques
Anem al cas. Reproduïm l'escenari de la línia base amb un script a la consola, perquè sigui repetible:
// Consola: 200 filtratges sobre el tauler de 600 tasques
const RESPONSABLES = ['Iván', 'Lucía', 'Marta', null];
async function estressarFiltres(vegades = 200) {
for (let i = 0; i < vegades; i += 1) {
vista.actualitzar({ filtres: { responsable: RESPONSABLES[i % 4], text: `t${i % 7}` } });
await new Promise((r) => setTimeout(r, 0)); // deixa respirar el navegador (09-02)
}
vista.actualitzar({ filtres: { responsable: null, text: '' } }); // tornar a l'estat base
}Aplicant el patró de les tres instantànies:
| Mida del monticle | Nodes del DOM | Oients | Detached HTMLLIElement |
|
|---|---|---|---|---|
| Instantània 1 (base) | 12,4 MB | 7.812 | 41 | 0 |
| Instantània 2 (després de 200) | 31,8 MB | 7.812 | 241 | 20.914 |
| Instantània 3 (després de 400) | 50,1 MB | 7.812 | 441 | 41.828 |
El veredicte és immediat: creixement lineal. Cada tanda de 200 filtratges hi afegeix uns 19 MB, 200 oients i ~20.900 nodes separats. No és una memòria cau estabilitzant-se: és una fuita.
Ara la vista Comparison entre la 3 i la 1, ordenada per Retained Size, amb els sospitosos:
| Constructor | # New | # Deleted | # Delta | Retained (delta) |
|---|---|---|---|---|
Detached HTMLLIElement |
41.828 | 0 | +41.828 | 22,1 MB |
(closure) |
41.628 | 0 | +41.628 | 9,4 MB |
Array |
400 | 0 | +400 | 5,8 MB |
system / Context |
400 | 0 | +400 | 0,4 MB |
I ara la part que resol el cas: seleccionar un Detached HTMLLIElement i llegir-ne els retainers. La cadena és aquesta:
Detached HTMLLIElement
└─ value in Map ← el reté un Map
└─ #nodesPerId in TaulerVista
└─ vista in app.js (mòdul) ← arrelI per a un dels (closure):
Dues fuites diferents, trobades en dos minuts. Anem a mirar el codi culpable.
// ✗ js/vista/tauler-vista.js — el codi amb les dues fuites
export class TaulerVista {
#contenidor; #resum; #estat;
#nodesPerId = new Map(); // «índex per accelerar la reconciliació» (09-02)
render() {
const visibles = this.#visibles();
// FUITA 2: un gestor NOU a cada render, sobre window, que a més
// captura `visibles` (fins a 600 tasques) al seu closure
window.addEventListener('resize', () => this.#ajustarAlcades(visibles));
for (const { estat } of COLUMNES) {
const columna = $(`.columna[data-estat="${estat}"]`, this.#contenidor);
const delEstat = perEstat[estat] ?? [];
reconciliar(
$('.llista-tasques', columna),
delEstat,
(t) => t.id,
(t, node) => {
this.#nodesPerId.set(t.id, node); // FUITA 1: s'hi afegeix… i mai se'n treu
return pintarTargeta(t, node, this.#estat.avui);
}
);
}
}
}Totes dues fallades són completament raonables vistes per separat, i aquest és el missatge d'aquest apartat. El Map es va afegir seguint el consell de 09-02 d'indexar per clau. L'addEventListener es va posar on semblava natural, al costat del codi que el necessita. Cap dels dos no es veu en una revisió de codi, cap dels dos no trenca cap de les 124 proves, i amb sis tasques cap dels dos no es nota. Les fuites no es troben llegint: es troben mesurant.
- Arreglar-la
Les dues correccions, amb la seva raó:
// ✓ js/vista/tauler-vista.js
export class TaulerVista {
#contenidor; #resum; #estat;
#nodesPerId = new Map();
#controlador = new AbortController(); // governa TOTS els gestors de la vista
constructor({ contenidor, resum, tauler, avui = AVUI }) {
this.#contenidor = contenidor;
this.#resum = resum;
this.#estat = { tauler, avui, filtres: { responsable: null, text: '' }, ordre: 'prioritat' };
this.#prepararColumnes();
// ARRANJAMENT 2: es registra UNA vegada, al constructor, amb signal per poder retirar-lo.
// I no captura `visibles`: llegeix l'estat actual quan s'executa.
window.addEventListener('resize', this.#enRedimensionar, {
signal: this.#controlador.signal,
passive: true
});
}
// Camp de classe amb funció fletxa: `this` correcte i mateixa referència sempre (04-02)
#enRedimensionar = throttle(() => this.#ajustarAlcades(this.#visibles()), 100);
render() {
const visibles = this.#visibles();
const perEstat = Object.groupBy(visibles, (t) => t.estat);
const vius = new Set(visibles.map((t) => t.id)); // ARRANJAMENT 1, part A
for (const { estat } of COLUMNES) {
const columna = $(`.columna[data-estat="${estat}"]`, this.#contenidor);
const delEstat = perEstat[estat] ?? [];
reconciliar($('.llista-tasques', columna), delEstat, (t) => t.id, (t, node) => {
this.#nodesPerId.set(t.id, node);
return pintarTargeta(t, node, this.#estat.avui);
});
}
// ARRANJAMENT 1, part B: purgar de l'índex el que ja no és a l'arbre
for (const id of this.#nodesPerId.keys()) {
if (!vius.has(id)) this.#nodesPerId.delete(id);
}
// …resum i esdeveniment, igual que a 06-06…
}
/**
* Allibera tot el que aquesta vista reté fora de si mateixa.
* Sense això, destruir la vista no basta: window continua apuntant als seus gestors.
*/
destruir() {
this.#controlador.abort(); // retira TOTS els gestors registrats amb signal
this.#enRedimensionar.cancelar?.();
this.#nodesPerId.clear();
this.#contenidor.replaceChildren();
this.#estat = null;
}
}Quatre punts que mereixen atenció:
{ signal }aaddEventListenerés l'eina clau de la lliçó: un solabort()retira tots els gestors registrats amb aquell senyal, per molts que siguin i estiguin on estiguin. És el mateixAbortControllerque vas fer servir a 07-03 per cancel·lar peticions, aplicat a esdeveniments (06-04).- El gestor és un camp de classe amb funció fletxa, no una fletxa creada al moment. Això garanteix que la referència és sempre la mateixa, requisit imprescindible si algun dia volguessis retirar-lo amb
removeEventListener. - El gestor ja no captura
visibles. Llegeix l'estat actual quan s'executa. Un closure de llarga vida no ha de congelar dades grans. - La purga de l'índex és l'aplicació literal de l'avís de 09-02: mantenir un índex obliga a mantenir-lo en les dues direccions.
I el mesurament, refent el patró de les tres instantànies:
| Mesura | Abans | Després |
|---|---|---|
| Monticle després de 400 filtratges | 50,1 MB | 12,8 MB |
| Creixement retingut | +37,7 MB | +0,4 MB |
Detached HTMLLIElement |
41.828 | 0 |
| Oients d'esdeveniments | 441 | 41 |
| Pauses de recol·lecció major en 60 s | 14 (màx. 84 ms) | 3 (màx. 11 ms) |
Aquesta última fila és el benefici que ningú no espera: arreglar la fuita també ha fet l'aplicació més ràpida, perquè un monticle petit es recol·lecta ràpid. La fila 8 de la línia base està resolta.
- Referències febles:
WeakMap, WeakSet, WeakRef i FinalizationRegistry
WeakMap, WeakSet, WeakRef i FinalizationRegistryJavaScript ofereix quatre mecanismes per referenciar sense retenir. Abans de res, l'advertiment honest: el 95 % del codi correcte no necessita cap dels quatre. La solució a una fuita és gairebé sempre netejar bé, no fer servir referències febles. Però hi ha un cas on WeakMap és exactament l'eina adequada.
| Eina | Què guarda | S'allibera quan… | Iterable | Ús realista |
|---|---|---|---|---|
WeakMap |
Clau objecte → valor qualsevol | La clau deixa de ser abastable | No | Metadades associades a objectes o nodes del DOM |
WeakSet |
Objectes | L'objecte deixa de ser abastable | No | Marcar objectes ja processats |
WeakRef |
Una referència solta | L'objectiu deixa de ser abastable | — | Memòries cau molt grans. Rar |
FinalizationRegistry |
Un callback de neteja | Després de recol·lectar l'objectiu | — | Alliberar recursos externs. Molt rar |
Que WeakMap i WeakSet no siguin iterables no és un descuit: si els poguessis recórrer, el resultat dependria de quan hagués passat el recol·lector, i el teu programa seria no determinista. La impossibilitat d'iterar és el que fa que la semàntica sigui segura.
El cas legítim a Nómada Tasques: associar dades a nodes del DOM sense retenir-los.
// js/vista/targeta.js
// Clau: el node. Valor: metadades que no volem ficar a dataset (04-01)
const metadades = new WeakMap();
export function crearTargeta(tasca, avui) {
const li = crearElement('li', { class: 'tasca', 'data-id': String(tasca.id) });
metadades.set(li, {
pintadaEl: performance.now(),
versioDades: tasca.versio,
alcadaCalculada: null
});
return li;
}
export function metadadesDe(node) {
return metadades.get(node) ?? null;
}La diferència és exactament la fuita de l'apartat 11: amb un Map normal, cada node pintat quedaria retingut per sempre pel mapa. Amb WeakMap, quan el node surt de l'arbre i ningú més no el referencia, l'entrada desapareix sola. És l'única estructura que dóna memòria cau sense obligació de purga.
// Demostració conceptual del contrast
const forta = new Map();
const feble = new WeakMap();
let node = document.createElement('li');
forta.set(node, 'metadades');
feble.set(node, 'metadades');
node = null;
// `forta` continua retenint el node: FUITA.
// `feble` no el reté: en la propera recol·lecció, l'entrada desapareix.I el seu límit, que cal conèixer: WeakMap només serveix quan la clau és l'objecte la vida del qual mana. Per memoïtzar formatarData, la clau de la qual és la cadena '2026-09-05', WeakMap no val (les cadenes no poden ser claus febles): allà la resposta és la CacheLRU de l'apartat 7.5.
WeakRef i FinalizationRegistry existeixen, i convé reconèixer-los, però la mateixa especificació desaconsella dependre'n: no hi ha cap garantia de quan —ni de si— s'executarà la finalització. No hi posis mai lògica necessària per a la correcció del teu programa.
// Exemple il·lustratiu, NO recomanat com a patró habitual
const registre = new FinalizationRegistry((etiqueta) => {
console.log(`Recol·lectat: ${etiqueta}`); // pot no executar-se MAI
});
registre.register(new Worker(url), 'worker de planificació');
- Neteja sistemàtica:
destruir() i AbortController
destruir() i AbortControllerLa lliçó estructural de tot l'anterior: qualsevol objecte que registri alguna cosa fora de si mateix necessita una manera de desfer-ho. Gestors a window o document, temporitzadors, observadors, sockets, workers, subscripcions. Aquest contracte s'anomena destruir(), i convé que sigui sistemàtic a tot el projecte.
| El que registra | Com es desfà |
|---|---|
addEventListener |
removeEventListener o, millor, { signal } + abort() |
setInterval / setTimeout |
clearInterval / clearTimeout |
IntersectionObserver, ResizeObserver, MutationObserver |
.disconnect() |
WebSocket |
.close() |
Worker |
.terminate() |
fetch en curs |
AbortController.abort() (07-03) |
requestAnimationFrame |
cancelAnimationFrame |
| Entrades en memòries cau i mapes propis | .clear() |
Aplicat al CanalTauler de 07-04, que és la classe amb més recursos externs del projecte:
// js/dades/temps-real.js
export class CanalTauler extends EventTarget {
#controlador = new AbortController();
connectar() {
if (this.#controlador.signal.aborted) throw new Error('Canal destruït');
this.#socket = new WebSocket(this.#url);
const { signal } = this.#controlador;
this.#socket.addEventListener('open', () => this.#enObrir(), { signal });
this.#socket.addEventListener('message', (e) => this.#enMissatge(e), { signal });
this.#socket.addEventListener('close', () => this.#enTancar(), { signal });
this.#socket.addEventListener('error', () => this.#enError(), { signal });
// Fins i tot els gestors de window queden governats pel mateix senyal
window.addEventListener('online', () => this.connectar(), { signal });
}
/** Allibera fil, socket, temporitzadors i gestors. Idempotent. */
destruir() {
this.#tancatAProposit = true;
this.#aturarBatec(); // clearInterval + clearTimeout
clearTimeout(this.#reintentId);
this.#socket?.close(1000, 'destruit');
this.#socket = null;
this.#cua.length = 0;
this.#controlador.abort(); // ← retira TOT de cop
}
}I el cablejat a app.js, amb el punt de neteja natural d'una aplicació d'una sola pàgina:
// js/app.js
const vista = new TaulerVista({ /* … */ });
const canal = new CanalTauler(URL_WS);
const planificador = new Planificador();
function destruirTot() {
vista.destruir();
canal.destruir();
planificador.destruir();
}
// En canviar de ruta (vista/encaminador.js) i en descarregar la pàgina
window.addEventListener('pagehide', destruirTot);Es fa servir pagehide i no unload deliberadament: unload impedeix que la pàgina entri a la memòria cau de retrocés (bfcache), cosa que empitjora el rendiment de la navegació cap enrere. És un cas bonic de dos objectius en tensió, i pagehide els resol.
I com tota decisió de disseny, es prova (08-03):
// proves/vista/tauler-vista-destruir.test.js
test('destruir() retira tots els gestors de window', () => {
const registrar = jest.spyOn(window, 'addEventListener');
const vista = new TaulerVista({ contenidor, resum, tauler, avui: AVUI });
expect(registrar).toHaveBeenCalledTimes(1);
vista.destruir();
window.dispatchEvent(new Event('resize')); // no ha de fer res ni llançar
expect(() => vista.render()).toThrow(); // la vista està destruïda
});
test("render() purga de l'índex les tasques que ja no es mostren", () => {
const vista = new TaulerVista({ contenidor, resum, tauler, avui: AVUI });
vista.render();
expect(vista.nodesIndexats).toBe(6);
vista.actualitzar({ filtres: { responsable: 'Lucía' } }); // només 1 tasca de la Lucía
expect(vista.nodesIndexats).toBe(1); // ← la resta, purgada
});
- Detectar fuites en integració contínua
Una fuita arreglada torna al cap de tres mesos si ningú no la vigila. Hi ha dos nivells de defensa automàtica, i convé tenir-los tots dos.
Nivell 1: proves d'invariants a Jest. Barates, ràpides i no mesuren memòria, sinó les estructures que la retenen. Són les que de debò s'executen a cada commit.
// proves/vista/no-fuites.test.js
test("200 filtratges no acumulen gestors ni entrades d'índex", () => {
const espia = jest.spyOn(window, 'addEventListener');
const vista = new TaulerVista({ contenidor, resum, tauler: taulerGran, avui: AVUI });
const alConstruir = espia.mock.calls.length;
for (let i = 0; i < 200; i += 1) {
vista.actualitzar({ filtres: { responsable: ['Iván', 'Lucía', 'Marta', null][i % 4] } });
}
expect(espia.mock.calls.length).toBe(alConstruir); // ni un més
expect(vista.nodesIndexats).toBeLessThanOrEqual(600); // l'índex no creix sense fi
expect(document.querySelectorAll('li.tasca').length).toBeLessThanOrEqual(600);
});
test('el canal no acumula intervals en reconnectar', () => {
jest.useFakeTimers();
const canal = new CanalTauler('ws://exemple');
const crear = jest.spyOn(global, 'setInterval');
const netejar = jest.spyOn(global, 'clearInterval');
for (let i = 0; i < 10; i += 1) { canal.simularObertura(); canal.simularTancament(); }
expect(crear).toHaveBeenCalledTimes(10);
expect(netejar).toHaveBeenCalledTimes(20); // es neteja en obrir i en tancar
canal.destruir();
});Nivell 2: mesurament real del monticle amb Puppeteer, en una feina nocturna (és lent i sorollós per a cada commit):
// scripts/mesurar-fuita.mjs
import puppeteer from 'puppeteer';
const navegador = await puppeteer.launch({ args: ['--js-flags=--expose-gc'] });
const pagina = await navegador.newPage();
await pagina.goto('http://localhost:4173');
await pagina.waitForSelector('li.tasca');
const mesurar = async () => {
await pagina.evaluate(() => globalThis.gc?.()); // forçar recol·lecció
return (await pagina.metrics()).JSHeapUsedSize;
};
const base = await mesurar();
await pagina.evaluate(() => estressarFiltres(200));
const despres = await mesurar();
const creixementMB = (despres - base) / 1024 / 1024;
console.log(`Creixement després de 200 filtratges: ${creixementMB.toFixed(1)} MB`);
await navegador.close();
if (creixementMB > 2) { // llindar amb marge per al soroll
console.error('Possible fuita de memòria: el monticle creix més del que caldria');
process.exit(1);
}Dos avisos sobre aquest script. El llindar ha de tenir marge generós (2 MB, no 0,1 MB): la memòria en un executor compartit és sorollosa, i un pressupost que falla en fals es desactiva en una setmana. I --expose-gc és imprescindible: sense forçar la recol·lecció mesuraries escombraries pendents i el resultat no significaria res.
Errors Habituals i Consells
- Creure que assignar
nullallibera memòria. Només deixa anar una referència. Si queda un altre camí des d'una arrel, l'objecte continua viu. - Creure que
innerHTML = ''neteja els gestors. Els allibera només si ningú més no referencia els nodes. Si unMap, un array o un observador els reté, tens nodes separats. - Guardar nodes del DOM en estructures de llarga vida. És la causa número u de nodes separats. I retenir un fill reté tot el seu arbre per
parentNode. - Registrar gestors dins de
render(). Cada render n'afegeix un més. Registra'ls al constructor, una sola vegada. setIntervalsenseclearInterval. Una arrel permanent que reté el seu closure sencer.- Reconnectar sense aturar el temporitzador anterior. Quaranta reconnexions, quaranta heartbeats.
- Memòries cau sense sostre. Un
Mapamb clau de text lliure creix indefinidament. Posa-hi un límit (LRU) o fes servirWeakMapsi la clau és un objecte. - Índexs no sincronitzats. Si
eliminar()treu de l'array i no delMap, tens una fuita i un error de dades. - Closures de llarga vida creats en àmbits grans. Capturen tot l'entorn, no només el que fan servir.
- Mesurar sense forçar la recol·lecció. Veuràs escombraries pendents i diagnosticaràs una fuita inexistent.
- Comparar la instantània 2 amb la 1. La primera ronda crea estructures legítimes. Compara la 3 amb la 1.
- Mesurar amb extensions instal·lades. Injecten nodes i gestors a la teva pàgina. Fes servir incògnit.
- Confiar en
FinalizationRegistryper alliberar recursos. No hi ha cap garantia que s'executi. No hi posis mai lògica necessària. - Fer servir
unloadper netejar. Impedeix la memòria cau de retrocés i empitjora la navegació. Fes servirpagehide. - Consell: cada classe que registri alguna cosa fora de si mateixa, amb el seu
destruir(). I que sigui idempotent. - Consell: un
AbortControllerper objecte de llarga vida. Unabort()retira desenes de gestors de cop: és la millor eina de neteja que existeix avui. - Consell: la delegació d'esdeveniments no és només elegància. Un gestor en lloc de 600 elimina d'arrel una família sencera de fuites.
- Consell: mira la corba de nodes i oients a Performance abans de prendre instantànies. En un minut saps si hi ha fuita i de quin tipus.
Exercicis
Exercici 1 — Auditoria de retencions. Per a cada fragment, digues si hi ha fuita, quina arrel reté què, i escriu la correcció.
// (a)
const historial = [];
document.addEventListener('tasca:canviada', (e) => {
historial.push({ quan: Date.now(), targeta: e.target.closest('li'), tauler: [...tauler] });
});
// (b)
function iniciarSincronitzacio(canal) {
setInterval(() => canal.enviar({ tipus: 'sync' }), 30000);
}
// (c)
const alcades = new WeakMap();
function mesurar(node) {
alcades.set(node, node.offsetHeight);
}
// (d)
class Editor {
constructor(node) {
this.node = node;
this.observador = new ResizeObserver(() => this.ajustar());
this.observador.observe(node);
}
tancar() { this.node.remove(); }
}Exercici 2 — Confirmar una fuita amb el mètode. L'Iván sospita que obrir i tancar el plafó de detall d'una tasca perd memòria. Escriu el procediment complet que seguiries: el script d'estrès, els passos amb el panell Memory, què miraries a cada instantània, quin resultat confirmaria la fuita i quin la descartaria. Després indica quines dues causes de les cinc clàssiques serien les més probables en un plafó modal, i per què.
Exercici 3 — Map o WeakMap. Per a cada memòria cau, decideix quina fer servir i justifica-ho. Si cap no serveix, proposa l'alternativa.
- Alçada calculada de cada targeta, indexada per node
<li>. - Resultat de
formatarData(iso), indexat per la cadena ISO. - Índex
id → TascadeTauler. - Últims 50 resultats de cerca, indexats per text cercat.
- Marcar quines tasques ja s'han enviat al servidor, indexat per instància de
Tasca.
Solucions
Solució 1
(a) Fuita greu. Tres alhora. document és arrel → reté el gestor → el closure reté historial → cada entrada reté un node del DOM (que quedarà separat després del render següent) i una còpia completa del tauler (600 tasques). Amb 200 canvis, 200 còpies de 600 tasques.
// ✓ Guardar dades, no nodes ni estructures completes; i limitar la mida
const historial = [];
const MAX_HISTORIAL = 100;
document.addEventListener('tasca:canviada', (e) => {
historial.push({
quan: Date.now(),
id: Number(e.target.closest('li')?.dataset.id), // ← l'id, no el node
total: tauler.resum(AVUI).total // ← un número, no una còpia
});
if (historial.length > MAX_HISTORIAL) historial.shift();
}, { signal: controlador.signal });(b) Fuita. El setInterval no es guarda: no hi ha manera de cancel·lar-lo. Reté canal per sempre, encara que el canal es destrueixi.
// ✓ Retornar la manera de cancel·lar
function iniciarSincronitzacio(canal) {
const id = setInterval(() => canal.enviar({ tipus: 'sync' }), 30000);
return () => clearInterval(id); // es crida des de canal.destruir()
}(c) No hi ha fuita. WeakMap no reté les seves claus: quan el node surt de l'arbre i ningú més no el referencia, l'entrada desapareix sola. És l'ús canònic. (Nota a part: offsetHeight força un càlcul d'estil síncron, i això és un problema de rendiment diferent que veuràs a 09-04.)
(d) Fuita. tancar() treu el node de l'arbre però no desconnecta el ResizeObserver. L'observador reté el node i l'Editor sencer, i el node queda separat.
// ✓
class Editor {
#controlador = new AbortController();
constructor(node) {
this.node = node;
this.observador = new ResizeObserver(() => this.ajustar());
this.observador.observe(node);
node.addEventListener('keydown', (e) => this.#drecera(e), { signal: this.#controlador.signal });
}
tancar() {
this.observador.disconnect(); // ← imprescindible
this.#controlador.abort();
this.node.remove();
this.node = null;
}
}Solució 2
Script d'estrès (repetible, amb tornada a l'estat base i cessió al navegador):
async function estressarDetall(vegades = 100) {
for (let i = 0; i < vegades; i += 1) {
document.querySelector(`li[data-id="${(i % 600) + 1}"] [data-accio="detall"]`).click();
await new Promise((r) => setTimeout(r, 20)); // que el modal es munti
document.querySelector('.modal__tancar').click();
await new Promise((r) => setTimeout(r, 20)); // que es desmunti
}
}Procediment:
- Finestra d'incògnit, sense extensions. Carregar l'aplicació i esperar el render inicial.
- Panell Performance amb la casella Memory: gravar 20 iteracions i mirar les corbes JS Heap, Nodes i Listeners. Si la base de la serra puja esglaó a esglaó, hi ha fuita i ja sabem si és de nodes o d'oients. Aquest pas costa un minut i sol bastar per decidir si val la pena continuar.
- Panell Memory → paperera → Instantània 1.
await estressarDetall(100)→ tornar a l'estat base → paperera → Instantània 2.await estressarDetall(100)un altre cop → estat base → paperera → Instantània 3.- Seleccionar la 3, vista Comparison contra la 1, ordenar per Retained Size.
- Filtrar per
Detacheda Summary i mirar els retainers d'un node separat.
Què confirma la fuita: que els comptadors creixin linealment —si la 2 té +100 modals i la 3 en té +200, és fuita—, que apareguin Detached HTMLDivElement en quantitat proporcional a les repeticions, o que el nombre d'oients pugi de 100 en 100 per tanda. Què la descarta: que la 3 sigui pràcticament igual que la 2 (creixement que s'estabilitza: una memòria cau amb sostre, ús legítim) o que el delta oscil·li sense tendència (soroll).
Les dues causes més probables en un modal són, per aquest ordre: gestors no retirats —un modal registra gairebé sempre keydown a document per tancar amb Escape, i un click al fons; si tancar() treu el node però no retira aquests gestors de document, cada obertura en deixa un més i cadascun reté el modal sencer, que queda separat— i temporitzadors no cancel·lats, perquè els modals solen fer servir un setTimeout per a l'animació de sortida o per tornar el focus, i si es tanca abans que dispari, el temporitzador continua retenint el node. Totes dues es resolen amb el mateix patró: un AbortController per modal i un tancar() que cridi abort() i clearTimeout().
Solució 3
| # | Memòria cau | Elecció | Justificació |
|---|---|---|---|
| 1 | Alçada per node <li> |
WeakMap |
La clau és un objecte la vida del qual ha de manar. Quan la targeta surt de l'arbre, l'entrada se'n va sola: zero purga, zero fuites. Ús canònic |
| 2 | formatarData(iso) |
Map (o CacheLRU) |
La clau és una cadena: WeakMap no l'admet. Les claus estan acotades (~200 dates diferents), així que un Map normal basta; si el rang fos il·limitat, CacheLRU amb sostre |
| 3 | Índex id → Tasca |
Map, amb purga explícita |
Aquí la retenció és desitjada: el tauler ha de mantenir vives les seves tasques. WeakMap ni tan sols funcionaria, perquè la clau és un número. L'obligació és sincronitzar eliminar() amb delete |
| 4 | Últims 50 resultats | CacheLRU(50) |
Clau de text lliure, és a dir, il·limitada: un Map creixeria sense fi (apartat 7.5) i cada valor pot ser un array de 600 tasques. El sostre és imprescindible |
| 5 | Tasques ja enviades | WeakSet |
La clau és un objecte i només interessa la pertinença, no un valor associat. Si la tasca s'elimina del tauler, la seva marca desapareix sola, que és justament el correcte |
La regla que resumeix la taula: WeakMap/WeakSet quan la clau és un objecte i la seva vida ha de governar la de l'entrada; Map quan la retenció és deliberada; CacheLRU quan la clau és un primitiu de domini il·limitat.
Conclusió
La fila 8 de la línia base està tancada: de +37,7 MB retinguts i 41.828 nodes separats a +0,4 MB i zero, amb l'avantatge afegit que les pauses de recol·lecció major han baixat de 14 (fins a 84 ms) a 3 (fins a 11 ms) per minut. Arreglar la fuita ha fet l'aplicació més ràpida a més de més estable.
Entens el model: la pila per a primitius i contextos, el monticle per a objectes, i variables que guarden referències, no objectes. Saps que el recol·lector decideix per abastabilitat des d'un conjunt d'arrels —l'objecte global, la pila activa, el DOM abastable, l'estat dels mòduls, els gestors i temporitzadors registrats—, i saps per què comptar referències no basta: dos objectes que s'apunten mútuament formarien una illa immortal, i els cicles són a tot arreu. Coneixes l'organització generacional —viver barat per al que és efímer, generació vella cara per al que és longeu— i les dues conseqüències que se'n deriven: crear objectes temporals és barat, i retenir és car dues vegades, en memòria i en cost de recol·lecció.
Tens les cinc causes clàssiques amb el seu cas real: globals accidentals (de les quals et salven els mòduls ES i no-undef, tret del window.__depuracio posat «només un moment»), temporitzadors mai cancel·lats com el heartbeat del CanalTauler que es duplicava a cada reconnexió, gestors sobre nodes eliminats, que tanca l'avís de 06-05 sobre innerHTML = '' explicant que el problema no és l'esborrat sinó qui més apunta al node, closures que retenen l'entorn sencer tancant l'avís de 03-04, i memòries cau que creixen sense límit, resoltes amb CacheLRU. I saps què és un node separat, per què retenir un sol fill arrossega tot el seu arbre, i per què la delegació d'esdeveniments de 06-04 elimina d'arrel una família sencera de fuites: un gestor en lloc de 600.
Saps diagnosticar: el panell Memory amb les seves tres eines, la lectura d'una instantània per Retained Size i sobretot pels retainers —la cadena literal que respon «per què continua viu això»—, el filtre Detached, la vista Comparison, les corbes de monticle, nodes i oients a Performance, i el patró de les tres instantànies amb les seves quatre regles: mateix estat base, forçar recol·lecció, comparar la 3 amb la 1, i buscar creixement lineal. Amb aquest mètode vas trobar dues fuites reals en dos minuts —un Map de nodes sense purgar i un addEventListener dins de render()—, cap de les quals no trencava cap de les 124 proves ni es veia en una revisió de codi.
Saps quan toquen les referències febles: WeakMap per a metadades associades a nodes, WeakSet per marcar objectes, i l'advertiment clar que WeakRef i FinalizationRegistry no són d'ús corrent i mai no han de sostenir lògica necessària. I tens l'hàbit que fa que les fuites deixin d'aparèixer: un mètode destruir() idempotent en tot objecte que registri alguna cosa fora de si mateix, i un AbortController amb signal que retira desenes de gestors amb un sol abort() —el mateix controlador de 07-03, aquí aplicat a esdeveniments—, amb pagehide en lloc d'unload per no trencar la memòria cau de retrocés. Tot això vigilat en integració contínua per proves d'invariants barates a Jest i un mesurament nocturn del monticle amb Puppeteer i llindars amb marge.
Queden dos fronts, i el següent és el que domina la taula. La fila 5 continua en 310 ms per render i la fila 9 en 7.812 nodes: sabem pel Bottom-Up de 09-01 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 l'has optimitzat— ni memòria retinguda: és el cost de parlar amb el DOM, un cost que té regles pròpies, un canal de comunicació car i un pipeline de renderitzat que es pot sabotejar sense adonar-se'n amb una sola línia que llegeixi offsetHeight dins d'un bucle. És el deute pendent des de 06-02 i 06-05, i li toca ara: Manipulació Eficient del DOM.
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
