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

  1. Per què la memòria importa en una aplicació de llarga vida
  2. Pila, monticle i referències
  3. El recol·lector d'escombraries: abastabilitat
  4. Per què no n'hi ha prou de comptar referències
  5. Generacional i marcatge-escombratge
  6. Què és exactament una fuita de memòria
  7. Les cinc causes clàssiques
  8. Nodes separats i per què la delegació els evita
  9. Diagnòstic amb DevTools: el panell Memory
  10. El patró de les tres instantànies
  11. Trobar la fuita real de Nómada Tasques
  12. Arreglar-la
  13. Referències febles: WeakMap, WeakSet, WeakRef i FinalizationRegistry
  14. Neteja sistemàtica: destruir() i AbortController
  15. Detectar fuites en integració contínua
  16. Errors Habituals i Consells
  17. Exercicis
  18. Conclusió

  1. 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.

  1. 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é viu

Aquest ú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?».

  1. 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.

  1. 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.

  1. 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:

  1. Crear molts objectes efímers és barat. L'objecte que crees en un map i 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.
  2. 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.
  3. 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.

  1. 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ò 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.

  1. 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.

  1. 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.

  1. 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, # Deleted i # 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.

  1. 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:

  1. 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.
  2. 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.)
  3. 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.
  4. 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:

  1. Pestanya Performance, casella Memory marcada.
  2. Grava mentre repeteixes l'acció 20 vegades.
  3. 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ó.
  4. Mira les corbes Nodes i Listeners: si pugen i no baixen, ja saps de quin tipus és la fuita.

  1. 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)       ← arrel

I per a un dels (closure):

(closure)
 └─ handleEvent in EventListener
     └─ resize listener in Window         ← arrel

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.

  1. 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 } a addEventListener és l'eina clau de la lliçó: un sol abort() retira tots els gestors registrats amb aquell senyal, per molts que siguin i estiguin on estiguin. És el mateix AbortController que 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.

  1. Referències febles: WeakMap, WeakSet, WeakRef i FinalizationRegistry

JavaScript 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ó');

  1. Neteja sistemàtica: destruir() i AbortController

La 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
});

  1. 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 null allibera 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 un Map, 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.
  • setInterval sense clearInterval. 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 Map amb clau de text lliure creix indefinidament. Posa-hi un límit (LRU) o fes servir WeakMap si la clau és un objecte.
  • Índexs no sincronitzats. Si eliminar() treu de l'array i no del Map, 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 FinalizationRegistry per alliberar recursos. No hi ha cap garantia que s'executi. No hi posis mai lògica necessària.
  • Fer servir unload per netejar. Impedeix la memòria cau de retrocés i empitjora la navegació. Fes servir pagehide.
  • Consell: cada classe que registri alguna cosa fora de si mateixa, amb el seu destruir(). I que sigui idempotent.
  • Consell: un AbortController per objecte de llarga vida. Un abort() 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.

  1. Alçada calculada de cada targeta, indexada per node <li>.
  2. Resultat de formatarData(iso), indexat per la cadena ISO.
  3. Índex id → Tasca de Tauler.
  4. Últims 50 resultats de cerca, indexats per text cercat.
  5. 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:

  1. Finestra d'incògnit, sense extensions. Carregar l'aplicació i esperar el render inicial.
  2. 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.
  3. Panell Memory → paperera → Instantània 1.
  4. await estressarDetall(100) → tornar a l'estat base → paperera → Instantània 2.
  5. await estressarDetall(100) un altre cop → estat base → paperera → Instantània 3.
  6. Seleccionar la 3, vista Comparison contra la 1, ordenar per Retained Size.
  7. Filtrar per Detached a 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

Mòdul 2: Estructures de Control

Mòdul 3: Funcions

Mòdul 4: Objectes i Arrays

Mòdul 5: Objectes i Funcions Avançades

Mòdul 6: El Model d'Objectes del Document (DOM)

Mòdul 7: APIs del Navegador i Temes Avançats

Mòdul 8: Proves i Depuració

Mòdul 9: Rendiment i Optimització

Mòdul 10: Frameworks i Llibreries de JavaScript

Mòdul 11: Projecte Final

© Copyright 2026. Tots els drets reservats