La línia base de 09-01 reparteix la culpa en quatre fronts, i aquesta lliçó ataca el primer: el codi teu que triga massa a executar-se. Són les files 2, 4, 6 i 7 de la taula —480 ms d'INP en escriure al cercador, una tasca llarga de 1.180 ms en arrencar, 96 ms llençats a recalcular el mateix i un informe de planificació de 940 ms que deixa la pantalla congelada—. Veuràs com treballa el motor per dins (just el necessari per no sabotejar-lo, sense caure en microoptimitzacions inútils), per què el cost algorísmic mana sobre qualsevol altra consideració, com calcular una sola vegada allò que es fa servir moltes amb una memòria cau correctament invalidada, quan toca debounce i quan throttle, com trossejar una feina llarga perquè el bucle d'esdeveniments respiri, i com treure del fil principal d'una vegada la feina veritablement pesada amb un Web Worker. Acabarem amb una taula de mites desmentits, perquè gairebé tot el que es repeteix sobre «JavaScript ràpid» va deixar de ser cert fa quinze anys.

Contingut

  1. On va el temps, exactament
  2. Com treballa el motor: anàlisi, interpretació i JIT
  3. Formes ocultes, monomorfisme i desoptimització
  4. Regles pràctiques per no sabotejar el motor
  5. El cost que de debò mana: la complexitat
  6. Del bucle quadràtic a l'índex amb Map
  7. Calcular una sola vegada: memoïtzació
  8. Una memòria cau ben invalidada a Tauler
  9. Gestors freqüents: debounce i throttle
  10. Trossejar la feina llarga per no bloquejar el fil
  11. Web Workers: un altre fil de debò
  12. El cost de creuar la frontera: serialització i transferibles
  13. L'informe de planificació, en un worker
  14. Mites desmentits
  15. Errors Habituals i Consells
  16. Exercicis
  17. Conclusió

  1. On va el temps, exactament

Abans de tocar res, desglossem la tasca llarga de 1.180 ms de l'arrencada. Amb la instrumentació de performance.mark/measure de 09-01 i el panell Performance amb CPU 4×, el repartiment és aquest (mediana de 15 arrencades, 600 tasques, portàtil de referència):

Fase de l'arrencada Durada És codi teu?
Analitzar i executar els 28 mòduls ES 214 ms Sí, però és un problema de càrrega (09-05)
JSON.parse del tauler desat (182 kB) 41 ms Sí, inevitable
600 × Tasca.desDeJSON 103 ms
agruparPerEtiqueta() 148 ms Sí, i és quadràtic
render() inicial 310 ms Sí, però és DOM (09-04)
resum() × 3 13 ms
Disseny i pintat del navegador 244 ms No directament (09-04)
Resta (esdeveniments, encaminador, repositori) 107 ms
Total 1.180 ms

Aquest desglossament ja pren dues decisions per nosaltres. La primera: agruparPerEtiqueta() consumeix 148 ms per fer una cosa conceptualment trivial, i això fa pudor d'algorisme equivocat. La segona: microoptimitzar Tasca.desDeJSON per esgarrapar un 10 % dels seus 103 ms donaria 10 ms, mentre que arreglar l'algorisme d'agrupació en donarà 145. Aquest és l'ordre en què cal treballar.

Però abans de tocar l'algorisme convé saber què fa el motor amb el teu codi, perquè hi ha un grapat de decisions d'escriptura que sí que importen —i moltíssimes que no.

  1. Com treballa el motor: anàlisi, interpretació i JIT

Un motor modern (V8 a Chrome i Node, SpiderMonkey a Firefox, JavaScriptCore a Safari) no interpreta el teu codi línia a línia eternament. Fa una cosa més llesta: comença ràpid i millora allò que es fa servir molt.

flowchart TD
    A["Codi font"] --> B["Analitzador<br/>(parser)"]
    B -->|"anàlisi diferida:<br/>només la capçalera"| C["Bytecode<br/>(Ignition)"]
    C --> D["Intèrpret<br/>arrenca JA"]
    D -->|"la funció es crida molt:<br/>es torna calenta"| E["Compilador optimitzador<br/>(Maglev / TurboFan)"]
    E --> F["Codi màquina<br/>optimitzat"]
    F -->|"una suposició falla"| G["Desoptimització"]
    G --> D

Quatre idees que cal retenir:

L'anàlisi és diferida. El motor no analitza a fons el cos d'una funció fins que la vas a executar. Només mira el just per saber on acaba. Per això un fitxer de 2 MB de JavaScript costa temps encara que no n'executis gairebé res: cal recórrer-lo sencer. És un argument de 09-05, no d'aquí, però explica per què «descarregar menys codi» és una optimització d'execució a més d'una de xarxa.

Es comença interpretant. El bytecode es genera ràpid i s'executa de seguida. Això afavoreix l'arrencada davant de la velocitat màxima.

El que és calent es compila. Quan una funció s'executa moltes vegades, o un bucle itera molt, el motor la promociona a un compilador optimitzador que genera codi màquina especialitzat per als tipus que ha vist fins ara.

Les suposicions poden fallar. Si el codi optimitzat va suposar «aquest paràmetre sempre és un número» i un dia hi arriba una cadena, el motor desoptimitza: llença el codi màquina, torna a l'intèrpret i potser reoptimitza més tard. Una desoptimització repetida en un bucle calent (el que s'anomena deopt loop) pot fer que un fragment sigui deu vegades més lent sense que hi hagi canviat ni una línia.

I aquí hi ha la conseqüència pràctica més important de tot l'apartat: això que acabes de llegir és, per al 95 % del codi, informació cultural. Serveix per no escriure barbaritats i per entendre mesuraments estranys. No serveix per decidir com escriure un filter.

  1. Formes ocultes, monomorfisme i desoptimització

Perquè l'accés a propietats sigui ràpid, V8 no guarda cada objecte com un diccionari. Assigna a cada objecte una forma oculta (hidden class o map) que descriu quines propietats té i en quin ordre, i així pot accedir a tasca.horesEstimades amb un desplaçament fix en memòria, com en un llenguatge compilat.

Dos objectes comparteixen forma si s'han construït amb les mateixes propietats en el mateix ordre:

const a = { id: 1, titol: 'Redissenyar la sala polivalent' };
const b = { id: 2, titol: 'Cartelleria del taller de serigrafia' };
// a i b comparteixen forma: accés ràpid a totes dues

const c = { titol: 'Actualitzar el web de reserves', id: 3 };
// c té UNA ALTRA forma (ordre diferent), encara que tingui les mateixes claus

const d = { id: 4, titol: 'Inventari de tintes' };
d.revisor = 'Marta';    // afegir una propietat després CANVIA la forma de d

Quan un fragment de codi accedeix a t.horesEstimades i tots els objectes que hi passen tenen la mateixa forma, aquest accés és monomòrfic: el motor guarda una memòria cau en línia (inline cache) amb el desplaçament i hi va directe. Si hi passen dues o tres formes diferents és polimòrfic (encara raonable), i si n'hi passen moltes és megamòrfic: el motor abandona i fa una cerca genèrica, molt més lenta.

Situació Nom Cost relatiu d'un accés
Sempre la mateixa forma Monomòrfic
2–4 formes Polimòrfic ~1,5–3×
Més de 4 formes Megamòrfic ~10× o més

El que és bo és que Nómada Tasques ja fa el que cal sense proposar-s'ho, i el motiu no era el rendiment: era el disseny. La classe Tasca de 05-02 assigna tots els seus camps al constructor, sempre en el mateix ordre, i #estat és privat i només canvia per canviarEstat. Resultat: les 600 tasques comparteixen una única forma i tots els accessos del render són monomòrfics.

Compara-ho amb l'alternativa que hauríem tingut si haguéssim fet servir objectes literals solts:

// ✗ Genera formes diferents: quatre variants segons les dades d'entrada
function crearTascaSolta(dades) {
  const t = { id: dades.id, titol: dades.titol };
  if (dades.responsable) t.responsable = dades.responsable;   // forma A o B
  if (dades.revisor) t.revisor = dades.revisor;               // forma C o D
  return t;
}

// ✓ Una sola forma per a totes: els camps absents existeixen amb valor nul
function crearTascaEstable(dades) {
  return {
    id: dades.id,
    titol: dades.titol,
    responsable: dades.responsable ?? null,
    revisor: dades.revisor ?? null
  };
}

La segona versió no és només més ràpida: és millor codi, perquè l'objecte té una forma predictible i no cal comprovar l'existència de propietats per aquí i per allà. I aquesta és la regla general d'aquest apartat: les pràctiques que ajuden el motor coincideixen gairebé sempre amb les pràctiques que ajuden el lector. Quan no coincideixin, guanya el lector, tret que tinguis un mesurament que digui el contrari.

Un advertiment clar: no escriguis codi estrany per «ajudar el JIT». Els motors canvien cada sis setmanes, i els trucs que circulaven el 2012 avui són contraproduents o irrellevants. El que continua sent veritat és allò estructural: tipus estables, formes estables, i no canviar la naturalesa d'una variable a mitja vida.

  1. Regles pràctiques per no sabotejar el motor

Aquestes són les úniques cinc regles d'aquest apartat que val la pena recordar:

Regla Per què A Nómada Tasques
Inicialitza tots els camps al constructor Una sola forma oculta class Tasca ja ho fa
No canviïs el tipus d'una variable Evita desoptimitzacions horesEstimades sempre número, mai '5'
No barregis tipus en un array Un array de números és un array «empaquetat»; afegir-hi un objecte el degrada etiquetes sempre cadenes
No creïs forats als arrays arr[1000] = x sobre un array de 3 el converteix en dispers i lent Fes servir push i mètodes d'array
Evita delete sobre objectes Canvia la forma i sovint degrada a diccionari Assigna null, o fes servir una còpia amb rest (04-07)

I l'exemple de l'array degradat, que sí que produeix una diferència mesurable:

// ✗ Array dispers: el motor el tracta com un diccionari
const hores = [];
hores[0] = 12;
hores[599] = 5;            // 598 forats: representació lenta
console.log(hores.length); // 600, però només 2 elements reals

// ✓ Array empaquetat i de tipus homogeni
const horesOk = new Array(600).fill(0);
horesOk[0] = 12;
horesOk[599] = 5;

Res d'això no arreglarà els 480 ms d'INP. El que sí que ho arreglarà és l'apartat següent.

  1. El cost que de debò mana: la complexitat

A 02-04 vas veure que dos bucles imbricats sobre la mateixa llista produeixen un cost quadràtic. Toca formalitzar-ho, perquè és l'única categoria d'optimització que millora les coses per factors de 50× en lloc de per un 15 %.

La complexitat descriu com creix la feina quan creixen les dades, ignorant constants:

Notació Nom 6 elements 600 60.000 Exemple
O(1) Constant 1 1 1 mapa.get(id), arr[i]
O(log n) Logarítmica 3 10 16 Cerca binària en un array ordenat
O(n) Lineal 6 600 60.000 filter, find, reduce, un for
O(n log n) Gairebé lineal 15 6.000 960.000 sort
O(n²) Quadràtica 36 360.000 3.600.000.000 Un find dins d'un bucle
O(2ⁿ) Exponencial 64 inviable inviable El simulador de repartiments de 07-07

Fixa't en la fila quadràtica: en passar de 6 a 600 tasques —cent vegades més dades— la feina es multiplica per deu mil. Això explica per què Nómada Tasques anava perfecta amb el backlog canònic i s'arrossega amb el de dos anys. No és que s'hagi tornat lenta: és que el defecte hi era des del principi i només es manifesta amb volum.

El senyal d'alarma és sempre el mateix, i es reconeix a simple vista:

// ✗ Un bucle sobre tasques, i A DINS una cerca sobre tasques → O(n²)
for (const tasca of tasques) {
  const relacionades = tasques.filter((altra) => compartenEtiqueta(tasca, altra));
  // …
}

for és O(n). filter és O(n). L'un dins de l'altre és O(n²). Si en llegir un fragment hi trobes un find, filter, includes, indexOf o some dins d'un bucle que recorre la mateixa col·lecció, has trobat el problema.

  1. Del bucle quadràtic a l'índex amb Map

Aquest és l'agruparPerEtiqueta() real de Nómada Tasques, el que consumeix 148 ms:

// js/model/informes.js — versió O(n²)

/** Per a cada etiqueta, les tasques que la porten. */
export function agruparPerEtiqueta(tasques) {
  const etiquetes = [...new Set(tasques.flatMap((t) => t.etiquetes))];

  return etiquetes.map((etiqueta) => ({
    etiqueta,
    // ✗ Aquest filter recorre les 600 tasques... una vegada per etiqueta
    tasques: tasques.filter((t) => t.etiquetes.includes(etiqueta))
  }));
}

Amb 600 tasques i 6 etiquetes, el filter s'executa 6 vegades sobre 600 elements, i dins de cadascun hi ha un includes sobre l'array d'etiquetes de la tasca. Són 3.600 iteracions externes × el cost de l'includes. I en l'ús real de la vista es crida per responsable i per etiqueta, amb la qual cosa la imbricació es multiplica.

La solució és la de 04-05, però portada a l'extrem: recórrer una sola vegada i construir un índex.

// js/model/informes.js — versió O(n)

/**
 * Per a cada etiqueta, les tasques que la porten.
 * Un sol recorregut: cada tasca es visita una vegada, i cadascuna de les seves
 * etiquetes s'afegeix a un Map en temps constant.
 */
export function agruparPerEtiqueta(tasques) {
  const index = new Map();

  for (const tasca of tasques) {
    for (const etiqueta of tasca.etiquetes) {          // compte: NO és quadràtic
      let llista = index.get(etiqueta);                // O(1)
      if (llista === undefined) {
        llista = [];
        index.set(etiqueta, llista);
      }
      llista.push(tasca);                              // O(1) amortitzat
    }
  }

  return [...index].map(([etiqueta, tasques]) => ({ etiqueta, tasques }));
}

El bucle interior no converteix això en O(n²): recorre les etiquetes d'una tasca, que són una o dues, no les 600 tasques. La complexitat total és O(n · e) amb e ≈ 1,5, és a dir, lineal a la pràctica.

Mesurament comparat, amb banc() de 09-01 (5 d'escalfament, 15 repeticions, CPU 4×):

Variant Mediana Mín p95 Davant de la base
filter imbricat — O(n²) 148 ms 141 ms 173 ms
Índex amb Map — O(n) 3,1 ms 2,8 ms 4,4 ms 48× més ràpid

Quaranta-vuit vegades. Cap microoptimització del món no produeix això. I el més important: amb 6.000 tasques, la versió quadràtica costaria uns 15 segons i la lineal uns 31 ms. La millora creix amb les dades.

El mateix patró s'aplica a Tauler.cercarPerId, que el controlador delegat de 06-04 crida a cada clic:

// js/model/tauler.js
export class Tauler {
  #tasques = [];
  #perId = new Map();          // índex, mantingut al costat de l'array

  afegir(tasca) {
    this.#tasques.push(tasca);
    this.#perId.set(tasca.id, tasca);      // l'índex s'actualitza AQUÍ
    this.#invalidar();
    return this;
  }

  /** O(1) en lloc de O(n). */
  cercarPerId(id) {
    return this.#perId.get(id) ?? null;
  }
}

Per a un clic solt la diferència és inapreciable (0,004 ms davant de 0,00008 ms). Però quan el CanalTauler de 07-04 rep un lot de 600 actualitzacions després d'una reconnexió, aquestes 600 cerques lineals són 360.000 comparacions: 34,2 ms davant de 0,8 ms. La regla és senzilla i no admet matisos: si cerques per clau més d'una vegada, indexa per clau.

Un avís d'honestedat, però. Un Map ocupa memòria i cal mantenir-lo sincronitzat amb l'array: si algú elimina una tasca i oblida el delete de l'índex, tens un error de dades i una fuita de memòria (09-03). L'índex es justifica quan hi ha moltes cerques; amb sis tasques i un clic cada minut, find va perfecte.

  1. Calcular una sola vegada: memoïtzació

La segona família d'optimitzacions és no repetir feina idèntica. A 03-04 vas construir crearComptadorMemoitzat() amb un closure; el patró general és aquest:

// js/util/memoitzar.js

/**
 * Retorna una versió de `fn` que guarda els resultats ja calculats.
 * Requisits: `fn` ha de ser PURA (mateix argument → mateix resultat, sense efectes).
 * @param {Function} fn
 * @param {Function} [clau] Com convertir els arguments en clau de memòria cau.
 */
export function memoitzar(fn, clau = (...args) => args.join('|')) {
  const cache = new Map();

  return function (...args) {
    const k = clau(...args);
    if (cache.has(k)) return cache.get(k);      // encert: 0 feina

    const valor = fn.apply(this, args);
    cache.set(k, valor);
    return valor;
  };
}

I el seu ús natural a Nómada Tasques: el format de dates amb Intl de util/format.js, que és sorprenentment car perquè crear un Intl.DateTimeFormat implica carregar dades de localització.

// js/util/format.js
import { memoitzar } from './memoitzar.js';

const formatador = new Intl.DateTimeFormat('ca-ES', { dateStyle: 'long' });

/** '2026-09-05' → '5 de setembre del 2026' */
export const formatarData = memoitzar((iso) =>
  formatador.format(new Date(`${iso}T00:00:00`))
);

Amb 600 tasques i unes 200 dates diferents, memoïtzar converteix 600 formatatges en 200: 4,6 ms → 1,7 ms. Poc, però de franc i sense risc.

Ara les tres condicions que cal verificar abans de memoïtzar qualsevol cosa, perquè memoïtzar malament produeix errors molt difícils de trobar:

  1. La funció ha de ser pura. Si depèn d'una cosa que canvia (l'hora, el tauler, Math.random), la memòria cau retorna mentides.
  2. La clau ha de capturar totes les entrades. formatarData(iso) està bé; una funció que a més depengués de l'idioma necessitaria l'idioma a la clau.
  3. La memòria cau ha de tenir límit o invalidació. Un Map que creix indefinidament és una fuita de memòria de manual (09-03). Per a claus acotades —200 dates— no hi ha problema; per a claus il·limitades cal posar-hi un sostre o fer servir WeakMap.

Memoïtzar formatarData compleix les tres. Memoïtzar resum() no compleix la primera, i aquí hi ha el cas interessant.

  1. Una memòria cau ben invalidada a Tauler

Tauler.resum(avui) recorre les tasques per calcular total, obertes, hores totals, hores obertes, vençudes i esforç. Amb 600 tasques costa 1,4 ms. La vista el crida tres vegades per render (la barra de resum, la barra lateral i l'exportador), i un filtratge en viu de 23 pulsacions dispara 23 renders: 1,4 × 3 × 23 = 96 ms llençats a les escombraries recalculant exactament el mateix.

resum() no és pura: depèn de l'estat intern del tauler. Però és determinista mentre el tauler no canviï, i aquesta és justament la condició que permet una memòria cau amb invalidació explícita.

La tècnica correcta és un comptador de versió: cada mutació l'incrementa, i la memòria cau guarda amb quina versió es va calcular.

// js/model/tauler.js
export class Tauler {
  #tasques = [];
  #perId = new Map();
  #versio = 0;                  // s'incrementa a CADA mutació
  #cacheResum = null;           // { versio, avui, valor }

  // ─────────────── mutacions: totes invaliden ───────────────

  #invalidar() {
    this.#versio += 1;
  }

  afegir(tasca) {
    this.#tasques.push(tasca);
    this.#perId.set(tasca.id, tasca);
    this.#invalidar();
    return this;
  }

  eliminar(id) {
    const i = this.#tasques.findIndex((t) => t.id === id);
    if (i === -1) return false;
    this.#tasques.splice(i, 1);
    this.#perId.delete(id);       // l'índex, sincronitzat
    this.#invalidar();
    return true;
  }

  canviarEstat(id, desti) {
    const tasca = this.cercarPerId(id);
    if (tasca === null) throw new ErrorDeDades(`No existeix la tasca ${id}`);
    tasca.canviarEstat(desti);     // valida la R6 i pot llançar
    this.#invalidar();             // ← si això falta, la memòria cau menteix
    return tasca;
  }

  // ─────────────── lectura amb memòria cau ───────────────

  /**
   * Resum del tauler. Es recalcula només si el tauler ha canviat
   * o si es demana per a una altra data.
   */
  resum(avui = AVUI) {
    const c = this.#cacheResum;
    if (c !== null && c.versio === this.#versio && c.avui === avui) {
      return c.valor;                                  // encert: O(1)
    }

    const valor = this.#calcularResum(avui);
    this.#cacheResum = { versio: this.#versio, avui, valor };
    return valor;
  }

  #calcularResum(avui) {
    let horesTotals = 0, horesObertes = 0, obertes = 0, vencudes = 0, esforc = 0;

    for (const t of this.#tasques) {                   // UN sol recorregut
      horesTotals += t.horesEstimades;
      esforc += t.esforc();
      if (t.estaOberta()) {
        obertes += 1;
        horesObertes += t.horesEstimades;
        if (t.estaVencuda(avui)) vencudes += 1;
      }
    }

    // Object.freeze: la memòria cau retorna SEMPRE el mateix objecte; que ningú no el muti
    return Object.freeze({
      total: this.#tasques.length, obertes, horesTotals, horesObertes, vencudes, esforc
    });
  }

  horesObertes(avui = AVUI) {
    return this.resum(avui).horesObertes;              // reutilitza la memòria cau
  }
}

Cinc decisions d'aquest codi que són les que separen una memòria cau correcta d'un generador d'errors subtils:

  • La clau inclou avui. Sense això, demanar el resum per a una altra data retornaria el d'ahir. És la fallada d'invalidació més habitual: oblidar un paràmetre.
  • #invalidar() és a totes les mutacions, inclosa canviarEstat, que no toca l'array sinó l'interior d'una tasca. Si una sola ruta de mutació se n'oblida, la memòria cau menteix i l'error apareixerà dies després.
  • L'objecte retornat està congelat. Com que la memòria cau lliura sempre la mateixa referència, si algú fes r.vencudes = 0 corrompria l'estat de tots els futurs lectors. Object.freeze ho impedeix (i en mode estricte, llança).
  • Un sol recorregut en lloc de sis. La versió anterior encadenava filter i reduce; recórrer una vegada acumulant-ho tot abaixa el cost fins i tot en els errors de memòria cau.
  • horesObertes es recolza en resum. Una sola font de veritat i una sola memòria cau.

I la prova, perquè una memòria cau sense prova d'invalidació és una bomba de rellotgeria (08-03):

// proves/model/tauler-cache.test.js
describe('memòria cau del resum', () => {
  test('retorna el mateix objecte mentre el tauler no canviï', () => {
    const tauler = new Tauler('Taller Nómada', crearBacklog());
    expect(tauler.resum(AVUI)).toBe(tauler.resum(AVUI));            // toBe: mateixa referència
  });

  test("s'invalida en canviar l'estat d'una tasca", () => {
    const tauler = new Tauler('Taller Nómada', crearBacklog());
    expect(tauler.resum(AVUI).horesObertes).toBe(45);

    tauler.canviarEstat(3, 'en-curs');   // 'Actualitzar el web de reserves', 14 h
    tauler.canviarEstat(3, 'feta');
    expect(tauler.resum(AVUI).horesObertes).toBe(31);               // 45 − 14
  });

  test('es recalcula per a una altra data', () => {
    const tauler = new Tauler('Taller Nómada', crearBacklog());
    expect(tauler.resum('2026-09-20').vencudes).toBe(1);
    expect(tauler.resum('2026-12-31').vencudes).toBe(5);            // altra data, altre valor
  });
});

Resultat del mesurament 6 de la línia base:

Mesura Abans Després
resum() per crida (error de memòria cau) 1,4 ms 0,9 ms (un recorregut en lloc de sis)
resum() per crida (encert) 1,4 ms 0,0008 ms
Total en un filtratge de 23 pulsacions 96 ms 1,5 ms

  1. Gestors freqüents: debounce i throttle

Els 96 ms anteriors només eren la meitat del problema del cercador. L'altra meitat és que hi ha 23 renders on n'hi hauria d'haver 2. Cap càlcul no és tan ràpid com el càlcul que no es fa.

Ja vas fer servir debounce a 06-07 i 07-01, i a 06-07 es va advertir que no s'ha de confondre amb throttle. Aquí va la distinció completa.

flowchart TB
    subgraph E["Esdeveniments: l'usuari escriu 'serigrafia'"]
        direction LR
        e1["s"] --- e2["e"] --- e3["r"] --- e4["i"] --- e5["g"] --- e6["..."] --- e7["a"]
    end
    E --> D["debounce(300)<br/>espera que S'ATURIN"]
    E --> T["throttle(100)<br/>com a molt 1 cada 100 ms"]
    D --> D1["1 execució<br/>en acabar"]
    T --> T1["execucions regulars<br/>durant el procés"]
  • Debounce («esmorteir»): ajorna l'execució fins que hagin passat N ms sense esdeveniments nous. Si l'usuari continua escrivint, es continua ajornant. Executa una vegada, al final.
  • Throttle («estrangular»): executa com a molt una vegada cada N ms, descartant les crides intermèdies. Executa regularment, durant.
Criteri Debounce Throttle
Quan executa En acabar la ràfega A intervals, durant la ràfega
Interessa el resultat intermedi? No
Casos típics Cercador, desat automàtic, validació remota, resize final scroll, mousemove, pointermove, barra de progrés
Risc Si N és alt, se sent lent Executa més vegades: cal assegurar que cada execució sigui barata
A Nómada Tasques Filtre del cercador (300 ms), desat a localStorage (300 ms) Capçalera enganxosa en desplaçar (100 ms), posició de la finestra virtual (09-04)

I la implementació completa de util/temps.js, ampliant el debounce que ja tenies:

// js/util/temps.js

/**
 * Executa `fn` només quan han passat `espera` ms des de l'ÚLTIMA crida.
 * @param {Function} fn
 * @param {number} espera
 * @returns {Function} amb mètode .cancelar() per a la neteja (09-03)
 */
export function debounce(fn, espera = 300) {
  let temporitzador = null;

  const embolcallada = function (...args) {
    clearTimeout(temporitzador);
    temporitzador = setTimeout(() => fn.apply(this, args), espera);
  };

  embolcallada.cancelar = () => clearTimeout(temporitzador);   // imprescindible en destruir la vista
  return embolcallada;
}

/**
 * Executa `fn` com a molt una vegada cada `interval` ms.
 * Executa immediatament la primera vegada (vora d'entrada) i una última
 * al final de la ràfega (vora de sortida), que és el que gairebé sempre es vol.
 */
export function throttle(fn, interval = 100) {
  let ultima = 0;
  let pendent = null;

  const embolcallada = function (...args) {
    const ara = performance.now();
    const restant = interval - (ara - ultima);

    if (restant <= 0) {                  // ha passat l'interval: executa ja
      clearTimeout(pendent);
      pendent = null;
      ultima = ara;
      fn.apply(this, args);
    } else if (pendent === null) {       // programa la de la vora de sortida
      pendent = setTimeout(() => {
        ultima = performance.now();
        pendent = null;
        fn.apply(this, args);
      }, restant);
    }
  };

  embolcallada.cancelar = () => { clearTimeout(pendent); pendent = null; };
  return embolcallada;
}

La vora de sortida del throttle és el detall que la majoria d'implementacions casolanes obliden: sense ella, si l'usuari deixa de desplaçar-se just després d'una execució, l'última posició no es processa mai i la interfície es queda desactualitzada.

Aplicat al controlador de 06-04:

// js/vista/controlador.js
import { debounce, throttle } from '../util/temps.js';

// Cercador: només interessa el resultat final → debounce
const filtrar = debounce((text) => vista.actualitzar({ filtres: { text } }), 300);
$('#cercar').addEventListener('input', (e) => filtrar(e.target.value));

// Capçalera enganxosa: interessa el resultat durant el desplaçament → throttle
const actualitzarCapcalera = throttle(() => {
  document.body.classList.toggle('desplacat', window.scrollY > 120);
}, 100);
window.addEventListener('scroll', actualitzarCapcalera, { passive: true });

Aquest { passive: true } no és decoratiu: promet al navegador que el gestor no cridarà preventDefault(), i així pot desplaçar sense esperar que el teu codi acabi. Per a scroll, touchstart i wheel és pràcticament obligatori.

Mesurament del cercador amb les dues optimitzacions (memòria cau + debounce), escrivint «serigrafia» a velocitat normal:

Mesura Abans Amb memòria cau Amb memòria cau + debounce
Renders disparats 23 23 2
Temps total de JavaScript 412 ms 316 ms 28 ms
INP mesurat 480 ms 372 ms 96 ms

L'INP ja compleix l'objectiu de ≤ 200 ms. I fixa't en l'ordre de les columnes: la memòria cau sola no bastava, perquè el problema dominant no era el càlcul, sinó el render (que arreglarà 09-04). El debounce ha funcionat perquè elimina renders sencers.

Un últim matís sobre scroll: per a feina que afecta el que es veu, requestAnimationFrame sol ser millor que throttle amb un nombre fix de mil·lisegons, perquè se sincronitza amb el ritme real de la pantalla. Això és matèria de 09-04.

  1. Trossejar la feina llarga per no bloquejar el fil

A 05-07 va quedar establert: JavaScript té un sol fil per executar el teu codi i pintar la interfície; un bucle pesat la congela, i await no ho arregla perquè no cedeix el fil si no hi ha res asíncron de debò al darrere. La solució que s'hi anunciava era trossejar.

El patró consisteix a partir la feina en lots i cedir el control al navegador entre lot i lot, perquè pugui atendre esdeveniments i pintar.

// js/util/lots.js

/**
 * Cedeix el fil al navegador perquè pinti i atengui esdeveniments.
 * Es tria la millor API disponible al navegador actual.
 */
export function cedir() {
  // 1 · El millor si existeix: cedeix sense perdre la prioritat de la tasca
  if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') {
    return scheduler.yield();
  }
  // 2 · Reserva universal: una macrotasca (05-07). NO val una microtasca
  return new Promise((resolve) => setTimeout(resolve, 0));
}

/**
 * Processa `elements` en lots, cedint el fil entre lot i lot.
 * @param {Iterable} elements
 * @param {Function} feina           Què fer amb cada element.
 * @param {object} opcions
 * @param {number} opcions.pressupost   ms de feina abans de cedir.
 * @param {AbortSignal} opcions.signal  Per cancel·lar (07-03).
 * @param {Function} opcions.enProgressar
 */
export async function perLots(elements, feina, {
  pressupost = 8, signal, enProgressar
} = {}) {
  const llista = [...elements];
  let inici = performance.now();

  for (let i = 0; i < llista.length; i += 1) {
    signal?.throwIfAborted();          // cancel·lació cooperativa
    feina(llista[i], i);

    // Cedeix quan s'exhaureix el pressupost d'aquest fotograma, no cada N elements:
    // així s'adapta sol a màquines ràpides i lentes
    if (performance.now() - inici >= pressupost) {
      enProgressar?.(i + 1, llista.length);
      await cedir();
      inici = performance.now();
    }
  }
  enProgressar?.(llista.length, llista.length);
}

Tres detalls importants:

Cedir per temps, no per nombre d'elements. if (i % 100 === 0) és el que es veu a tot arreu, i està malament: en un portàtil ràpid cedeix massa (i va lent per excés d'anades i vingudes), i en un mòbil lent cedeix massa poc (i bloqueja igualment). Mesurar el pressupost s'adapta sol.

setTimeout(0) sí que cedeix; await Promise.resolve() no. És exactament la distinció entre macrotasques i microtasques de 05-07: les microtasques es buiden abans que el navegador pinti, així que cedir a una microtasca no permet pintar res. Aquest és un dels errors més freqüents en implementar el trossejat.

El pressupost de 8 ms surt de l'apartat 2 de 09-01: menys de 10 ms de JavaScript per fotograma.

I les tres maneres de cedir, comparades:

API Quan executa la continuació Ús adequat Compte
setTimeout(fn, 0) Macrotasca següent (mínim real ~4 ms amb imbricació) Reserva universal Competeix amb altres tasques; s'hi pot colar feina per davant
requestAnimationFrame Just abans del pintat següent Feina visual (09-04) No s'executa si la pestanya està amagada
requestIdleCallback Quan el navegador està ociós Feina no urgent: precalcular, enviar analítica Pot trigar molt, o no arribar mai si hi ha activitat
scheduler.yield() De seguida, conservant la prioritat L'opció moderna per trossejar Disponibilitat encara desigual: fes servir reserva

requestIdleCallback mereix un exemple, perquè és l'eina correcta per a la feina que pot esperar:

// Precalcular l'índex d'etiquetes quan el navegador no tingui res a fer
requestIdleCallback((termini) => {
  // termini.timeRemaining() diu quants ms queden del buit d'inactivitat
  if (termini.timeRemaining() > 5 || termini.didTimeout) {
    indexEtiquetes = agruparPerEtiqueta([...tauler]);
  }
}, { timeout: 2000 });   // si en 2 s no hi ha buit, executa-ho igualment

Aplicant perLots a la reconstrucció del tauler després de carregar 600 tasques:

Mesura En un bucle Per lots de 8 ms
Tasca més llarga 1.180 ms 41 ms
Temps total fins a acabar 1.180 ms 1.310 ms
Respon a un clic mentrestant? No Sí, en < 50 ms
Es pot mostrar progrés? No

Llegeix bé la segona fila: la feina total ha augmentat un 11 %, pel cost d'anar i tornar del bucle d'esdeveniments. I tot i així és una millora enorme, perquè el rendiment percebut no és el temps total, sinó el temps durant el qual l'usuari no pot fer res. Aquesta és una de les lliçons més contraintuïtives del mòdul.

Però trossejar té un límit: si la feina són 940 ms de càlcul pur, trossejar-la la reparteix però continua robant 940 ms al fil que ha de pintar. Per a això cal un altre fil.

  1. Web Workers: un altre fil de debò

Un Web Worker executa JavaScript en un fil separat del principal. No és una simulació ni un truc de planificació: és paral·lelisme real, en un altre nucli si n'hi ha.

A canvi, viu en un món a part:

No té
El seu propi fil i el seu propi bucle d'esdeveniments Accés al DOM (res de document)
fetch, WebSocket, IndexedDB, caches window, localStorage, alert
postMessage, import de mòduls ES Variables compartides amb el fil principal
performance, temporitzadors, crypto Accés directe als teus objectes

Aquesta última fila és la clau: worker i fil principal no comparteixen memòria. Es comuniquen passant-se missatges, i els missatges es copien.

flowchart LR
    subgraph P["Fil principal"]
        A["app.js"] --> B["DOM · pintat · esdeveniments"]
    end
    subgraph W["Worker"]
        C["planificador.worker.js<br/>càlcul pesat"]
    end
    A -->|"postMessage(dades)<br/>còpia estructurada"| C
    C -->|"postMessage(resultat)<br/>còpia estructurada"| A

L'esquelet mínim:

// js/planificacio/planificador.worker.js
// Un worker de tipus mòdul pot importar com qualsevol altre mòdul (05-04)
import { calcularInforme } from './informe.js';

self.addEventListener('message', (esdeveniment) => {
  const { tipus, id, dades } = esdeveniment.data;

  if (tipus !== 'informe') return;

  try {
    const resultat = calcularInforme(dades);         // 940 ms de càlcul… en UN ALTRE fil
    self.postMessage({ tipus: 'informe:ok', id, resultat });
  } catch (error) {
    // Els objectes Error sí que sobreviuen a la còpia estructurada, però no les classes pròpies
    self.postMessage({ tipus: 'informe:error', id, missatge: error.message });
  }
});
// js/planificacio/client-planificador.js

/** Embolcalla el worker en una API de promeses, que és com es vol fer servir (05-06). */
export class Planificador {
  #worker = null;
  #pendents = new Map();        // id → { resolve, reject }
  #seguentId = 0;

  #assegurarWorker() {
    if (this.#worker !== null) return this.#worker;

    this.#worker = new Worker(
      new URL('./planificador.worker.js', import.meta.url),
      { type: 'module' }                                 // imprescindible per fer servir import
    );

    this.#worker.addEventListener('message', ({ data }) => {
      const pendent = this.#pendents.get(data.id);
      if (pendent === undefined) return;
      this.#pendents.delete(data.id);                    // ← si falta, hi ha fuita (09-03)

      if (data.tipus === 'informe:ok') pendent.resolve(data.resultat);
      else pendent.reject(new Error(data.missatge));
    });

    // Errors del worker mateix (un import trencat, una excepció no capturada)
    this.#worker.addEventListener('error', (e) => {
      for (const { reject } of this.#pendents.values()) reject(new Error(e.message));
      this.#pendents.clear();
    });

    return this.#worker;
  }

  /** @returns {Promise<object>} l'informe calculat fora del fil principal. */
  informe(dades) {
    const worker = this.#assegurarWorker();
    const id = this.#seguentId += 1;

    return new Promise((resolve, reject) => {
      this.#pendents.set(id, { resolve, reject });
      worker.postMessage({ tipus: 'informe', id, dades });
    });
  }

  /** Neteja explícita: un worker viu reté memòria i un fil (09-03). */
  destruir() {
    this.#worker?.terminate();
    this.#worker = null;
    this.#pendents.clear();
  }
}

L'id per missatge no és un luxe: sense ell no pots tenir dues peticions en vol i aparellar cada resposta amb la seva promesa. És el mateix problema que resol l'id d'una petició HTTP.

El new URL(..., import.meta.url) és obligatori si fas servir un empaquetador (09-05): és el patró que Vite i companyia reconeixen per incloure el worker a la compilació. Una ruta en cadena de text no funcionaria després de l'empaquetatge.

  1. El cost de creuar la frontera: serialització i transferibles

Aquí hi ha el parany dels workers, i per què de vegades no compensen. Quan crides postMessage(dades), el navegador fa una clonació estructurada de les dades —el mateix algorisme de structuredClone que vas veure a 04-08— i lliura la còpia a l'altre fil. Copiar costa temps, i aquest temps es paga al fil emissor.

Primer, la comparació de mecanismes de còpia sobre les 600 tasques (uns 182 kB de JSON):

Mecanisme Mediana Què preserva
JSON.parse(JSON.stringify(x)) 8,4 ms Només tipus JSON: perd undefined, Date, Map, Set, funcions
structuredClone(x) 3,1 ms Date, Map, Set, RegExp, ArrayBuffer, referències cícliques
postMessage (anada) 3,3 ms Igual que structuredClone

structuredClone és més ràpid i més fidel: és la resposta correcta a «com faig una còpia en profunditat», i el viatge per JSON de 04-08 queda relegat a quan necessites específicament text (per a localStorage o per a la xarxa).

Una limitació crucial que cal tenir sempre present: la clonació estructurada no conserva les classes. Un objecte Tasca arriba al worker com un objecte pla, sense mètodes ni camps privats:

// ✗ Això falla al worker
worker.postMessage({ dades: [...tauler] });      // instàncies de Tasca
// dins del worker: dades[0].estaVencuda is not a function

// ✓ Serialitza a dades planes amb el toJSON que ja tens (05-02), i reconstrueix si cal
worker.postMessage({ dades: [...tauler].map((t) => t.toJSON()) });

Segon, els objectes transferibles. Alguns tipus —ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas— es poden transferir en lloc de copiar-se: la memòria canvia d'amo, sense còpia, en un temps pràcticament nul. El preu és que l'emissor perd l'accés (el búfer queda desvinculat).

// Transferir un búfer de 4,8 MB amb les dades numèriques de l'informe
const buffer = new Float64Array(600 * 5).buffer;

worker.postMessage({ tipus: 'calcular', buffer }, [buffer]);   // ← 2n argument: la llista
console.log(buffer.byteLength);   // 0 ← ja no és teu
Enviament d'un ArrayBuffer de 4,8 MB Cost al fil emissor
Copiat (postMessage(buffer)) 6,2 ms
Transferit (postMessage(buffer, [buffer])) 0,08 ms

La regla operativa completa per decidir si un worker compensa:

Un worker compensa quan el temps de càlcul és molt més gran que el de serialització. Si calcularàs 5 ms i serialitzaràs 3 ms d'anada i 3 de tornada, no ho facis. Si calcularàs 940 ms, fes-ho sense dubtar.

  1. L'informe de planificació, en un worker

L'informe trimestral del Taller Nómada agrupa les 600 tasques per responsable, setmana i etiqueta, calcula percentils de càrrega i busca la millor distribució d'hores. Costa 940 ms al fil principal. Mentre es calcula, la Marta veu la pantalla congelada: no respon a clics, no es desplaça, no pinta res. És exactament l'escenari de 05-07.

Amb el Planificador de l'apartat 11:

// js/vista/controlador.js
import { Planificador } from '../planificacio/client-planificador.js';

const planificador = new Planificador();

$('#generar-informe').addEventListener('click', async () => {
  const boto = $('#generar-informe');
  boto.disabled = true;
  boto.textContent = 'Calculant…';             // la interfície CONTINUA VIVA

  try {
    // toJSON: dades planes, perquè les classes no sobreviuen a la còpia (apartat 12)
    const informe = await planificador.informe([...tauler].map((t) => t.toJSON()));
    mostrarInforme(informe);
  } catch (error) {
    mostrarError(`No s'ha pogut generar l'informe: ${error.message}`);
  } finally {
    boto.disabled = false;
    boto.textContent = 'Generar informe';
  }
});

Fixa't en el que ha canviat i el que no: el codi de la vista és pràcticament idèntic a com seria amb una funció asíncrona normal. Embolcallar el worker en promeses (05-06) fa que la complexitat del pas de missatges quedi tancada dins de Planificador i no es propagui.

Mesurament de l'abans i el després (CPU 4×, mediana de 9 execucions, prement el botó i desplaçant la llista mentre es calcula):

Mesura Fil principal Web Worker
Temps total fins a veure l'informe 940 ms 1.012 ms (+8 %)
Bloqueig del fil principal 940 ms 52 ms (serialitzar anada + tornada)
Tasca més llarga al fil principal 940 ms 31 ms
INP durant el càlcul 620 ms 28 ms
Fotogrames perduts en desplaçar 56 0
Es pot cancel·lar? No Sí (terminate())

Un altre cop el patró contraintuïtiu: la feina total ha augmentat i l'experiència ha millorat radicalment. Vuitanta mil·lisegons de cost de missatgeria a canvi de 888 ms en què l'aplicació deixa d'estar morta. I una capacitat nova que abans era impossible: cancel·lar, perquè terminate() mata el fil de debò, cosa que un bucle al fil principal no permet fer de cap manera.

I la comparació honesta amb les alternatives del mòdul, per al mateix problema:

Enfocament Bloqueig Total Complexitat afegida Veredicte
Bucle directe 940 ms 940 ms Cap Inacceptable
perLots amb cessió 41 ms 1.070 ms Baixa Acceptable si no hi ha worker
Web Worker 52 ms 1.012 ms Mitjana La solució
WebAssembly (07-07) 940 ms 380 ms Alta No resol el bloqueig per si sol

L'última fila tanca la discussió de 07-07: Wasm feia el càlcul 2,5× més ràpid, però continuava bloquejant el fil principal, perquè el problema mai no va ser la velocitat bruta sinó qui ocupa el fil que pinta. Un worker en JavaScript normal guanya Wasm al fil principal. I si algun dia calgués el millor dels dos, un mòdul Wasm dins d'un worker és una combinació perfectament normal.

  1. Mites desmentits

Bona part del que es repeteix sobre «JavaScript ràpid» és folklore de fa quinze anys, quan els motors no tenien JIT. Aquí està mesurat, al portàtil de referència, sobre les 600 tasques, amb banc() i 15 repeticions.

Mite Realitat mesurada Veredicte
«for és molt més ràpid que forEach/map» 0,038 ms davant de 0,061 ms sobre 600 elements: 23 microsegons de diferència Irrellevant. Escriu el que es llegeixi millor
«++i és més ràpid que i++» Idèntics; el compilador genera el mateix quan no fas servir el valor Fals
«Concatenar cadenes amb + és lent; fes servir array.join» Cert el 2005. Avui els motors optimitzen la concatenació amb cordes (ropes): 600 concatenacions, 0,09 ms davant de 0,11 ms Fals avui
«delete és igual que assignar undefined» delete canvia la forma oculta i pot degradar l'objecte a diccionari Cert que són diferents, i delete és pitjor
«Les variables locals són més ràpides que les globals» Cert, però la diferència és de nanosegons tret dels bucles molt calents Irrellevant tret d'un mesurament
«try/catch impedeix l'optimització» Cert fins a ~2015. Avui TurboFan optimitza funcions amb try/catch sense problema Obsolet
«Les funcions fletxa són més ràpides» Idèntiques en temps d'execució; la diferència és semàntica (this), no de velocitat Fals
«Cal posar array.length en una variable al for» El motor ho fa sol des de fa més d'una dècada Obsolet
«Map sempre és més ràpid que un objecte» Per a claus de cadena i poques entrades, un objecte pot guanyar. Map guanya en inserció/esborrat freqüents i claus no textuals Depèn: mesura
«Menys línies és més ràpid» Sense cap relació Fals

La conclusió pràctica no és «res no importa»: és que l'eix que importa ha canviat de lloc. Ja no guanyes res triant entre for i forEach; guanyes 48× triant entre O(n²) i O(n), 130× no repetint un càlcul, i tot el rendiment percebut decidint qui ocupa el fil principal.

La llegibilitat guanya per defecte. Només un mesurament concret, sobre dades reals, justifica escriure codi menys clar. I quan ho facis, deixa un comentari amb el número que ho va justificar i la data, perquè d'aquí a dos anys el motor haurà canviat.

Errors Habituals i Consells

  • Microoptimitzar abans de mirar la complexitat. Canviar forEach per for en un algorisme quadràtic és pintar una paret que s'està caient.
  • No detectar el find dins del bucle. És la causa número u de lentitud amb volum, i es veu a simple vista un cop saps buscar-la.
  • Memoïtzar una funció impura. Si depèn del tauler, del rellotge o de l'atzar, la memòria cau retorna mentides i l'error apareixerà molt lluny de la seva causa.
  • Oblidar una ruta d'invalidació. canviarEstat no toca l'array de tasques, però canvia el resultat de resum. Si no invalida, la memòria cau menteix.
  • Deixar la clau de memòria cau incompleta. Oblidar avui a la clau del resum és un error de dades, no de rendiment, i dels que triguen setmanes a descobrir-se.
  • Memòries cau sense límit. Un Map que creix amb cada clau nova és una fuita (09-03).
  • Confondre debounce amb throttle. El debounce a scroll deixa la interfície congelada fins que l'usuari s'atura; el throttle en un cercador dispara peticions a mitja paraula.
  • Posar un debounce massa llarg. Per sobre de ~400 ms es percep com a lentitud. 250–300 ms és el rang habitual per a un cercador.
  • Crear la funció esmorteïda dins del gestor. Un input amb debounce(fn, 300) creat a cada esdeveniment no esmorteeix res: cada crida té el seu propi temporitzador. Crea-la una vegada, a fora.
  • Cedir amb await Promise.resolve(). És una microtasca: s'executa abans de pintar. No cedeix res (05-07).
  • Trossejar per nombre d'elements en lloc de per temps. No s'adapta a màquines diferents. Mesura el pressupost.
  • Ficar en un worker un càlcul de 5 ms. La serialització costarà més que el càlcul.
  • Enviar instàncies de classe per postMessage. Arriben com a objectes plans, sense mètodes. Fes servir toJSON().
  • Crear un worker per a cada operació. Arrencar-lo costa entre 10 i 40 ms. Reutilitza'n un, o un grup petit.
  • No cridar terminate(). Un worker viu reté el seu fil i tota la seva memòria (09-03).
  • Consell: abans d'optimitzar un càlcul, pregunta't si cal fer-lo. El càlcul més ràpid és el que no s'executa: memòria cau, debounce o carregar-lo sota demanda (09-05).
  • Consell: escriu una prova que fixi la complexitat. Comprovar que el temps amb 1.200 tasques és menys del triple que amb 600 detecta una regressió a quadràtic.
  • Consell: embolcalla sempre el worker en una API de promeses. El pas de missatges és un detall d'implementació que no ha de contaminar la vista.

Exercicis

Exercici 1 — Caçar el quadràtic. Aquest codi calcula, per a cada tasca, quantes altres comparteixen responsable i prioritat. Amb 600 tasques triga 214 ms.

export function companyesDeCarrega(tasques) {
  return tasques.map((t) => ({
    id: t.id,
    companyes: tasques.filter((o) =>
      o.id !== t.id && o.responsable === t.responsable && o.prioritat === t.prioritat
    ).length
  }));
}

Indica (a) la complexitat actual i quantes comparacions fa amb 600 tasques, (b) una reescriptura O(n) amb Map, (c) quina complexitat té la nova versió, i (d) quina millora esperes si el tauler creixés fins a 6.000 tasques.

Exercici 2 — Memòria cau amb invalidació per responsable. Tauler necessita carregaDe(responsable), que retorni les hores obertes d'aquella persona (els canònics: Iván 25 h, Lucía 14 h, Marta 6 h). Es crida una vegada per targeta durant el render, és a dir, 600 vegades. Escriu una versió amb memòria cau correctament invalidada, indica què ha de formar part de la clau, escriu les proves d'invalidació que garanteixin que no menteix, i calcula la millora esperada sabent que un càlcul costa 0,9 ms.

Exercici 3 — Worker, lots o res? Per a cadascuna d'aquestes quatre feines de Nómada Tasques, decideix entre «fil principal directe», «perLots amb cessió» i «Web Worker», justificant-ho amb el criteri de cost de serialització davant de cost de càlcul. Dades: el tauler serialitzat són 182 kB, la clonació dels quals costa 3,3 ms per trajecte.

  1. Validar el formulari de nova tasca (10 regles, 0,05 ms).
  2. Reconstruir 600 instàncies de Tasca des de localStorage en arrencar (103 ms).
  3. Calcular l'informe trimestral (940 ms).
  4. Recalcular el resum després de cada canvi d'estat (0,9 ms).

Solucions

Solució 1

(a) És O(n²). El map recorre les 600 tasques i, per a cadascuna, el filter recorre les 600: 360.000 comparacions, cadascuna amb tres condicions. D'aquí els 214 ms.

(b) L'observació clau: el filter sempre busca el mateix, la combinació responsable + prioritat. Es pot comptar una vegada quantes tasques hi ha de cada combinació i després consultar-ho en temps constant.

export function companyesDeCarrega(tasques) {
  // 1 · Un recorregut: comptar quantes tasques hi ha per combinació → O(n)
  const recompte = new Map();
  for (const t of tasques) {
    const clau = `${t.responsable}|${t.prioritat}`;
    recompte.set(clau, (recompte.get(clau) ?? 0) + 1);
  }

  // 2 · Un altre recorregut: consultar el recompte i restar-se un mateix → O(n)
  return tasques.map((t) => ({
    id: t.id,
    companyes: recompte.get(`${t.responsable}|${t.prioritat}`) - 1
  }));
}

El - 1 substitueix l'o.id !== t.id de l'original: cada tasca s'ha comptat a si mateixa.

(c) O(n): dos recorreguts complets i accessos O(1) al Map. Mesurat: 214 ms → 4,2 ms, unes 51× més ràpid.

(d) Amb 6.000 tasques, la versió quadràtica faria 36 milions de comparacions: al voltant de 21 segons (100× els 214 ms, perquè la feina creix amb el quadrat). La lineal faria 12.000 operacions: uns 42 ms (10× els 4,2 ms). La millora passaria de 51× a unes 500×. Aquest és el punt essencial de la complexitat: el benefici d'arreglar-la creix amb les dades, mentre que el d'una microoptimització es queda on és.

Solució 2

La clau ha d'incloure el responsable i avui (perquè «oberta» no depèn de la data, però si demà volguéssim filtrar per venciment sí; incloure-ho des del principi evita l'error clàssic), i la memòria cau sencera s'ha d'invalidar amb el comptador de versió del tauler.

// js/model/tauler.js
export class Tauler {
  #versio = 0;
  #cacheCarrega = { versio: -1, avui: null, valors: new Map() };

  /** Hores obertes d'una persona. Desades a la memòria cau per responsable. */
  carregaDe(responsable, avui = AVUI) {
    const c = this.#cacheCarrega;

    // Un canvi de versió o de data invalida el mapa SENCER, no una entrada
    if (c.versio !== this.#versio || c.avui !== avui) {
      c.versio = this.#versio;
      c.avui = avui;
      c.valors = new Map();
    }

    if (c.valors.has(responsable)) return c.valors.get(responsable);

    let hores = 0;
    for (const t of this.#tasques) {
      if (t.responsable === responsable && t.estaOberta()) hores += t.horesEstimades;
    }
    c.valors.set(responsable, hores);
    return hores;
  }
}

Buidar el Map sencer en canviar la versió és el correcte: una mutació pot afectar qualsevol responsable (un canvi de responsable n'afecta dos), així que invalidar-ne només una entrada deixaria dades ràncies. I com que el nombre de responsables està acotat, el Map no creix sense límit.

// proves/model/tauler-carrega.test.js
describe('carregaDe amb memòria cau', () => {
  let tauler;
  beforeEach(() => { tauler = new Tauler('Taller Nómada', crearBacklog()); });

  test('els valors canònics', () => {
    expect(tauler.carregaDe('Iván')).toBe(25);
    expect(tauler.carregaDe('Lucía')).toBe(14);
    expect(tauler.carregaDe('Marta')).toBe(6);
    expect(tauler.carregaDe('Iván') + tauler.carregaDe('Lucía') + tauler.carregaDe('Marta'))
      .toBe(tauler.resum(AVUI).horesObertes);           // 45
  });

  test("s'invalida en completar una tasca", () => {
    expect(tauler.carregaDe('Iván')).toBe(25);
    tauler.canviarEstat(6, 'en-curs');                  // fusteria, 5 h, de l'Iván
    tauler.canviarEstat(6, 'feta');
    expect(tauler.carregaDe('Iván')).toBe(20);          // 25 − 5
  });

  test("s'invalida per a TOTS els responsables, no només l'afectat", () => {
    tauler.carregaDe('Marta');                           // sembra la memòria cau
    tauler.afegir(new Tasca({ ...DADES_VALIDES, id: 99, responsable: 'Marta',
                              horesEstimades: 4, estat: 'pendent' }));
    expect(tauler.carregaDe('Marta')).toBe(10);          // 6 + 4
    expect(tauler.carregaDe('Iván')).toBe(25);           // continua correcte
  });

  test('memòria cau calenta: la segona crida no recalcula', () => {
    const t0 = performance.now(); tauler.carregaDe('Iván'); const fred = performance.now() - t0;
    const t1 = performance.now(); tauler.carregaDe('Iván'); const calent = performance.now() - t1;
    expect(calent).toBeLessThan(fred);
  });
});

Millora esperada: el render crida carregaDe 600 vegades, però només hi ha tres responsables. Sense memòria cau: 600 × 0,9 ms = 540 ms. Amb memòria cau: 3 càlculs + 597 encerts ≈ 2,7 ms. Unes 200× més ràpid, i —el que de debò importa— passa de dominar el render a ser-hi invisible.

Solució 3

# Feina Càlcul Serialització Decisió Justificació
1 Validar el formulari 0,05 ms Fil principal directe 0,05 ms és invisible; un worker costaria 130× més només en missatgeria
2 Reconstruir 600 Tasca 103 ms 6,6 ms perLots Bloqueja de més (103 ms > 50 ms), però el resultat són instàncies de classe, que no sobreviuen a la còpia: caldria reconstruir-les igualment al fil principal, i el worker no estalviaria res
3 Informe trimestral 940 ms 6,6 ms Web Worker Càlcul 142× més gran que la serialització, i el resultat són dades planes. Cas de llibre
4 Recalcular el resum 0,9 ms 3,3 ms Fil principal directe (amb memòria cau) La serialització d'anada ja costa gairebé quatre vegades més que el càlcul sencer. Aquí l'optimització correcta és la memòria cau de l'apartat 8, no un altre fil

El criteri general, resumit en una frase: si el càlcul no supera amb escreix els ~50 ms, no surtis del fil principal; si els supera però el resultat no viatja bé, trosseja; si els supera i viatja bé, fes servir un worker. I observa que en dos dels quatre casos la resposta correcta no és «paral·lelitzar» sinó «no calcular»: el número 4 es resol amb memòria cau i el número 1 no es resol perquè no és un problema.

Conclusió

Has atacat el primer front de la línia base i els números ho confirmen: l'INP del cercador ha baixat de 480 ms a 96 ms, els 96 ms de recàlculs són ara 1,5 ms, agruparPerEtiqueta ha passat de 148 ms a 3,1 ms, la tasca llarga de l'arrencada de 1.180 ms a 41 ms trossejant, i l'informe que congelava la pantalla 940 ms ara només ocupa el fil principal 52 ms. Tot això sense tocar ni una sola línia del DOM.

Saps com treballa el motor —anàlisi diferida, bytecode que arrenca ja, compilació del que és calent, i desoptimització quan una suposició falla— i coneixes les formes ocultes i la diferència entre accessos monomòrfics, polimòrfics i megamòrfics. Amb això has entès per què class Tasca, dissenyada a 05-02 pensant en la claredat, resulta ser també la forma més ràpida: tots els camps al constructor, en el mateix ordre, tipus estables. I has rebut l'avís que estalvia anys de ximpleries: no escriguis codi estrany per ajudar el JIT; escriu codi estable i llegible, que és el mateix.

Tens claríssim quin és l'eix que de debò mana: la complexitat. Reconeixes el senyal —un find, filter o includes dins d'un bucle sobre la mateixa col·lecció— i saps convertir O(n²) en O(n) amb un índex Map: 48× a agruparPerEtiqueta, 43× en aplicar un lot de 600 actualitzacions, amb la millora creixent a mesura que creixen les dades. I saps el preu de l'índex: memòria i l'obligació de mantenir-lo sincronitzat a cada mutació.

Saps no repetir feina: memoïtzar funcions pures amb les tres condicions verificades (puresa, clau completa, límit de mida), i posar una memòria cau amb invalidació per comptador de versió quan la funció no és pura però sí determinista entre mutacions —amb avui a la clau, #invalidar() a totes les rutes de mutació, el resultat congelat perquè ningú no el corrompi i proves d'invalidació que impedeixin que la memòria cau menteixi—. Saps no executar de més amb debounce (esperar que s'aturin els esdeveniments: cercador, desat automàtic) i throttle (executar a intervals durant la ràfega: scroll, pointermove), amb la seva vora de sortida i el seu { passive: true }.

I saps treure't del fil principal: trossejar amb perLots cedint per pressupost de temps i no per nombre d'elements, distingint les macrotasques que sí que cedeixen de les microtasques que no (05-07), amb requestIdleCallback per al que pot esperar i scheduler.yield() quan estigui disponible; i, quan això no basta, un Web Worker embolcallat en una API de promeses, amb el seu id per missatge, la seva gestió d'errors i el seu destruir(). Coneixes el cost de la frontera: la clonació estructurada és més ràpida i més fidel que el viatge per JSON de 04-08, però no conserva les classes, i hi ha transferibles que canvien d'amo en lloc de copiar-se. D'aquí la regla que decideix: un worker compensa quan el càlcul supera amb escreix la serialització. Amb això queda tancada també la comparació de 07-07: WebAssembly accelerava el càlcul però continuava bloquejant; el worker no bloqueja, que era el problema real.

Finalment, tens la taula de mites desmuntada amb mesuraments: for davant de forEach són 23 microsegons, ++i és idèntic a i++, concatenar cadenes ja no és lent, try/catch ja no impedeix optimitzar i posar .length en una variable fa una dècada que sobra. La llegibilitat guanya per defecte, i només un mesurament concret sobre dades reals justifica el contrari.

Queden tres fronts. I n'hi ha un que s'ha insinuat diverses vegades sense desenvolupar-se: en parlar del Map d'índexs vam dir «si oblides el delete, tens una fuita»; en parlar de memoïtzació, «una memòria cau sense límit és una fuita»; en parlar de debounce, «cal .cancelar() per netejar»; i en parlar del worker, «un worker viu reté el seu fil i tota la seva memòria». Quatre avisos, un mateix tema. La fila 8 de la línia base continua intacta: 37,7 MB retinguts després de 200 filtratges, memòria que l'aplicació reserva i no torna mai, fins que la pestanya es torna lenta i acaba morint. Això és una fuita de memòria, i trobar-la exigeix entendre com el navegador decideix què conservar i què llençar. Aquest és el tema de Gestió de Memòria.

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