Queden tres files de la línia base i cap d'elles no s'arregla escrivint millor JavaScript, perquè totes passen abans que s'executi la primera línia del teu codi: 4,1 s d'LCP, 0,21 de CLS i 214 kB en 28 peticions abans que aparegui la primera targeta. Durant aquests quatre segons, l'índex Map de 09-02, la neteja de fuites de 09-03 i la finestra virtual de 09-04 encara no existeixen: la Lucía mira una pantalla en blanc des del mòbil, al tren, amb Slow 4G. Aquesta lliçó tracta del que decideix aquest temps: què descarrega el navegador, en quin ordre, i quant d'això es fa servir de debò. Veuràs la ruta crítica de renderitzat i per què el CSS bloqueja mentre el JavaScript pot no fer-ho, tancant el que 01-03 i 06-01 van deixar apuntat sobre defer, async i type="module"; mesuraràs el pes real amb el panell Network i la pestanya Coverage; entendràs per fi què fa un empaquetador i per què existeix, amb una configuració mínima de Vite per a Nómada Tasques; tancaràs la remissió que 05-04 va deixar oberta sobre el tree shaking; dividiràs el codi amb import() dinàmic; aprendràs a precarregar amb preload, modulepreload, prefetch, preconnect i dns-prefetch; faràs diferides les imatges i els components; arreglaràs les fonts, que és on hi ha la meitat del CLS; i tancaràs amb la compressió, la memòria cau HTTP i la seva relació amb el service worker de 07-05. Al final, la taula de les deu files, completa, amb les seves columnes «abans» i «després».

Contingut

  1. El rendiment que es decideix abans d'executar una línia
  2. La ruta crítica de renderitzat
  3. Per què el CSS bloqueja i el JavaScript pot no fer-ho
  4. Mesurar el pes real: el panell Network
  5. La pestanya Coverage: quant codi descarregat no es fa servir
  6. El problema de la fila 10: 28 mòduls ES sense empaquetar
  7. Què fa un empaquetador i per què existeix
  8. Vite per a Nómada Tasques: configuració mínima i comentada
  9. npm run build: què genera i què canvia a index.html
  10. Minificació
  11. Tree shaking: per què els mòduls ES el fan possible
  12. Divisió de codi amb import() dinàmic
  13. El patró de càrrega: indicador, cancel·lació i fallada
  14. Precarregar: preload, modulepreload, prefetch, preconnect, dns-prefetch
  15. Càrrega diferida d'imatges
  16. Càrrega diferida de components amb IntersectionObserver
  17. Fonts: font-display, subconjunts, precàrrega i mètriques de reserva
  18. Tancar la fila 3: d'on sortia el CLS de 0,21
  19. Compressió: Gzip i Brotli
  20. Memòria cau HTTP, hashing i el service worker de 07-05
  21. El pressupost de rendiment en integració contínua
  22. La taula completa: les deu files, abans i després
  23. El que no s'ha resolt i el que ha costat
  24. Errors Habituals i Consells
  25. Exercicis
  26. Conclusió

  1. El rendiment que es decideix abans d'executar una línia

Hi ha una asimetria incòmoda en el rendiment web. Les tres lliçons anteriors han optimitzat l'execució: algorismes, memòria, manipulació del DOM. Però perquè s'executi alguna cosa, primer cal descarregar-la, analitzar-la i compilar-la, i aquesta feina prèvia té les seves pròpies regles.

Mira la cronologia real de Nómada Tasques a la línia base, amb Slow 4G i CPU 4× (que és el que Lighthouse simula per defecte i el que la Lucía té de debò al tren):

Moment t Què està passant
Petició del document 0 ms
Primer byte (TTFB) 610 ms El servidor respon
HTML analitzat 780 ms Es descobreixen el CSS i el mòdul d'entrada
CSS descarregat i aplicat 1.240 ms Fins aquí, pantalla en blanc obligatòria
FCP 2.300 ms Apareix la capçalera
Els 28 mòduls ES descarregats 3.150 ms En cascada, per nivells del graf
app.js acaba d'executar-se 3.790 ms
Primera targeta pintada (LCP) 4.100 ms

Cap d'aquests mil·lisegons no s'arregla amb un Map o amb una finestra virtual. Dels 4,1 segons, el teu codi s'executa durant 310 (que 09-04 va deixar en 31). Els altres 3,8 segons són xarxa, anàlisi i espera.

La conseqüència de mètode és la mateixa de 09-01, aplicada a un altre eix: el coll d'ampolla es mou. Vas optimitzar el render fins a fer-lo deu vegades més ràpid, i ara el render és l'1 % del problema de la primera càrrega. Optimitzar més el render no serviria de res; el que serveix és descarregar menys i en millor ordre.

I hi ha una segona raó, menys evident, per la qual descarregar menys codi també accelera l'execució. A 09-02 vas veure que l'analitzador de V8 és diferit: no analitza a fons el cos d'una funció fins que s'ha d'executar, però sí que ha de recórrer el fitxer sencer per saber on acaba cada funció. Un mòdul de 200 kB costa temps d'anàlisi encara que només en facis servir una funció. Reduir el JavaScript inicial no és només una optimització de xarxa: és també una optimització de CPU a l'arrencada, i per tant de la fila 4 de la línia base.

  1. La ruta crítica de renderitzat

La ruta crítica de renderitzat (critical rendering path) és la seqüència mínima de recursos que el navegador necessita per pintar el primer píxel de contingut. Tot el que és en aquesta ruta retarda l'FCP i, gairebé sempre, l'LCP.

flowchart TD
    A["Petició del document"] --> B["L'HTML arriba per trossos"]
    B --> C["L'analitzador construeix el DOM"]
    C -->|"troba &lt;link rel=stylesheet&gt;"| D["Descarregar CSS<br/><b>BLOQUEJA el renderitzat</b>"]
    C -->|"troba &lt;script&gt; sense defer/module"| E["Descarregar i executar JS<br/><b>BLOQUEJA l'anàlisi</b>"]
    C -->|"troba &lt;script type=module&gt;"| F["Descarregar en paral·lel<br/>executar en acabar el DOM"]
    D --> G["CSSOM complet"]
    C --> H["DOM complet"]
    G --> I["Arbre de renderitzat"]
    H --> I
    I --> J["Layout → Pintat → FCP"]
    F --> K["El JS muta el DOM"]
    K --> L["LCP: apareix el contingut principal"]
    J --> L
    style D fill:#fdd,stroke:#c00
    style E fill:#fdd,stroke:#c00
    style F fill:#dfd,stroke:#090

Hi ha dues idees que cal treure d'aquest diagrama, i són diferents entre si:

El CSS bloqueja el renderitzat. El navegador no pinta absolutament res fins a tenir tot el CSS que aplica a la pàgina. La raó és de sentit comú: si pintés abans, la pàgina apareixeria sense estils i saltaria així que arribessin, cosa que s'anomena FOUC (flash of unstyled content) i produeix un CLS espantós. Així que el navegador prefereix el blanc a la mentida.

Un <script> clàssic bloqueja l'anàlisi. Quan l'analitzador troba un <script src> sense defer ni async, s'atura: descarrega el fitxer, l'executa, i només llavors continua construint el DOM. I com que el script podria cridar document.write, no hi ha manera d'evitar-ho. Aquest és el motiu històric de posar els scripts al final del <body>.

A Nómada Tasques hi ha un sol CSS (css/estils.css, 18 kB) i un sol punt d'entrada (<script type="module" src="js/app.js">). El CSS bloqueja 460 ms amb Slow 4G, i el mòdul no bloqueja l'anàlisi però sí que arrossega la cascada de les seves 27 dependències.

  1. Per què el CSS bloqueja i el JavaScript pot no fer-ho

A 01-03 i 06-01 ja vas veure la taula de defer, async i type="module". La recuperem aquí, ampliada amb el que importa per a la ruta crítica:

Forma Bloqueja l'anàlisi de l'HTML? Quan s'executa Ordre garantit? Ús adequat
<script src> , mentre descarrega i executa Immediatament Pràcticament mai
<script defer src> No Després d'analitzar el document, abans de DOMContentLoaded Codi de l'aplicació
<script async src> No en descarregar; sí en executar Tan bon punt es descarregui No Scripts independents (analítica)
<script type="module"> No (és defer implícit) Després d'analitzar el document El que fa servir Nómada Tasques
<script type="module" async> No en descarregar; sí en executar Tan bon punt es descarregui No Mòduls sense dependències del DOM

Tres precisions que eviten errors freqüents:

async no és «millor que defer». És diferent. Un script async s'executa tan bon punt arriba, cosa que significa que pot executar-se enmig de l'anàlisi de l'HTML i bloquejar el fil principal en el pitjor moment possible. I com que no garanteix l'ordre, dos scripts async interdependents fallen de manera intermitent. defer és l'opció per defecte sensata; async, només per a codi que no depèn de res ni de ningú.

El CSS també es pot treure de la ruta crítica. Un <link rel="stylesheet"> bloqueja; però es pot marcar com a no bloquejant amb el truc del media:

<!-- Bloquejant: els estils que calen per pintar el que es veu al principi -->
<link rel="stylesheet" href="/assets/estils-8f3a1c.css">

<!-- No bloquejant: estils que només calen més tard (impressió, modals) -->
<link rel="stylesheet" href="/assets/impressio-2b7e40.css" media="print">
<link rel="stylesheet" href="/assets/modals-9c1d5a.css" media="print" onload="this.media='all'">

El segon patró funciona perquè el navegador descarrega amb prioritat baixa el CSS el media del qual no coincideix, i no bloqueja el renderitzat per ell; en carregar-se, l'onload canvia el media a all i els estils s'apliquen. És un truc conegut i legítim, però convé fer-lo servir poc: a Nómada Tasques només té sentit per al CSS d'impressió.

El <style> en línia no bloqueja la xarxa però sí que ocupa bytes de l'HTML. La tècnica del CSS crític consisteix a incrustar al <head> els pocs estils necessaris per pintar la part visible i carregar la resta de manera no bloquejant. És eficaç —a Nómada Tasques estalvia uns 300 ms d'FCP— però exigeix generar aquest fragment automàticament a la compilació, perquè mantenir-lo a mà és garantia que quedi obsolet. L'esmentem com a opció avançada i no l'aplicarem: amb un CSS de 18 kB, el benefici no compensa la complexitat.

  1. Mesurar el pes real: el panell Network

Ja vas fer servir Network a 08-01 per depurar i a 09-01 per anotar la línia base. Aquí el fem servir com a instrument de mesura, amb el procediment fix:

  1. Finestra d'incògnit.
  2. Disable cache marcat. Sense això mesures la teva segona visita.
  3. Estrangulament: Slow 4G.
  4. Recarregar i esperar que acabi tot.
  5. Mirar la barra de resum del peu i ordenar per Size i per Time.

Les columnes que importen, ampliant la taula de 09-01:

Columna Què et diu Senyal d'alarma
Size Dos valors: transferit / recursos Si coincideixen, no hi ha compressió
Priority Com prioritza el navegador La teva imatge LCP en Low és un problema
Waterfall Què espera què Esglaons = cascada de dependències
Initiator Qui ha demanat el recurs Troba importacions inesperades
Protocol h2, h3, http/1.1 En HTTP/1.1, moltes peticions sí que fan mal

La barra de resum de Nómada Tasques avui, ja citada a 09-01:

31 requests · 238 kB transferred · 512 kB resources · DOMContentLoaded 1,9 s · Load 4,3 s

I el desglossament per tipus, que és el que diu on treballar:

Tipus Peticions Transferit Recursos Comentari
Document 1 3,1 kB 9,4 kB
CSS 1 4,8 kB 18 kB Comprimit, correcte
JavaScript 28 214 kB 214 kB Sense comprimir, sense minificar
Fonts 1 94 kB 94 kB Una font completa
Imatges 2 41 kB 41 kB Sense dimensions declarades
Total 31 238 kB 512 kB

Tres coses salten a la vista i totes tres són d'aquesta lliçó: 28 peticions de JavaScript sense comprimir ni minificar, una font de 94 kB que resulta ser el segon recurs més pesat de la pàgina, i dues imatges sense dimensions, que és el clàssic generador de CLS.

Un apunt sobre HTTP/2 i HTTP/3, perquè circula una mitja veritat. És cert que amb HTTP/2 moltes peticions ja no costen el que costaven en HTTP/1.1: hi ha multiplexació sobre una sola connexió i no existeix el límit de sis peticions en paral·lel per domini. Però cada petició continua tenint un cost: capçaleres, latència d'anada i tornada si no és a la finestra de congestió, i —el que és important per a mòduls ES— una cascada per nivells. I aquesta cascada és exactament el problema de la fila 10.

  1. La pestanya Coverage: quant codi descarregat no es fa servir

Hi ha una eina a DevTools que gairebé ningú no obre i que respon a la pregunta més incòmoda: de tot el que has descarregat, quant s'ha executat?

S'obre des del menú d'ordres (Ctrl+Shift+P → Show Coverage), es prem el botó de recàrrega, i apareix una taula amb una barra per fitxer: en vermell el codi descarregat i no utilitzat, en blau el utilitzat.

El procediment honest té un matís: Coverage mesura fins al moment en què atures la gravació. Si atures acabat de carregar, veuràs un percentatge de desús altíssim que no significa «codi mort», sinó «codi encara no executat». La lectura correcta és:

  • Aturar just després de l'LCP per saber quant codi sobra a la ruta crítica → això és el que cal dividir.
  • Aturar després de fer servir l'aplicació sencera per saber quant codi és realment mort → això és el que cal esborrar.

Resultat sobre Nómada Tasques, aturant just després de la primera targeta:

Fitxer Bytes No utilitzat %
js/planificacio/informe.js 34,1 kB 34,1 kB 100 %
js/vista/editor-descripcio.js 41,8 kB 41,8 kB 100 %
js/util/format.js 12,4 kB 7,9 kB 64 %
js/dades/api-tasques.js 9,2 kB 6,1 kB 66 %
js/vista/formulari.js 11,7 kB 9,4 kB 80 %
js/i18n/*.js (3 idiomes) 12,3 kB 8,2 kB 67 %
Resta 92,5 kB 25,1 kB 27 %
Total JavaScript 214 kB 132,6 kB 62 %

El 62 % del JavaScript que la Lucía descarrega al tren no s'executa abans de veure la primera targeta. I dos fitxers —el càlcul de l'informe de planificació i l'editor enriquit de descripcions— sumen 76 kB al 100 % de desús: són codi que només cal quan algú prem un botó que la majoria de les visites no premen mai.

Aquí hi ha el pla de la lliçó, i surt d'un mesurament, no d'una intuïció:

  1. El que mai no es fa servir: esborrar-ho (tree shaking, apartat 11).
  2. El que es fa servir més tard: carregar-ho més tard (divisió de codi, apartat 12).
  3. El que es fa servir sempre: comprimir-ho, minificar-ho i desar-ho bé a la memòria cau (apartats 10, 19 i 20).

  1. El problema de la fila 10: 28 mòduls ES sense empaquetar

Fins ara, Nómada Tasques s'ha servit tal qual: el navegador rep app.js, veu els seus import, demana aquells fitxers, veu els seus import, demana els següents… És net, és exactament el que 05-04 va ensenyar, i en desenvolupament és meravellós. En producció té un problema concret: la cascada.

flowchart TD
    subgraph N1["Nivell 1 · 610 ms"]
        A["app.js"]
    end
    subgraph N2["Nivell 2 · +280 ms"]
        B["model/tauler.js"]
        C["vista/tauler-vista.js"]
        D["dades/repositori-local.js"]
    end
    subgraph N3["Nivell 3 · +280 ms"]
        E["model/tasca.js"]
        F["vista/targeta.js"]
        G["vista/dom.js"]
        H["dades/http.js"]
    end
    subgraph N4["Nivell 4 · +280 ms"]
        I["util/dates.js"]
        J["util/format.js"]
        K["util/temps.js"]
    end
    A --> B & C & D
    B --> E
    C --> F & G
    D --> H
    E --> I
    F --> J
    G --> K

El navegador no pot saber que necessita util/format.js fins que ha descarregat i analitzat vista/targeta.js, que al seu torn necessitava vista/tauler-vista.js, que necessitava app.js. Amb Slow 4G, cada nivell costa un viatge d'anada i tornada d'uns 280 ms. Quatre nivells són 1,1 segons d'espera pura, sense descarregar amb prou feines bytes.

I a més, els 28 fitxers porten comentaris, noms llargs i espais, perquè són codi font llegible. Els 214 kB són, en bona part, aire.

Cost de servir mòduls ES sense processar Magnitud
Cascada de descobriment (4 nivells × 280 ms) 1.120 ms
Capçaleres i sobrecàrrega de 28 peticions ~95 ms
Bytes sobrants (comentaris, noms, format) ~96 kB
Sense compressió (servidor de desenvolupament) ~150 kB

Això no és un defecte dels mòduls ES: és que van ser dissenyats per expressar el graf de dependències, no per ser el format de lliurament òptim. L'eina que tradueix l'un en l'altre és l'empaquetador.

  1. Què fa un empaquetador i per què existeix

Un empaquetador (bundler) llegeix el teu punt d'entrada, recorre el graf complet d'import, i produeix un o diversos fitxers optimitzats per al lliurament. Pel camí fa diverses coses diferents que convé no confondre:

Tasca Què fa Què resol
Empaquetatge Uneix mòduls en pocs fitxers La cascada i el nombre de peticions
Resolució Troba import 'web-vitals' a node_modules Els mòduls ES del navegador no saben resoldre bare imports
Minificació Treu espais, comentaris i escurça noms locals Bytes
Tree shaking Elimina exportacions que ningú no importa Bytes de codi mort
Divisió de codi Separa el que es carrega sota demanda Bytes a la ruta crítica
Hashing Reanomena a index-8f3a1c.js segons el contingut Memòria cau eterna sense risc de servir el que és vell
Transformació Compila TypeScript, JSX, CSS modern Compatibilitat
Actius Converteix imatges, fonts i CSS en recursos amb hash Coherència del desplegament

De totes elles, la que justifica per si sola l'existència de l'eina és la segona. Això, que a Node funciona de sempre, no funciona al navegador:

import { onLCP } from 'web-vitals';   // ✗ el navegador no sap què és 'web-vitals'

El navegador només entén rutes: ./x.js, /x.js, https://…. Un identificador nu com ara web-vitals no li diu res. Existeixen els import maps per resoldre-ho a mà, però quan tens cinc dependències amb les seves pròpies dependències, mantenir-los és inviable.

I un aclariment important, perquè és font de confusió constant: empaquetar no vol dir «un sol fitxer». Aquell era el model del 2015, quan HTTP/1.1 penalitzava molt les peticions. Avui l'objectiu és «pocs fitxers, ben triats»: un nucli que sempre cal, i trossos separats per al que només cal de vegades, cadascun amb el seu propi hash perquè la memòria cau funcioni amb gra fi.

Panorama d'eines, per situar-se:

Eina Què és Quan triar-la
Vite Servidor de desenvolupament amb mòduls natius + Rollup per a producció Per defecte avui per a una aplicació web
Rollup Empaquetador orientat a ESM; el millor tree shaking Llibreries
esbuild Empaquetador i minificador en Go; extremadament ràpid Quan la velocitat mana
webpack El veterà; el més configurable i el més complex Projectes grans heretats
Parcel Zero configuració Prototips
Cap Mòduls ES natius Projectes petits, demostracions, aprenentatge

Triem Vite per un motiu que encaixa amb el mòdul: en desenvolupament no empaqueta res, serveix els mòduls ES natius igual que fins ara (per això arrenca a l'instant i la recàrrega és immediata), i només empaqueta en compilar per a producció. El codi que has escrit en nou mòduls no canvia ni una línia.

  1. Vite per a Nómada Tasques: configuració mínima i comentada

npm install --save-dev vite rollup-plugin-visualizer
// vite.config.js
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  // Arrel del projecte: on és index.html. Vite parteix de l'HTML, no del JS
  root: '.',

  // Ruta base amb què es generen les URL dels actius.
  // Si desplegues a https://taller.example/app/, aquí hi va '/app/'
  base: '/',

  build: {
    outDir: 'dist',                 // carpeta de sortida
    assetsDir: 'assets',            // dins de dist/
    emptyOutDir: true,              // neteja dist/ abans de compilar
    sourcemap: true,                // mapes d'origen: depurar producció (08-01)
    target: 'es2022',               // no transpilar de més: camps privats, groupBy…
    cssCodeSplit: true,             // un CSS per punt d'entrada

    // Avisar si un tros supera el pressupost (apartat 21)
    chunkSizeWarningLimit: 80,      // en kB

    rollupOptions: {
      output: {
        // Noms amb hash de contingut: memòria cau eterna sense risc (apartat 20)
        entryFileNames:  'assets/[name]-[hash].js',
        chunkFileNames:  'assets/[name]-[hash].js',
        assetFileNames:  'assets/[name]-[hash].[ext]',

        /**
         * Trossos manuals. Només dos, i per una raó concreta:
         * el model i les utilitats canvien molt menys que la vista,
         * així que separar-los fa que un canvi a la interfície no invalidi
         * la memòria cau de la part estable.
         */
        manualChunks(id) {
          if (id.includes('/js/model/') || id.includes('/js/util/')) return 'model';
          if (id.includes('node_modules/web-vitals')) return 'vitals';
          return undefined;         // la resta, que ho decideixi Rollup
        }
      }
    }
  },

  server: {
    port: 5173,
    open: true
  },

  preview: {
    port: 4173                      // el que fa servir Lighthouse CI a 09-01
  },

  // L'informe visual del paquet: dist/informe-paquet.html
  plugins: [
    visualizer({
      filename: 'dist/informe-paquet.html',
      gzipSize: true,
      brotliSize: true
    })
  ]
});

Cinc decisions d'aquest fitxer que no són cosmètiques:

  • target: 'es2022'. Transpilar a ES5 «per si de cas» és avui un error car: engreixa el paquet un 20–30 % i afegeix polyfills que cap navegador amb suport de mòduls ES no necessita. Nómada Tasques fa servir camps privats (05-03) i Object.groupBy (06-06); es2022 els conserva tal qual.
  • sourcemap: true. Sense mapes d'origen, un error en producció és una pila de crides il·legible amb noms d'una lletra. Els mapes es generen com a fitxers a part i no es descarreguen tret que s'obri DevTools, així que no costen res a l'usuari. Puja'ls, o com a mínim puja'ls al teu servei d'errors.
  • manualChunks amb criteri, no per costum. La raó per separar model no és la mida, és la freqüència de canvi: la vista es toca cada setmana i el model cada uns quants mesos. Separar-los fa que un desplegament de la interfície no obligui els usuaris a tornar a descarregar el model.
  • cssCodeSplit: true fa que cada punt d'entrada tingui el seu CSS, i que el CSS d'un tros carregat dinàmicament es carregui amb ell.
  • chunkSizeWarningLimit: 80 és literalment el pressupost de la fila 10 de la línia base, posat a l'eina.

I els guions del package.json:

{
  "name": "nomada-tasques",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "jest",
    "test:e2e": "cypress run",
    "lint": "eslint js proves",
    "analitzar": "vite build && open dist/informe-paquet.html",
    "pressupost": "node scripts/pressupost.mjs"
  }
}

  1. npm run build: què genera i què canvia a index.html

npm run build
vite v5.4.0 building for production...
✓ 34 modules transformed.
dist/index.html                        1,84 kB │ gzip:  0,79 kB
dist/assets/estils-4c9e21.css         17,90 kB │ gzip:  3,91 kB │ brotli: 3,22 kB
dist/assets/vitals-9c1d5a.js           2,61 kB │ gzip:  1,18 kB │ brotli: 1,02 kB
dist/assets/model-2b7e40.js           11,64 kB │ gzip:  4,02 kB │ brotli: 3,44 kB
dist/assets/index-8f3a1c.js           44,08 kB │ gzip: 15,71 kB │ brotli: 14,93 kB
dist/assets/informes-5a3f18.js        18,22 kB │ gzip:  6,44 kB │ brotli: 5,71 kB
dist/assets/editor-7e1b93.js          24,36 kB │ gzip:  8,12 kB │ brotli: 7,05 kB
dist/assets/planificador.worker-c40d7e.js  9,11 kB │ gzip: 3,28 kB
dist/assets/ca-1f8a06.js               1,92 kB │ gzip:  0,74 kB
dist/assets/en-4d2c77.js               1,88 kB │ gzip:  0,71 kB
✓ built in 1.42s

El primer que cal aprendre a llegir en aquesta sortida és què és i què no és a la ruta crítica. Els tres primers fitxers JavaScript (index, model, vitals) els demana l'HTML: són la ruta crítica. informes, editor, planificador.worker, ca i en no apareixen a l'HTML: són trossos que només es descarregaran quan algú els demani, i això és obra de l'apartat 12.

I l'index.html generat. Aquest és el de partida:

<!-- index.html — abans (font) -->
<link rel="stylesheet" href="/css/estils.css">
<script type="module" src="/js/app.js"></script>

I això és el que Vite escriu a dist/index.html:

<!-- dist/index.html — generat -->
<link rel="stylesheet" crossorigin href="/assets/estils-4c9e21.css">
<script type="module" crossorigin src="/assets/index-8f3a1c.js"></script>
<link rel="modulepreload" crossorigin href="/assets/model-2b7e40.js">
<link rel="modulepreload" crossorigin href="/assets/vitals-9c1d5a.js">

Fixa't en el que ha fet sense que li ho demanéssim: a més de substituir les rutes per les versions amb hash, ha afegit dos <link rel="modulepreload">. Això elimina la cascada: el navegador descobreix els tres fitxers JavaScript en analitzar el <head>, i els demana en paral·lel, sense esperar a analitzar index-8f3a1c.js per descobrir que necessita model-2b7e40.js. És l'apartat 14, aplicat automàticament.

El resultat sobre la fila 10, mesurant amb Slow 4G en incògnit:

Pas JS abans de la 1a targeta Peticions JS LCP
Línia base: 28 mòduls ES sense processar 214 kB 28 4,1 s
Empaquetat sense minificar (build.minify: false) 209 kB 2 3,2 s
Amb minificació (apartat 10) 118 kB 2 2,8 s
Amb tree shaking (apartat 11) 104 kB 2 2,7 s
Amb divisió de codi (apartat 12) 58,3 kB 3 2,4 s

Llegeix-ho amb atenció, perquè cada fila ensenya alguna cosa diferent. Empaquetar sense minificar amb prou feines estalvia bytes (5 kB) però retalla gairebé un segon: el que estava matant l'LCP no era el pes, era la cascada de 28 peticions en quatre nivells. La minificació és la que sí que estalvia bytes, gairebé la meitat. I la divisió de codi en treu uns altres 46 kB, que és el que la pestanya Coverage havia assenyalat com a «descarregat i no utilitzat».

Un canvi de flux de treball que convé assumir explícitament, perquè té cost: a partir d'ara, l'aplicació ja no s'obre fent doble clic a index.html. Hi ha un pas de compilació. En desenvolupament es fa servir npm run dev (que serveix mòduls natius, sense empaquetar, amb recàrrega instantània) i en producció npm run build + npm run preview. Tornarem sobre aquest cost a l'apartat 23.

  1. Minificació

Minificar és reescriure el codi perquè ocupi menys sense canviar el que fa. Vite ho fa per defecte amb esbuild. Concretament:

Transformació Exemple
Treure espais i salts de línia Tot el fitxer en poques línies
Treure comentaris Els JSDoc del teu codi no arriben a l'usuari
Escurçar noms locals const horesObertesconst a
Simplificar expressions if (x) { return 1 } else { return 2 }return x?1:2
Eliminar codi inabastable El que ve després d'un return
Plegar constants 60 * 100060000

Un fragment real, abans i després:

// Abans: js/model/tauler.js (font)
  /**
   * 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;
    }
    const valor = this.#calcularResum(avui);
    this.#cacheResum = { versio: this.#versio, avui, valor };
    return valor;
  }
// Després: assets/model-2b7e40.js (minificat)
resum(t=g){const e=this.#c;if(e!==null&&e.v===this.#v&&e.h===t)return e.r;const s=this.#p(t);return this.#c={v:this.#v,h:t,r:s},s}

Tres coses importants sobre això:

Els noms exportats no s'escurcen (tret d'amb configuració avançada), perquè un altre mòdul els pot importar per nom. Només s'escurcen els locals, que és on hi ha la major part del text.

Minificar no ofusca. Amb els mapes d'origen activats, DevTools et mostra el codi original en depurar. La minificació no és una mesura de seguretat i no s'ha de fer servir com a tal.

Comprimir i minificar són coses diferents i se sumen. Minificar redueix el text; comprimir (apartat 19) redueix els bytes que viatgen pel cable. Els 44,08 kB de l'index minificat viatgen com a 14,93 kB amb Brotli.

Estat del fitxer d'entrada Mida
Font, 28 fitxers 214 kB
Empaquetat sense minificar 209 kB
Empaquetat i minificat 118 kB
Empaquetat, minificat i amb tree shaking 104 kB
Empaquetat, minificat, dividit 58,3 kB
…i transferit amb Brotli 19,4 kB

De 214 kB a 19,4 kB pel cable. I encara no ha canviat ni una línia del codi font.

  1. Tree shaking: per què els mòduls ES el fan possible

A 05-04 es va dir: «el tree shaking i els empaquetadors es veuran a 09-05». Ha arribat el moment.

Sacsejar l'arbre és eliminar del paquet final les exportacions que ningú no importa. El nom ve de la imatge d'agitar un arbre perquè caiguin les fulles mortes.

L'interessant és per què es pot fer, i la resposta enllaça amb una cosa que 05-04 va explicar per un altre motiu: els import i export dels mòduls ES són estàtics. Han de ser al nivell superior, no poden anar dins d'un if ni d'una funció, i els seus noms són literals. Aquesta restricció, que en el seu moment podia semblar una molèstia, és exactament el que permet a una eina saber amb certesa, sense executar res, quines exportacions es fan servir i quines no.

Amb CommonJS això és impossible:

// CommonJS: l'eina no pot saber què s'importa fins a executar-ho
const nom = condicio ? 'formatarData' : 'formatarHores';
const fn = require('./util/format.js')[nom];   // ✗ indecidible estàticament
// ESM: els noms són literals i són al nivell superior. Decidible
import { formatarData } from './util/format.js';   // ✓ només això es conserva

El cas concret a Nómada Tasques és util/format.js, que des de 07-06 crea nou formatadors d'Intl:

// js/util/format.js
const LOCAL = 'ca-ES';

const DATA_LLARGA = new Intl.DateTimeFormat(LOCAL, { dateStyle: 'long' });
const DATA_CURTA  = new Intl.DateTimeFormat(LOCAL, { day: 'numeric', month: 'short' });
const DATA_HORA   = new Intl.DateTimeFormat(LOCAL, { dateStyle: 'medium', timeStyle: 'short' });
const RELATIU     = new Intl.RelativeTimeFormat(LOCAL, { numeric: 'auto' });
const NUMERO      = new Intl.NumberFormat(LOCAL, { maximumFractionDigits: 1 });
const HORES       = new Intl.NumberFormat(LOCAL, { style: 'unit', unit: 'hour', unitDisplay: 'long' });
const PERCENTATGE = new Intl.NumberFormat(LOCAL, { style: 'percent', maximumFractionDigits: 0 });
const LLISTA      = new Intl.ListFormat(LOCAL, { style: 'long', type: 'conjunction' });
const COMPARADOR  = new Intl.Collator(LOCAL, { sensitivity: 'base', numeric: true });

export function dataLlegible(iso)      { /* fa servir DATA_LLARGA */ }
export function dataCurta(iso)         { /* fa servir DATA_CURTA */ }
export function dataIHora(iso)         { /* fa servir DATA_HORA */ }
export function fa(dies)               { /* fa servir RELATIU */ }
export function numero(n)              { /* fa servir NUMERO */ }
export function horesLlegibles(n)      { /* fa servir HORES */ }
export function percentatge(n)         { /* fa servir PERCENTATGE */ }
export function llistaLlegible(items)  { /* fa servir LLISTA */ }
export function compararTextos(a, b)   { /* fa servir COMPARADOR */ }

D'aquestes nou funcions, la ruta crítica en fa servir tres: dataCurta a .tasca__meta, horesLlegibles al comptador de columna i compararTextos a l'ordre per títol. Les altres sis les fan servir l'informe i l'exportador, que ja no són a la ruta crítica. El tree shaking elimina aquestes sis funcions i els seus formatadors, i aquí hi ha bona part dels 14 kB que es perden en aquest pas.

Ara, les quatre condicions perquè el tree shaking funcioni de debò, perquè falla molt més sovint del que la gent es pensa:

1 · Res d'importacions amb comodí quan ho puguis evitar.

// ✗ Impedeix sacsejar: demana l'objecte sencer
import * as format from './util/format.js';
element.textContent = format.dataCurta(t.dataLimit);

// ✓ Importació nominal: l'eina sap exactament què es fa servir
import { dataCurta } from './util/format.js';

A la pràctica, Rollup és prou llest per sacsejar molts import * quan l'ús és analitzable, però n'hi ha prou que passis format a una altra funció perquè l'hagi de conservar sencer. La importació nominal no falla mai.

2 · Compte amb els efectes secundaris de mòdul. Si un mòdul fa alguna cosa en importar-se —registrar un gestor, mutar un global, crear un objecte car—, l'empaquetador no el pot eliminar encara que no en facis servir cap exportació, perquè no sap si aquell efecte importa.

// ✗ Efecte secundari en importar: aquest mòdul no es podrà eliminar mai
document.body.classList.add('amb-js');
export function res() {}

La manera de declarar que els teus mòduls són nets és el camp sideEffects del package.json:

{
  "sideEffects": ["**/*.css", "./js/app.js"]
}

Això diu: «tot el meu codi és lliure d'efectes secundaris tret dels CSS i el punt d'entrada». Sense aquesta declaració, moltes eines assumeixen el pitjor i conserven de més.

3 · La llibreria ha d'estar publicada com a ESM. Una dependència que només ofereix CommonJS no es pot sacsejar. web-vitals publica ESM, per això els seus 2,6 kB són només el que en fas servir. És un criteri real a l'hora de triar dependències.

4 · El codi mort ha de ser realment inabastable. Si una funció es fa servir en una branca que només s'executa en desenvolupament, continua estant referenciada. Per a això hi ha les constants de compilació:

// Vite substitueix import.meta.env.DEV per `false` en compilar,
// i llavors el minificador elimina el bloc sencer com a codi mort
if (import.meta.env.DEV) {
  const { instrumentar } = await import('./util/mesura.js');
  instrumentar(vista);
}

I una manera de comprovar que tot això funciona en lloc de suposar-ho: l'informe de rollup-plugin-visualizer que vam configurar a l'apartat 8.

npm run analitzar

Obre un mapa d'arbre on la mida de cada rectangle és el pes del mòdul al paquet final. És l'eina que respon a «per què el meu paquet pesa 140 kB?», i la resposta sol ser una dependència que no sabies que estaves arrossegant.

  1. Divisió de codi amb import() dinàmic

A 05-04 vas conèixer import() dinàmic: s'assembla a una crida a funció, retorna una promesa, admet rutes variables i pot anar dins d'un if. Allà es va presentar com a sintaxi; aquí és l'eina que treu 46 kB de la ruta crítica.

La regla per decidir què dividir surt directament de la taula de Coverage de l'apartat 5:

Divideix el que compleixi les tres condicions: (1) pesa, (2) no es fa servir a la primera pantalla i (3) s'activa per una acció concreta de l'usuari. Si en falta qualsevol de les tres, no ho divideixis: afegiràs una espera sense estalviar res rellevant.

A Nómada Tasques hi ha exactament tres candidats.

12.1 L'informe de planificació i el seu Web Worker

El mòdul d'informes pesa 18,2 kB minificat i arrossega el worker de 09-02. El fa servir la Marta una vegada al trimestre.

// js/vista/controlador.js

// ✗ Abans: importació estàtica. 18 kB i el worker a la ruta crítica
// import { Planificador } from '../planificacio/client-planificador.js';

let planificador = null;

$('#generar-informe').addEventListener('click', async () => {
  const boto = $('#generar-informe');
  boto.disabled = true;
  boto.textContent = 'Carregant…';

  try {
    // ✓ Es descarrega la PRIMERA vegada que algú el prem. Després ja és a memòria
    if (planificador === null) {
      const { Planificador } = await import('../planificacio/client-planificador.js');
      planificador = new Planificador();
    }

    boto.textContent = 'Calculant…';
    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';
  }
}, { signal: controlador.signal });

Fixa't en un detall que Vite resol sol: el Planificador crea el seu worker amb new Worker(new URL('./planificador.worker.js', import.meta.url), { type: 'module' }). Aquest patró, que a 09-02 es va presentar com a «obligatori si fas servir un empaquetador», és exactament el que permet a Vite descobrir el worker, compilar-lo com un tros a part amb el seu hash (planificador.worker-c40d7e.js) i reescriure la URL. Amb una ruta en cadena de text, el worker no s'hauria inclòs a la compilació i en producció donaria un 404.

12.2 L'editor enriquit de descripcions

Pesa 24,4 kB i només apareix quan l'Iván prem «Editar descripció» en una targeta. És el cas de llibre.

// js/vista/formulari.js
import { $ } from './dom.js';

/** Carrega l'editor la primera vegada que cal. Desa la promesa, no el mòdul. */
const carregarEditor = (() => {
  let promesa = null;
  return () => (promesa ??= import('./editor-descripcio.js'));
})();

$('#nova-tasca').addEventListener('click', async (esdeveniment) => {
  if (esdeveniment.target.closest('[data-accio="editar-descripcio"]') === null) return;

  const caixa = $('#descripcio');
  caixa.classList.add('carregant');

  try {
    const { muntarEditor } = await carregarEditor();
    muntarEditor(caixa, { enDesar: desarDescripcio });
  } catch (error) {
    // Degradació elegant: el <textarea> normal continua funcionant
    console.warn("No s'ha pogut carregar l'editor enriquit", error);
    caixa.focus();
  } finally {
    caixa.classList.remove('carregant');
  }
}, { signal: controlador.signal });

Dues decisions interessants en aquest fragment:

  • Es desa la promesa, no el mòdul. promesa ??= import(...) garanteix que dos clics ràpids no llancin dues descàrregues: el segon rep la mateixa promesa en vol. És un patró que convé tenir automatitzat.
  • La fallada té una sortida digna. Si el tros no es descarrega —xarxa caiguda, desplegament a mitges— el <textarea> normal continua sent-hi i l'usuari pot escriure. Això és millora progressiva, el mateix principi de 07-06.

12.3 Les traduccions

Cada idioma pesa uns 1,9 kB. Descarregar-ne els tres per fer-ne servir un és llençar-ne dos terços, i és el cas on la ruta variable d'import() brilla:

// js/i18n/index.js

const IDIOMES = new Set(['es', 'ca', 'en']);
const carregats = new Map();

/**
 * Carrega els textos d'un idioma sota demanda.
 * El literal parcial de la plantilla és IMPRESCINDIBLE: diu a l'empaquetador
 * quins fitxers són candidats, i per això genera ca-*.js i en-*.js com a trossos.
 */
export async function carregarIdioma(codi) {
  if (!IDIOMES.has(codi)) codi = 'es';
  if (carregats.has(codi)) return carregats.get(codi);

  const promesa = import(`./textos/${codi}.js`).then((m) => m.textos);
  carregats.set(codi, promesa);
  return promesa;
}

El detall que cal entendre és el del comentari: import(variable) a seques és indecidible per a l'empaquetador, que no sabria què incloure i fallaria en temps d'execució. import(\./textos/${codi}.js`)`, amb la part fixa visible al literal, li permet compilar tots els fitxers que encaixin amb el patró com a trossos independents, i triar-ne un en temps d'execució. És una restricció que cal conèixer.

A més, el castellà es resol sense descàrrega perquè és l'idioma per defecte i els seus textos són al paquet principal. Només la Marta, que té el navegador en català, descarrega 1,9 kB extra.

Resultat de les tres divisions:

Tros Mida Quan es descarrega % de visites que el demanen
index-8f3a1c.js 44,1 kB Sempre 100 %
model-2b7e40.js 11,6 kB Sempre 100 %
vitals-9c1d5a.js 2,6 kB Sempre 100 %
informes-5a3f18.js + worker 27,3 kB En prémer «Generar informe» 4 %
editor-7e1b93.js 24,4 kB En editar una descripció 11 %
ca-1f8a06.js / en-4d2c77.js 1,9 kB Segons l'idioma 22 %

58,3 kB per al 100 % de les visites, en lloc de 104 kB. I l'estalvi no es reparteix per igual: qui només consulta el tauler —la majoria— descarrega 58 kB i prou.

I un avís contra l'entusiasme, perquè dividir de més és un error real: cada import() és un viatge d'anada i tornada. Si divideixes un mòdul de 3 kB que es necessita mig segon després de carregar, has canviat 3 kB per 280 ms de latència amb Slow 4G. Això és un mal negoci. Divideix trossos grossos i tardans, no tot el que es pugui dividir.

  1. El patró de càrrega: indicador, cancel·lació i fallada

Carregar sota demanda introdueix una cosa que abans no existia: una espera visible enmig d'una interacció. Cal tractar-la com qualsevol operació asíncrona (05-06, 07-03), i convé tenir un ajudant únic per no repetir el mateix cablejat.

// js/util/diferit.js

/**
 * Embolcalla un import() dinàmic amb memòria cau, indicador i reintent.
 *
 * @param {() => Promise<any>} carregador   Funció que fa l'import().
 * @param {object} opcions
 * @param {HTMLElement} [opcions.indicador]  Element al qual posar la classe 'carregant'.
 * @param {number} [opcions.reintents]       Reintents davant d'una fallada de xarxa.
 * @returns {() => Promise<any>}
 */
export function diferit(carregador, { indicador = null, reintents = 1 } = {}) {
  let promesa = null;

  return async function carregar() {
    if (promesa !== null) return promesa;          // ja carregat o en vol

    indicador?.classList.add('carregant');
    indicador?.setAttribute('aria-busy', 'true');  // accessibilitat: hi ha alguna cosa en marxa

    promesa = (async () => {
      for (let intent = 0; ; intent += 1) {
        try {
          return await carregador();
        } catch (error) {
          if (intent >= reintents) {
            promesa = null;                        // ← permetre tornar-ho a intentar després
            throw error;
          }
          // Retrocés simple, com l'ambReintents de 07-03
          await new Promise((r) => setTimeout(r, 400 * (intent + 1)));
        }
      }
    })();

    try {
      return await promesa;
    } finally {
      indicador?.classList.remove('carregant');
      indicador?.removeAttribute('aria-busy');
    }
  };
}
// Ús
const carregarInformes = diferit(
  () => import('../planificacio/client-planificador.js'),
  { indicador: $('#plafo-informe'), reintents: 2 }
);

Tres detalls que separen una càrrega diferida correcta d'una que dóna problemes en producció:

En fallar definitivament cal esborrar la promesa desada. Si la conserves, tots els intents posteriors rebran el mateix error per sempre, encara que la xarxa hagi tornat. És una fallada subtil i molt habitual.

Un desplegament nou invalida els hashes. Si un usuari té la pestanya oberta des de fa dues hores i desplegueu una versió nova, l'editor-7e1b93.js que el seu index intenta demanar pot ja no existir al servidor. Aquell import() fallarà amb un error de xarxa que sembla inexplicable. Hi ha dues mitigacions: conservar els fitxers antics al servidor durant uns dies (el més senzill i el que fa la majoria), i detectar la fallada per oferir de recarregar:

window.addEventListener('vite:preloadError', (esdeveniment) => {
  esdeveniment.preventDefault();
  mostrarAvis('Hi ha una versió nova de Nómada Tasques. Recarrega per continuar.', {
    accio: () => location.reload()
  });
});

L'indicador ha de ser accessible. aria-busy="true" avisa els lectors de pantalla que la regió s'està actualitzant; una classe CSS amb una animació no li diu res a ningú que no vegi la pantalla. I si l'espera pot superar el segon (09-01), cal a més un text explícit.

  1. Precarregar: preload, modulepreload, prefetch, preconnect, dns-prefetch

El navegador és força bo prioritzant, però només pot prioritzar el que coneix. Els suggeriments de recurs (resource hints) serveixen per explicar-li coses abans que les descobreixi pel seu compte.

Suggeriment Què fa Prioritat Quan fer-lo servir Risc d'abusar-ne
<link rel="preconnect"> Obre la connexió (DNS + TCP + TLS) sense descarregar res Un origen del qual segur que demanaràs alguna cosa de seguida Cada connexió ociosa consumeix recursos. Màxim 2–3
<link rel="dns-prefetch"> Només resol el DNS Orígens probables però no segurs Gairebé cap; és molt barat
<link rel="preload"> Descarrega ja, amb prioritat alta, sense executar Alta Un recurs crític que el navegador descobriria tard (fonts, imatge LCP) Competeix amb el que sí que és crític i pot empitjorar l'LCP
<link rel="modulepreload"> Descarrega i analitza un mòdul ES, sense executar-lo Alta Trossos que el mòdul d'entrada importarà L'anàlisi també costa CPU
<link rel="prefetch"> Descarrega amb prioritat mínima, per a la navegació següent La més baixa El que l'usuari probablement demanarà després Gasta dades que potser no es faran servir

La diferència entre preload i prefetch és la que més es confon i és fàcil de recordar: preload és «ho necessito per a aquesta pantalla, ara»; prefetch és «potser ho necessitaré després, quan no tinguis res millor a fer».

Aplicat a Nómada Tasques:

<head>
  <!-- 1 · L'API de tasques: connexió oberta abans que app.js faci el primer fetch -->
  <link rel="preconnect" href="https://api.tallernomada.example" crossorigin>
  <link rel="dns-prefetch" href="https://api.tallernomada.example">

  <!-- 2 · La font: el navegador no la descobreix fins a analitzar el CSS. Sense això,
          arriba tard i provoca el salt de text que veuràs a l'apartat 17.
          `crossorigin` és OBLIGATORI a les fonts, encara que siguin del mateix origen -->
  <link rel="preload" href="/assets/inter-subset-3d9a17.woff2"
        as="font" type="font/woff2" crossorigin>

  <!-- 3 · Generats per Vite: els trossos que index-*.js importarà -->
  <link rel="modulepreload" crossorigin href="/assets/model-2b7e40.js">
  <link rel="modulepreload" crossorigin href="/assets/vitals-9c1d5a.js">

  <link rel="stylesheet" crossorigin href="/assets/estils-4c9e21.css">
  <script type="module" crossorigin src="/assets/index-8f3a1c.js"></script>
</head>

I el prefetch, que a Nómada Tasques es fa millor des de JavaScript, quan el navegador està ociós (09-02):

// js/app.js — precarregar l'editor quan no hi hagi res millor a fer
requestIdleCallback(() => {
  // L'editor el fa servir l'11 % de les visites, però quan es fa servir, es fa servir aviat
  import('./vista/editor-descripcio.js');
}, { timeout: 5000 });

Aquest import() sense await i sense fer servir el resultat és idiomàtic: només serveix perquè el tros entri a la memòria cau HTTP. Quan l'usuari premi «Editar descripció», el mòdul ja hi serà i la càrrega serà instantània.

I les tres regles que eviten que els suggeriments facin mal:

  1. Precarrega poc. Si precarregues cinc coses, cap no és prioritària. preload funciona perquè desplaça recursos a la cua; precarregar-ho tot és no precarregar res.
  2. No precarreguis mai res que no facis servir en aquesta pantalla. El navegador avisa a la consola («The resource was preloaded but not used within a few seconds») i amb raó: has gastat amplada de banda de la ruta crítica.
  3. crossorigin a les fonts és obligatori. Les fonts es descarreguen sempre en mode anònim, així que un preload sense crossorigin provoca dues descàrregues de la mateixa font. És l'error més habitual amb preload i duplica el pes del recurs més car de la pàgina.

  1. Càrrega diferida d'imatges

Nómada Tasques té poques imatges —el logotip del taller i l'avatar de cada responsable— però les suficients per il·lustrar els quatre atributs que importen.

<!-- ✗ Abans: sense dimensions, sense prioritat, tot eager -->
<img src="/img/logo-taller.png" alt="Taller Nómada">
<img src="/img/avatar-ivan.png" alt="">

<!-- ✓ Després -->
<img src="/assets/logo-taller-a91c4e.webp"
     alt="Taller Nómada"
     width="180" height="48"
     fetchpriority="high"
     decoding="async">

<img src="/assets/avatar-ivan-6b2d05.webp"
     alt=""
     width="32" height="32"
     loading="lazy"
     decoding="async">

Què fa cada atribut:

Atribut Què fa Quan
width / height Reserven l'espai abans que la imatge arribi Sempre. És la meitat del CLS
loading="lazy" No es descarrega fins a acostar-se a l'àrea visible Imatges fora de la primera pantalla
loading="eager" Descàrrega immediata (valor per defecte) La imatge LCP
decoding="async" Descodificar fora del fil principal Gairebé sempre
fetchpriority="high" Puja la prioritat a la cua de descàrrega La imatge LCP, i només ella
fetchpriority="low" La baixa Imatges decoratives grans

Quatre avisos que eviten els errors típics:

loading="lazy" a la imatge LCP és un tret al peu. Retarda deliberadament el recurs que defineix la teva mètrica principal. És, de bon tros, el mal ús més freqüent d'aquesta tècnica, i Lighthouse el detecta i l'assenyala.

width i height funcionen encara que el CSS canviï la mida. Posar width="180" height="48" no fixa la mida si tens img { max-width: 100%; height: auto; }: els navegadors moderns fan servir aquests dos números només per calcular la relació d'aspecte i reservar el buit correcte. És exactament el que cal per al CLS i no interfereix amb el disseny adaptatiu.

El format importa més que la càrrega diferida. Convertir els PNG a WebP va abaixar les dues imatges de Nómada Tasques de 41 kB a 9,2 kB sense diferència visible. AVIF hauria baixat a 6,8 kB amb menys compatibilitat. Abans de discutir sobre lazy, comprova el format.

Per a imatges grans, fes servir srcset i sizes. Servir una imatge de 1.600 px d'ample a un mòbil de 360 px és llençar tres quartes parts dels bytes:

<img src="/assets/portada-800.webp"
     srcset="/assets/portada-400.webp 400w,
             /assets/portada-800.webp 800w,
             /assets/portada-1600.webp 1600w"
     sizes="(max-width: 640px) 100vw, 800px"
     width="1600" height="900"
     alt="Vista del taller" fetchpriority="high" decoding="async">

  1. Càrrega diferida de components amb IntersectionObserver

loading="lazy" només existeix per a <img> i <iframe>. Per a components —un mapa, un gràfic, un plafó pesat— cal l'IntersectionObserver de 07-06, combinat amb l'import() de l'apartat 12.

A Nómada Tasques hi ha un candidat clar: el plafó de càrrega per responsable, que dibuixa un gràfic de barres i viu al final de la barra lateral, gairebé sempre fora de la pantalla.

// js/vista/diferit-visible.js

/**
 * Munta un component la primera vegada que el seu contenidor s'acosta a l'àrea visible.
 *
 * @param {HTMLElement} contenidor
 * @param {() => Promise<{muntar: Function}>} carregador
 * @param {object} [opcions]
 * @returns {() => void} funció de neteja (09-03)
 */
export function enAcostarse(contenidor, carregador, { marge = '300px' } = {}) {
  const observador = new IntersectionObserver(async (entrades) => {
    if (!entrades[0].isIntersecting) return;

    observador.disconnect();                 // una sola vegada: res de recarregar en fer scroll

    contenidor.setAttribute('aria-busy', 'true');
    try {
      const { muntar } = await carregador();
      muntar(contenidor);
    } catch (error) {
      contenidor.textContent = "No s'ha pogut carregar el plafó de càrrega.";
      console.warn(error);
    } finally {
      contenidor.removeAttribute('aria-busy');
    }
  }, { rootMargin: marge });                 // començar ABANS que es vegi (07-06)

  observador.observe(contenidor);
  return () => observador.disconnect();      // per al destruir() de la vista
}
// js/app.js
import { enAcostarse } from './vista/diferit-visible.js';

const netejarPlafo = enAcostarse(
  $('#plafo-carrega'),
  () => import('./vista/plafo-carrega.js')
);

Dues condicions perquè això no empitjori l'experiència, i totes dues són de la lliçó anterior:

El buit ha d'estar reservat. Si #plafo-carrega fa 0 px fins que el component es munta, en muntar-se empenyerà tot cap avall i sumarà CLS. La solució és donar-li al CSS l'alçada que tindrà, o aspect-ratio:

#plafo-carrega { min-height: 240px; contain: layout paint; }

El rootMargin ha de ser generós. Amb 300px, la descàrrega comença quan el plafó és a tres-cents píxels de treure el nas, així que quan l'usuari hi arriba, ja està muntat. Sense marge, veuria el buit durant mig segon.

  1. Fonts: font-display, subconjunts, precàrrega i mètriques de reserva

Les fonts web són, de bon tros, el recurs més subestimat. A Nómada Tasques, una sola font pesa 94 kB: més que tot el JavaScript de la ruta crítica després d'optimitzar-lo. I a més és la principal font de CLS.

El problema té dues cares que cal separar:

  • FOIT (flash of invisible text): mentre la font es descarrega, el navegador amaga el text. La pàgina es veu buida encara que l'HTML estigui llest. Mata l'FCP i l'LCP.
  • FOUT (flash of unstyled text): es mostra el text amb una font de reserva i es canvia en arribar la web. No mata l'LCP, però si les dues fonts tenen mètriques diferents, el text es recol·loca i això és CLS.

17.1 font-display

@font-face {
  font-family: 'Inter';
  src: url('/assets/inter-subset-3d9a17.woff2') format('woff2');
  font-weight: 400 700;              /* font variable: un fitxer, tots els gruixos */
  font-style: normal;
  font-display: swap;                /* ← la decisió clau */
}
Valor de font-display Període de bloqueig Comportament
auto ~3 s El que decideixi el navegador. Sol ser FOIT llarg
block ~3 s Text invisible fins a 3 s. Evita'l
swap 0 ms Reserva immediata, canvi en arribar. FOUT, no FOIT
fallback ~100 ms Breu invisibilitat; si triga més de 3 s, es queda la reserva
optional ~100 ms Com fallback, però el navegador pot no fer servir mai la web. Zero CLS

swap és l'elecció per defecte sensata: garanteix que el text es veu des del primer moment. optional és l'elecció si el CLS t'importa més que la tipografia —el navegador decideix, i en connexions lentes simplement fa servir la reserva, amb salt zero—.

17.2 Subconjunts

Una font completa inclou milers de glifs: grec, ciríl·lic, vietnamita, símbols matemàtics. Nómada Tasques escriu en castellà, català i anglès: necessita llatí bàsic i estès, i poca cosa més.

# L'eina estàndard (Python), del projecte fonttools
pip install fonttools brotli

pyftsubset inter-variable.ttf \
  --output-file=inter-subset.woff2 \
  --flavor=woff2 \
  --layout-features='kern,liga' \
  --unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212'
Font Mida Glifs
inter-variable.ttf (original) 312 kB 2.548
inter-variable.woff2 94 kB 2.548
inter-subset.woff2 21,4 kB 382

De 94 kB a 21,4 kB. És el major estalvi individual de tota la lliçó, i no toca ni una línia de codi. Tres regles: fes servir sempre WOFF2 (els formats .ttf, .eot i .woff sobren des de fa anys), fes servir fonts variables quan necessitis diversos gruixos, i comprova que el subconjunt inclou el que escrius: els noms catalans amb ŀ, les cometes tipogràfiques i el símbol · dels separadors de Nómada Tasques.

17.3 Precàrrega

El navegador no descobreix la font fins que ha descarregat el CSS i ha calculat quins elements la fan servir. Això són dos viatges d'anada i tornada de retard. Per això la font crítica es precarrega, amb el crossorigin de l'apartat 14:

<link rel="preload" href="/assets/inter-subset-3d9a17.woff2"
      as="font" type="font/woff2" crossorigin>

Precarrega una sola font: la que es fa servir per al text principal. Precarregar quatre variants és un cas de manual de suggeriment contraproduent.

17.4 Mètriques de reserva: el CLS que queda

Encara amb swap i precàrrega, queda el salt del canvi de font: la de reserva i la web tenen amples i alçades de línia diferents, així que en intercanviar-les el text es recol·loca. La solució moderna és declarar una font de reserva ajustada amb les mètriques de la real:

/* Reserva ajustada: la font del sistema, deformada per MESURAR igual que Inter */
@font-face {
  font-family: 'Inter reserva';
  src: local('Arial'), local('Helvetica Neue'), local('sans-serif');
  size-adjust: 107%;          /* Arial és més estreta: s'eixampla un 7 % */
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter reserva', system-ui, sans-serif;
}

Amb aquestes quatre propietats, el text de reserva ocupa exactament les mateixes caixes que el definitiu, i el canvi deixa de moure res: el FOUT continua existint visualment (canvia la forma de les lletres) però el CLS cau a zero. Els valors de size-adjust i companyia es calculen comparant les mètriques de les dues fonts; hi ha eines que els generen, i no és raonable ajustar-los a ull.

  1. Tancar la fila 3: d'on sortia el CLS de 0,21

El CLS mesura quant es mou el contingut sense que l'usuari ho provoqui. A 09-01 vam anotar 0,21 i a 09-04 vam aprendre a veure'l amb Layout Shift Regions del panell Rendering. Gravant amb el panell Performance i mirant les entrades layout-shift, el 0,21 es descompon així:

Origen del salt Aportació Quan passa
Les 600 targetes s'insereixen en arribar les dades i empenyen el peu 0,13 ~3,8 s
El text es recol·loca en arribar la font web 0,05 ~2,9 s
El logotip sense width/height reserva 0 px i després 48 px 0,03 ~1,3 s
Total 0,21

I les tres correccions, cadascuna d'un apartat diferent d'aquesta lliçó:

El salt de les targetes (0,13 → 0,00). No s'arregla carregant abans, sinó reservant el lloc: un esquelet amb l'alçada exacta que tindran les columnes. Com que la llista està virtualitzada (09-04) i cada targeta fa 116 px, l'alçada és coneguda per endavant.

.columna { min-height: 640px; }               /* el buit existeix des del primer pintat */
.llista-tasques { min-height: 640px; }
.tasca--esquelet {
  height: 108px;
  margin-bottom: 8px;
  background: linear-gradient(90deg, var(--gris-clar) 25%, var(--gris-molt-clar) 50%,
                                     var(--gris-clar) 75%);
  background-size: 200% 100%;
  animation: brillantor 1.4s infinite;
}

@keyframes brillantor { to { background-position: -200% 0; } }   /* només background-position */

@media (prefers-reduced-motion: reduce) {
  .tasca--esquelet { animation: none; }
}

Una nota que enllaça amb 09-01: l'esquelet no compta com a contingut per a l'LCP. Serveix per al CLS i per a la percepció, no per a la mètrica. No el posis pensant que millora l'LCP, perquè no ho fa.

El salt de la font (0,05 → 0,00). La reserva ajustada amb size-adjust i ascent-override de l'apartat 17.4.

El salt del logotip (0,03 → 0,00). width="180" height="48" a l'<img>, de l'apartat 15.

Mesura Abans Després
CLS (Lighthouse mòbil) 0,21 0,02

El 0,02 restant és un salt minúscul de la barra de resum, que passa d'una línia a dues quan el nombre de tasques supera tres xifres. Està per sota del llindar de 0,1 i arreglar-lo exigiria fixar l'alçada d'un element el contingut del qual és genuïnament variable. És una decisió conscient de no perseguir el zero, en la línia de 09-01: els últims punts costen molt i no aporten res perceptible.

  1. Compressió: Gzip i Brotli

Minificar redueix el text; comprimir redueix els bytes que viatgen. Se sumen, i la compressió és responsabilitat del servidor, no de l'empaquetador.

Algorisme Compatibilitat Ràtio en JS Cost de comprimir
Cap 0
Gzip Universal des de fa 20 anys ~3,5× Baix
Brotli (br) Tots els navegadors moderns ~4,2× Mitjà (alt si és al vol)
Zstandard (zstd) Emergent ~4,3× Baix

El navegador anuncia el que accepta i el servidor tria:

Accept-Encoding: gzip, deflate, br, zstd      ← el que envia el navegador
Content-Encoding: br                          ← el que respon el servidor

Sobre Nómada Tasques ja compilada:

Fitxer Sense comprimir Gzip Brotli
index-8f3a1c.js 44,08 kB 15,71 kB 14,93 kB
model-2b7e40.js 11,64 kB 4,02 kB 3,44 kB
vitals-9c1d5a.js 2,61 kB 1,18 kB 1,02 kB
estils-4c9e21.css 17,90 kB 3,91 kB 3,22 kB
Total ruta crítica 76,23 kB 24,82 kB 22,61 kB

Tres coses que cal saber:

Comprimeix a la compilació, no al vol. Brotli amb nivell màxim (11) és lent de comprimir i ràpid de descomprimir. Comprimir cada resposta al vol obliga a fer servir un nivell baix; generar els .br durant la compilació permet el nivell 11 de franc. Gairebé qualsevol servidor sap servir un fitxer.js.br precomprimit si existeix.

No comprimeixis el que ja està comprimit. WebP, AVIF, WOFF2, PNG, JPEG i MP4 ja porten compressió pròpia. Tornar-los a comprimir gasta CPU i de vegades augmenta la mida.

Comprova que la compressió està activa. És la fallada de desplegament més habitual i més invisible: al panell Network, si les columnes de mida transferida i de recursos coincideixen, no hi ha compressió. A la línia base de Nómada Tasques, els 214 kB de JavaScript apareixien idèntics a les dues columnes. Aquella sola observació valia 150 kB.

  1. Memòria cau HTTP, hashing i el service worker de 07-05

La visita més ràpida és la que no descarrega res. La memòria cau HTTP ho aconsegueix, i el hashing de l'apartat 8 és el que la fa segura.

Les dues capçaleres que governen l'assumpte:

Capçalera Què fa
Cache-Control: max-age=N El navegador pot fer servir la còpia local N segons sense preguntar
Cache-Control: no-cache La pot desar, però ha de revalidar abans de fer-la servir
Cache-Control: immutable Promet que el contingut mai no canviarà: ni tan sols revalidar en recarregar
ETag: "abc123" Empremta del contingut; el navegador la reenvia a If-None-Match
Last-Modified Data; el navegador la reenvia a If-Modified-Since

Amb ETag, quan expira el max-age el navegador pregunta i el servidor pot respondre 304 Not Modified sense cos: s'estalvia el pes, però no el viatge d'anada i tornada. Amb immutable, no hi ha ni viatge.

I aquí hi ha la raó profunda del hashing. index-8f3a1c.js conté un resum del contingut al nom. Si el contingut canvia, el nom canvia. Això permet una estratègia sense compromisos:

# Actius amb hash: desar-los a la memòria cau un any, sense revalidar mai
location /assets/ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}

# L'HTML: MAI a la memòria cau. És qui apunta als noms nous
location = /index.html {
  add_header Cache-Control "no-cache";
}

# El service worker: mai a la memòria cau (avís literal de 07-05)
location = /sw.js {
  add_header Cache-Control "no-cache";
}
flowchart TD
    A["Despleges una versió nova"] --> B["index.html canvia<br/>(no-cache: es demana sempre)"]
    B --> C{"Quins actius<br/>referencia?"}
    C -->|"index-8f3a1c.js<br/>(sense canvis)"| D["Ja és a la memòria cau<br/><b>0 bytes</b>"]
    C -->|"index-b7d20f.js<br/>(nom nou)"| E["Es descarrega<br/>només el que ha canviat"]
    D --> F["Càrrega gairebé instantània"]
    E --> F

Això és el que fa valuós el manualChunks de l'apartat 8: si només toques la vista, model-2b7e40.js conserva el seu hash i els usuaris no el tornen a descarregar.

20.1 El service worker de 07-05, ara que hi ha compilació

Aquí hi ha un conflicte real que cal resoldre, i és el tipus de cosa que trenca desplegaments. El sw.js de 07-05 precachejava una llista escrita a mà:

// ✗ sw.js — aquesta llista ja no existeix després de compilar
const RECURSOS_SHELL = [
  '/', '/index.html', '/css/estils.css',
  '/js/app.js', '/js/model/tasca.js', '/js/model/tauler.js',
  // …25 rutes més que ja no es serveixen…
];

Després de npm run build cap d'aquestes rutes no existeix: ara són /assets/index-8f3a1c.js i companyia. El cache.addAll fallaria sencer, perquè addAll és atòmic: si una petició falla, no se'n desa cap. El service worker no s'instal·laria i l'aplicació perdria el mode sense connexió sense que ningú se n'adonés fins que un usuari agafés el metro.

La solució és generar la llista a la compilació. Amb un petit complement propi, que a més il·lustra com s'enganxen les eines:

// scripts/plugin-precache.mjs
import { writeFileSync } from 'node:fs';

/** Escriu dist/precache.json amb els actius generats i els seus hashes. */
export function generarPrecache() {
  return {
    name: 'generar-precache',
    generateBundle(opcions, paquet) {
      const rutes = ['/', '/index.html', '/offline.html', '/manifest.json'];

      for (const [nom, fitxer] of Object.entries(paquet)) {
        // Només el que cal per arrencar: res de trossos diferits ni mapes
        if (nom.endsWith('.map')) continue;
        if (fitxer.isDynamicEntry) continue;
        rutes.push(`/${nom}`);
      }

      this.emitFile({
        type: 'asset',
        fileName: 'precache.json',
        source: JSON.stringify({ versio: Date.now(), rutes }, null, 2)
      });
    }
  };
}
// sw.js — llegeix la llista generada en lloc de tenir-la escrita
self.addEventListener('install', (esdeveniment) => {
  esdeveniment.waitUntil((async () => {
    const resposta = await fetch('/precache.json', { cache: 'no-store' });
    const { versio, rutes } = await resposta.json();

    const cache = await caches.open(`nomada-shell-${versio}`);
    await cache.addAll(rutes);
  })());
});

self.addEventListener('activate', (esdeveniment) => {
  esdeveniment.waitUntil((async () => {
    const resposta = await fetch('/precache.json', { cache: 'no-store' });
    const { versio } = await resposta.json();

    // Esborrar les memòries cau de versions anteriors (07-05)
    const noms = await caches.keys();
    await Promise.all(noms
      .filter((n) => n.startsWith('nomada-') && !n.endsWith(String(versio)))
      .map((n) => caches.delete(n)));

    await self.clients.claim();
  })());
});

Tres avisos de desplegament que valen el seu pes en or:

  • sw.js amb Cache-Control: no-cache, com ja va dir 07-05. Si un CDN serveix un sw.js vell durant hores, els teus usuaris es queden congelats i no hi ha res que puguis fer des del client.
  • No precachegis els trossos diferits. L'informes-*.js que només demana el 4 % de les visites no s'ha de descarregar a la instal·lació del service worker: seria exactament el problema que acabem de resoldre, canviat de lloc. Es desa a la memòria cau en demanar-lo, amb staleWhileRevalidate (07-05).
  • Conserva els fitxers del desplegament anterior uns dies. És la mitigació de l'apartat 13 per als import() que fallen en pestanyes obertes durant un desplegament.

  1. El pressupost de rendiment en integració contínua

A 09-01 va quedar dit que els pressupostos de quantitat són molt més estables que els de temps, perquè no depenen del soroll de l'executor, i que es veurien aquí. Els muntarem.

Nivell 1: el pressupost de bytes, comprovat amb un script propi sobre la sortida de la compilació.

// scripts/pressupost.mjs
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { brotliCompressSync } from 'node:zlib';

// El pressupost és la fila 10 de la línia base de 09-01
const PRESSUPOST = {
  jsInicialKB: 80,        // ≤ 80 kB de JS sense comprimir a la ruta crítica
  peticionsJs: 5,         // ≤ 5 peticions
  cssKB: 25,
  brotliTotalKB: 30
};

const html = readFileSync('dist/index.html', 'utf8');

// Els fitxers que l'HTML demana directament = la ruta crítica
const critics = [...html.matchAll(/(?:src|href)="\/(assets\/[^"]+)"/g)].map((m) => m[1]);
const js = critics.filter((f) => f.endsWith('.js'));
const css = critics.filter((f) => f.endsWith('.css'));

const bytes = (f) => statSync(join('dist', f)).size;
const brotli = (f) => brotliCompressSync(readFileSync(join('dist', f))).length;

const jsKB     = js.reduce((s, f) => s + bytes(f), 0) / 1024;
const cssKB    = css.reduce((s, f) => s + bytes(f), 0) / 1024;
const brotliKB = critics.reduce((s, f) => s + brotli(f), 0) / 1024;

console.table(critics.map((f) => ({
  Fitxer: f,
  kB: (bytes(f) / 1024).toFixed(2),
  'kB (br)': (brotli(f) / 1024).toFixed(2)
})));

const fallades = [];
if (jsKB > PRESSUPOST.jsInicialKB)       fallades.push(`JS inicial ${jsKB.toFixed(1)} kB > ${PRESSUPOST.jsInicialKB}`);
if (js.length > PRESSUPOST.peticionsJs)  fallades.push(`${js.length} peticions JS > ${PRESSUPOST.peticionsJs}`);
if (cssKB > PRESSUPOST.cssKB)            fallades.push(`CSS ${cssKB.toFixed(1)} kB > ${PRESSUPOST.cssKB}`);
if (brotliKB > PRESSUPOST.brotliTotalKB) fallades.push(`Brotli total ${brotliKB.toFixed(1)} kB > ${PRESSUPOST.brotliTotalKB}`);

if (fallades.length > 0) {
  console.error('\n✗ Pressupost de rendiment incomplert:');
  for (const f of fallades) console.error(`  · ${f}`);
  process.exit(1);                         // ← trenca la compilació, com una prova de Jest
}
console.log(`\n✓ Pressupost complert: ${jsKB.toFixed(1)} kB de JS en ${js.length} peticions`);

Sortida real sobre la compilació de l'apartat 9:

┌─────────┬──────────────────────────────┬───────┬─────────┐
│ (index) │ Fitxer                       │ kB    │ kB (br) │
├─────────┼──────────────────────────────┼───────┼─────────┤
│ 0       │ 'assets/estils-4c9e21.css'   │ 17.90 │ 3.22    │
│ 1       │ 'assets/index-8f3a1c.js'     │ 44.08 │ 14.93   │
│ 2       │ 'assets/model-2b7e40.js'     │ 11.64 │ 3.44    │
│ 3       │ 'assets/vitals-9c1d5a.js'    │ 2.61  │ 1.02    │
└─────────┴──────────────────────────────┴───────┴─────────┘

✓ Pressupost complert: 58.3 kB de JS en 3 peticions

Nivell 2: Lighthouse CI, que ja vas configurar a 09-01, amb les asseveracions ara ajustades als objectius de la taula:

{
  "ci": {
    "collect": {
      "url": ["http://localhost:4173/"],
      "numberOfRuns": 3,
      "startServerCommand": "npm run preview"
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift":  ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time":      ["error", { "maxNumericValue": 200 }],
        "first-contentful-paint":   ["warn",  { "maxNumericValue": 1800 }],
        "unused-javascript":        ["warn",  { "maxNumericValue": 20000 }],
        "uses-text-compression":    "error",
        "unsized-images":           "error",
        "font-display":             "error",
        "uses-responsive-images":   "off"
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}

Fixa't en les quatre assercions noves i en per què són de regla i no de temps: uses-text-compression detecta que algú ha desactivat Brotli al servidor, unsized-images detecta un <img> sense dimensions —el CLS de l'apartat 18— i font-display detecta un @font-face sense swap. Són comprovacions binàries i estables, exactament les que convé posar com a error.

I el flux de treball complet, seguint l'ordre barat-primer de 08-06:

# .github/workflows/ci.yml (fragment)
  rendiment:
    needs: [proves]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm run build
      - run: npm run pressupost     # barat, estable, trenca la compilació
      - run: npx lhci autorun       # car, una mica sorollós, llindars amb marge
      - uses: actions/upload-artifact@v4
        with:
          name: informe-paquet
          path: dist/informe-paquet.html

L'últim pas guarda el mapa d'arbre de rollup-plugin-visualizer com a artefacte de l'execució. Quan d'aquí a tres mesos el pressupost falli perquè algú ha afegit una llibreria de gràfics, aquell informe dirà en trenta segons quina és.

  1. La taula completa: les deu files, abans i després

Aquest és el tancament del contracte que 09-01 va establir. Mateix procediment —mediana de 15 repeticions després de 5 d'escalfament, CPU 4×, Slow 4G, incògnit, mateix portàtil de referència, mateix tauler de 600 tasques amb llavor fixa—.

# Mesura Abans Objectiu Després On es va resoldre
1 LCP (mòbil simulat) 4,1 s ≤ 2,5 s 1,9 s 09-05 (8–11, 14, 17)
2 INP en escriure al cercador 480 ms ≤ 200 ms 42 ms 09-02 (memòria cau + debounce), 09-04 (render)
3 CLS 0,21 ≤ 0,1 0,02 09-05 (15, 17, 18)
4 Tasca llarga màxima a l'arrencada 1.180 ms ≤ 200 ms 41 ms 09-02 (perLots)
5 render() complet, 600 tasques 310 ms ≤ 50 ms 31 ms 09-04 (read-then-write, empremta, virtualització)
6 Recàlculs de resum() per filtratge 96 ms ≤ 5 ms 1,5 ms 09-02 (memòria cau per versió)
7 Informe de planificació 940 ms 0 ms al fil principal 52 ms ⚠️ 09-02 (Web Worker)
8 Memòria retinguda després de 200 filtratges +37,7 MB ≈ 0 +0,4 MB 09-03 (AbortController, purga de l'índex)
9 Nodes DOM del document 7.812 ≤ 1.500 1.194 09-04 (LlistaVirtual)
10 JS descarregat abans de la 1a targeta 214 kB / 28 pet. ≤ 80 kB / ≤ 5 58,3 kB / 3 pet. 09-05 (8–12)

I el desglossament de la fila 1, perquè és la que resumeix la feina d'aquesta lliçó:

Pas JS inicial Peticions LCP CLS
Línia base 214 kB 28 4,10 s 0,21
Empaquetat amb Vite 209 kB 2 3,20 s 0,21
Minificació 118 kB 2 2,80 s 0,21
Tree shaking 104 kB 2 2,70 s 0,21
Divisió de codi 58,3 kB 3 2,40 s 0,21
Compressió Brotli al servidor 58,3 kB (19,4 kB transferits) 3 2,15 s 0,21
preconnect, preload de la font, modulepreload 58,3 kB 3 2,00 s 0,21
Subconjunt de la font (94 → 21,4 kB) 58,3 kB 3 1,90 s 0,21
Imatges amb dimensions, esquelet, reserva ajustada 58,3 kB 3 1,90 s 0,02

I la barra de resum del panell Network, comparada amb la de 09-01:

Abans:    31 requests · 238 kB transferred · 512 kB resources · DCL 1,9 s · Load 4,3 s
Després:   9 requests ·  57 kB transferred · 138 kB resources · DCL 0,9 s · Load 2,0 s

  1. El que no s'ha resolt i el que ha costat

Nou vistiplaus verds i un avís ambre. Val la pena mirar l'avís, i sobretot la factura.

La fila 7 no va arribar a zero. L'objectiu era «0 ms al fil principal» i el resultat és 52 ms: 3,3 ms de serialitzar l'anada, uns 45 de la tornada amb l'informe complet i uns pocs de rebre'l. Treure el càlcul del fil principal no elimina el cost de creuar la frontera, com 09-02 va explicar amb la clonació estructurada. Es podria abaixar més fent servir un ArrayBuffer transferible per a les dades numèriques, però això obligaria a serialitzar a mà una estructura que avui és un objecte llegible. Cinquanta-dos mil·lisegons són un fotograma perdut, no una interfície congelada. És una decisió conscient, no un descuit, i així és com cal documentar-la.

El 0,02 de CLS que queda és la barra de resum en passar de tres xifres, i ja està explicat a l'apartat 18: perseguir el zero exigiria fixar l'alçada d'alguna cosa genuïnament variable.

Res d'això no està mesurat en camp. Tots aquests números són de laboratori, en un portàtil concret amb estrangulament simulat. El web-vitals que vas instal·lar a 09-01 continua sent l'única manera de saber què li passa de debò a la Marta al seu mòbil. La distinció laboratori/camp de 09-01 no desapareix perquè la taula estigui verda.

I ara la factura, que és la part que gairebé mai no s'escriu:

El que s'ha guanyat El que ha costat
LCP 2,2 s més ràpid Un pas de compilació: l'aplicació ja no s'obre amb doble clic a index.html
156 kB menys a la ruta crítica Dues dependències de desenvolupament i un vite.config.js que cal entendre
Render 10× més ràpid ~90 línies de LlistaVirtual, amb aria-setsize, Ctrl+F trencat i un mode de fallada nou
Sense fuites de memòria Un destruir() a cada classe i un AbortController que cal recordar de fer servir
Informe sense congelar la interfície Un worker, un protocol de missatges amb id i serialització a dades planes
Memòria cau eterna segura Noms amb hash, i un sw.js que ja no pot tenir la llista escrita a mà
Pressupost vigilat Dues feines més de CI que algú haurà de mantenir

Tot això és complexitat real, i no tota estava justificada per endavant. Si el Taller Nómada tingués sis tasques en lloc de sis-centes, gairebé res d'aquest mòdul no hauria estat necessari: el render costava 1,4 ms, no hi havia fuites perceptibles i 214 kB en una xarxa decent són mig segon. La disciplina de 09-01 no consistia a optimitzar-ho tot, sinó a mesurar primer per saber què valia la pena, i aquesta disciplina inclou la decisió contrària: no fer res quan el mesurament diu que no cal.

Si t'haguessis de quedar només amb tres coses de les quinze que has fet, les tres amb millor relació entre benefici i complexitat serien: arreglar el layout thrashing (310 → 179 ms, mitja hora de feina, zero complexitat afegida), empaquetar i minificar (4,1 → 2,8 s d'LCP, un fitxer de configuració) i el subconjunt de la font (72,6 kB menys, una ordre de terminal). Totes tres són canvis d'infraestructura o d'ordre, no d'arquitectura. La virtualització i el worker, que són les que més impressionen, són també les que més deute deixen.

Errors Habituals i Consells

  • Optimitzar l'execució quan el problema és la càrrega. Durant els 4,1 s de la línia base, el teu codi encara no s'havia executat. Mesura on és el temps abans de decidir què tocar.
  • Posar un <script> clàssic sense defer al <head>. Atura l'anàlisi de l'HTML. Amb type="module" el problema no existeix.
  • Fer servir async pensant que és «defer però millor». async no garanteix l'ordre i executa tan bon punt arriba, possiblement en el pitjor moment.
  • Llegir Coverage acabat de carregar i anomenar «codi mort» el que només és «codi encara no executat». Són dues preguntes diferents i exigeixen dues maneres d'aturar la gravació.
  • Creure que empaquetar és «ficar-ho tot en un fitxer». Avui l'objectiu són pocs fitxers ben triats, separats per freqüència de canvi i per moment d'ús.
  • Transpilar a ES5 «per si de cas». Engreixa el paquet un 20–30 % i afegeix polyfills que cap navegador amb mòduls ES no necessita.
  • Desactivar els mapes d'origen en producció. No es descarreguen tret que s'obri DevTools, i sense ells un error real és il·legible.
  • Suposar que el tree shaking funciona. Falla amb import * fets servir de manera opaca, amb mòduls que tenen efectes secundaris i amb dependències que només publiquen CommonJS. Comprova-ho amb el visualitzador.
  • Dividir de més. Cada import() és un viatge d'anada i tornada. Dividir un mòdul de 3 kB canvia 3 kB per 280 ms amb Slow 4G.
  • Fer servir import(variable) a seques. L'empaquetador no sap què incloure. Deixa la part fixa visible: import(\./textos/${codi}.js`)`.
  • No gestionar la fallada d'un import() dinàmic. Un desplegament nou pot esborrar el tros que una pestanya oberta demanarà. Detecta l'error i ofereix de recarregar.
  • Desar la promesa d'un import() fallit. Tots els intents posteriors fallaran per sempre. Esborra-la en esgotar els reintents.
  • Precarregar massa coses. Si tot és prioritari, res no ho és. preload funciona perquè desplaça altres recursos a la cua.
  • preload d'una font sense crossorigin. Provoca dues descàrregues del recurs més car de la pàgina. És l'error clàssic.
  • loading="lazy" a la imatge LCP. Retarda deliberadament la mètrica principal. Lighthouse ho detecta i t'ho retreu.
  • Imatges sense width i height. És la causa número u de CLS, i els dos números no interfereixen amb el disseny adaptatiu.
  • Servir una font completa de 94 kB per escriure en català. Fes-ne un subconjunt: 21,4 kB amb els mateixos glifs que fas servir.
  • font-display: block o auto. Text invisible fins a tres segons. swap com a mínim, optional si el CLS mana.
  • Creure que un esquelet millora l'LCP. Millora el CLS i la percepció; l'LCP mesura contingut, i un esquelet no ho és.
  • No comprovar que la compressió està activa. Si a Network coincideixen «transferit» i «recursos», no hi ha Gzip ni Brotli. Val 150 kB comprovar-ho.
  • Tornar a comprimir WebP, WOFF2 o MP4. Ja estan comprimits: gastes CPU i de vegades engreixen.
  • Desar a la memòria cau l'HTML o el sw.js. L'HTML apunta als noms amb hash; si es desa, els usuaris es queden a la versió antiga per sempre.
  • Deixar la llista de precaché del service worker escrita a mà després d'afegir compilació. addAll és atòmic: una ruta inexistent i no s'instal·la res.
  • Precachejar els trossos diferits. Tornaries a descarregar a l'arrencada justament el que acabaves de treure de la ruta crítica.
  • Consell: mesura sempre amb Disable cache i Slow 4G. Sense això estàs mesurant la teva segona visita amb fibra.
  • Consell: separa els trossos per freqüència de canvi, no només per mida. És el que fa que la memòria cau funcioni amb gra fi.
  • Consell: guarda l'informe del visualitzador com a artefacte de CI. Quan el pressupost falli d'aquí a tres mesos, tindràs la resposta en trenta segons.
  • Consell: posa com a error les assercions binàries i com a warn les de temps. «Falta compressió» és determinista; «LCP 2,6 s» pot ser l'executor tenint un mal dia.
  • Consell: escriu al codi el número que va justificar cada optimització, amb la seva data. D'aquí a dos anys, qui llegeixi LlistaVirtual mereix saber que es va posar per baixar de 7.812 nodes a 1.194.

Exercicis

Exercici 1 — Diagnòstic des del panell Network. Una companya desplega Nómada Tasques al seu propi servidor i t'ensenya aquesta barra de resum i aquest extracte, mesurats amb Slow 4G i incògnit:

14 requests · 291 kB transferred · 294 kB resources · DOMContentLoaded 2,8 s · Load 5,1 s
Recurs Size (transferit / recurs) Priority Temps
index.html 9,1 kB / 9,1 kB Highest 620 ms
assets/index-8f3a1c.js 44,1 kB / 44,1 kB High 1.240 ms
assets/estils-4c9e21.css 17,9 kB / 17,9 kB Highest 980 ms
assets/informes-5a3f18.js 18,2 kB / 18,2 kB High 1.310 ms
assets/editor-7e1b93.js 24,4 kB / 24,4 kB High 1.380 ms
fonts/inter-variable.woff2 94 kB / 94 kB Highest 2.100 ms
img/portada.png 78 kB / 78 kB Low 2.900 ms

Identifica quatre problemes diferents, digues en què et bases exactament per afirmar cadascun, i proposa la correcció concreta de cadascun. Després estima quin dels quatre tindrà més impacte en l'LCP i per què.

Exercici 2 — Decidir què dividir. El Taller Nómada vol quatre funcionalitats noves. Per a cadascuna, decideix entre «importació estàtica», «import() en activar-se» i «import() amb precàrrega a requestIdleCallback», justificant-ho amb les tres condicions de l'apartat 12 i amb el cost d'un viatge d'anada i tornada amb Slow 4G (~280 ms).

  1. Un validador de formularis de 2,1 kB que es fa servir en donar d'alta qualsevol tasca.
  2. Un exportador a PDF de 186 kB que fa servir el 3 % de les visites, sempre al final de la sessió.
  3. Un selector d'emojis de 34 kB per als comentaris; el fa servir el 40 % de les visites, típicament als primers trenta segons.
  4. Un mòdul d'accessibilitat de 6 kB que instal·la dreceres de teclat i s'activa acabat de carregar.

Exercici 3 — Tancar un CLS de 0,34. El nou plafó d'estadístiques de Nómada Tasques té un CLS de 0,34, repartit així segons el panell Performance: 0,18 en inserir-se un bàner d'avís de galetes a la part superior, 0,09 en arribar un gràfic carregat amb import() dinàmic, 0,05 en canviar la font, i 0,02 per un <img> de logotip. Per a cadascun: (a) explica per què es produeix el salt, (b) escriu la correcció concreta amb el seu codi, i (c) digues si el CLS resultant compliria l'objectiu de ≤ 0,1. Després respon: per què un salt provocat per l'usuari (prémer «Veure detalls» i que es desplegui un plafó) no compta per al CLS?

Solucions

Solució 1

Problema 1: no hi ha compressió. Es veu directament a la columna Size: transferit i recurs coincideixen a tots els fitxers de text (44,1 / 44,1, 17,9 / 17,9, 9,1 / 9,1), i el resum diu 291 kB transferred · 294 kB resources. Amb Brotli, aquells 71,1 kB de JS i CSS viatjarien com uns 21,6 kB.

gzip on;
brotli on;
brotli_types text/html text/css application/javascript application/json image/svg+xml;
brotli_static on;                 # servir els .br generats a la compilació

Problema 2: es descarreguen els trossos diferits. informes-5a3f18.js i editor-7e1b93.js apareixen a la càrrega inicial amb prioritat High. Això significa que l'HTML els referencia, gairebé amb certesa amb <link rel="modulepreload"> posats a mà «perquè vagin més ràpid». Estan anul·lant exactament el benefici de la divisió de codi: 42,6 kB de la ruta crítica que el 89 % de les visites no fa servir. La correcció és treure aquests dos modulepreload de l'HTML i, si es vol anticipar la descàrrega de l'editor, fer-ho des de requestIdleCallback com a l'apartat 14.

Problema 3: la font no té subconjunt i arriba tardíssim. 94 kB i 2.100 ms, amb prioritat Highest, és la font completa sense subconjunt (apartat 17.2). A més, si triga 2,1 s és que s'ha descobert tard, en analitzar el CSS. Doble correcció: pyftsubset per baixar a ~21 kB i <link rel="preload" as="font" crossorigin> al <head>.

Problema 4: la imatge de portada és l'element LCP i té prioritat Low. És la més pesada (78 kB), és un PNG i és l'última a arribar, als 2.900 ms. Low indica que porta loading="lazy" o que el navegador l'ha despriorititzada per no saber que és important.

<img src="/assets/portada-800.webp"
     srcset="/assets/portada-400.webp 400w, /assets/portada-800.webp 800w"
     sizes="(max-width: 640px) 100vw, 800px"
     width="800" height="450" alt="Vista del taller"
     fetchpriority="high" decoding="async">

Quin pesa més en l'LCP. El problema 4, sense discussió. L'element LCP és la portada, i arriba als 2.900 ms; cap altra correcció no pot abaixar l'LCP per sota d'aquell moment. Convertir-la a WebP (78 → 22 kB), servir-la dimensionada al viewport i pujar-li la prioritat l'avança a uns 1.100 ms. Els problemes 1 i 2 són els següents en importància, perquè alliberen amplada de banda de la ruta crítica que la portada havia de compartir amb 42,6 kB de JavaScript inútil. És una bona il·lustració que les optimitzacions de xarxa interactuen: treure pes a un recurs accelera els altres.

Solució 2

# Funcionalitat Decisió Justificació
1 Validador, 2,1 kB, sempre Importació estàtica Falla la condició (1) —no pesa— i falla la (2) —es fa servir de seguida—. Dividir-lo canviaria 2,1 kB (≈0,6 kB amb Brotli) per 280 ms d'espera enmig d'un formulari, que és el pitjor moment possible
2 Exportador a PDF, 186 kB, 3 % import() en activar-se Compleix les tres condicions de manera exemplar: pesa molt, no es fa servir a la primera pantalla i s'activa amb un botó. A més, qui exporta ja espera un moment per la generació, així que els 280 ms es camuflen. I com que es fa servir al final de la sessió, precarregar-lo abans seria gastar 186 kB en el 97 % de les visites que no el faran servir mai
3 Selector d'emojis, 34 kB, 40 % import() amb precàrrega a requestIdleCallback Compleix les tres condicions per dividir-lo, però la probabilitat d'ús és alta i l'ús és primerenc: si esperes al clic, quatre de cada deu usuaris veuran una pausa. Precarregar-lo quan el navegador està ociós dóna el millor dels dos mons: fora de la ruta crítica, però ja a la memòria cau quan calgui
4 Accessibilitat, 6 kB, sempre en carregar Importació estàtica Falla la condició (3): no s'activa per una acció, es necessita des del primer moment. Dividir-lo deixaria l'aplicació sense dreceres de teclat durant els primers 280 ms, i seria una regressió d'accessibilitat a canvi de 6 kB

La lectura transversal: la decisió no depèn només de la mida. El cas 2 i el cas 3 pesen de manera molt diferent i tots dos es divideixen; el cas 1 i el cas 4 també pesen de manera diferent i cap dels dos no es divideix. El que mana és quan es necessita i amb quina probabilitat.

Solució 3

(a) i (b), salt per salt:

El bàner de galetes (0,18). S'insereix al flux normal, a la part superior, després que la pàgina ja estigui pintada, i empeny cap avall tota la resta. És el cas més perjudicial possible: molt desplaçament, i afecta tota la pantalla. Dues correccions, i la segona és la bona:

/* ✓ Millor: treure'l del flux. Si està fix, no pot empènyer ningú */
.baner-galetes {
  position: fixed;
  bottom: 0; left: 0; right: 0;
  z-index: 100;
}
/* ✓ Alternativa si ha d'anar a dalt al flux: reservar-ne l'alçada des del principi */
.buit-baner { min-height: 64px; }     /* el buit existeix abans que arribi el bàner */

El gràfic carregat amb import() (0,09). El seu contenidor fa 0 px fins que el component es munta, i en muntar-se creix de cop. És exactament l'avís de l'apartat 16:

#plafo-grafic {
  min-height: 280px;               /* o aspect-ratio: 16 / 9 */
  contain: layout paint;           /* a més, aïlla el recàlcul (09-04) */
}

El canvi de font (0,05). En arribar la font web, les seves mètriques difereixen de les de la reserva i el text es recol·loca. La correcció és la de l'apartat 17.4: un @font-face de reserva amb size-adjust, ascent-override, descent-override i line-gap-override, més font-display: swap i preload amb crossorigin.

@font-face {
  font-family: 'Inter reserva';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}
body { font-family: 'Inter', 'Inter reserva', system-ui, sans-serif; }

El logotip (0,02). Falta la reserva d'espai:

<img src="/assets/logo-taller-a91c4e.webp" alt="Taller Nómada"
     width="180" height="48" fetchpriority="high" decoding="async">

(c) Compleix? Sí, amb marge. El bàner fix elimina 0,18, el min-height del gràfic elimina 0,09, la reserva ajustada elimina 0,05 i les dimensions del logotip eliminen 0,02: CLS resultant ≈ 0,00–0,02, molt per sota de l'objectiu de 0,1. Convé mesurar-ho, no donar-ho per fet: el panell Rendering de DevTools amb Layout Shift Regions assenyala visualment qualsevol salt que quedi.

Per què un salt provocat per l'usuari no compta. L'especificació de CLS ignora els desplaçaments que passen dins d'una finestra de 500 ms després d'una interacció de l'usuari (una pulsació de tecla, un clic, un toc). La raó és que el CLS pretén mesurar sorpresa, no moviment: si la Marta prem «Veure detalls» i el plafó es desplega empenyent el contingut, allò és exactament el que ha demanat i no la desconcerta. El que arruïna l'experiència és que el contingut salti mentre llegeix, fent-la prémer el botó equivocat.

Dues conseqüències pràctiques d'aquest detall. La primera: no intentis «amagar» els teus salts darrere d'una interacció falsa, perquè l'objectiu és l'experiència real, no la mètrica. La segona, més útil: si un desplegament triga més de 500 ms a produir-se després del clic —perquè hi ha un import() pel mig—, el salt sí que comptarà, i allà sí que cal reservar el buit. És un altre argument per al min-height del punt anterior.

Conclusió

El contracte de 09-01 està tancat. Deu números mesurats, deu números tornats a mesurar amb el mateix procediment, i una taula que ja no és una llista de queixes sinó un registre de decisions: LCP de 4,1 a 1,9 s, INP de 480 a 42 ms, CLS de 0,21 a 0,02, tasca llarga de 1.180 a 41 ms, render de 310 a 31 ms, recàlculs de 96 a 1,5 ms, informe de 940 a 52 ms de bloqueig, memòria de +37,7 MB a +0,4 MB, nodes de 7.812 a 1.194, i JavaScript inicial de 214 kB en 28 peticions a 58,3 kB en 3.

Entens el rendiment que es decideix abans d'executar una línia. Coneixes la ruta crítica de renderitzat i saps per què el CSS bloqueja el renderitzat —per no mostrar una pàgina sense estils que saltaria després— i per què un <script> clàssic bloqueja l'anàlisi, amb la taula completa de defer, async i type="module" que tanca el que 01-03 i 06-01 van deixar apuntat. Saps mesurar el pes real amb el panell NetworkDisable cache, Slow 4G, la columna Size amb els seus dos valors que delaten la manca de compressió, Priority i Initiator— i amb la pestanya Coverage, que et va donar la dada incòmoda d'on surt tot: el 62 % del JavaScript descarregat no s'executava abans de la primera targeta.

Saps què fa un empaquetador i, més important, per què existeix: no per «ficar-ho tot en un fitxer», sinó per resoldre els identificadors nus que el navegador no entén, per eliminar la cascada de descobriment que costava 1,1 s en quatre nivells, i per posar noms amb hash que facin segura la memòria cau eterna. Tens un vite.config.js real i comentat, amb target: 'es2022' en lloc de transpilar de més, mapes d'origen activats, i manualChunks separats per freqüència de canvi i no per mida. Saps què fa la minificació (214 → 118 kB) i què fa el tree shaking (118 → 104 kB), i per què aquest últim només és possible gràcies al fet que els import/export dels mòduls ES són estàtics —la restricció de 05-04 que ara té sentit— amb les seves quatre condicions per funcionar de debò: importacions nominals, sideEffects declarats, dependències en ESM i codi realment inabastable.

Saps dividir amb import() dinàmic aplicant les tres condicions —que pesi, que no es faci servir a la primera pantalla i que l'activi una acció concreta—, amb l'informe i el seu worker, l'editor enriquit i les traduccions fora de la ruta crítica: 46 kB menys per al 100 % de les visites. I saps fer-ho bé: la promesa desada que s'esborra en fallar, el literal parcial que permet a l'empaquetador descobrir les rutes variables, aria-busy a l'indicador, una degradació digna quan el tros no arriba i la detecció del desplegament nou que va invalidar els hashes. Coneixes els cinc suggeriments de recurs i quan fer servir cadascun, amb les seves tres regles: precarrega poc, mai el que no faràs servir en aquesta pantalla, i crossorigin obligatori a les fonts.

Saps fer diferides les imatgesloading="lazy" tret de a l'LCP, decoding="async", fetchpriority="high" només per a l'element LCP, width i height sempre, WebP abans de discutir sobre càrrega diferida— i els components, amb IntersectionObserver i rootMargin generós sobre un buit ja reservat. I saps el que gairebé ningú no mira: que una font pot pesar més que tot el teu JavaScript, i que font-display: swap, un subconjunt de 94 a 21,4 kB, la precàrrega amb crossorigin i una font de reserva amb mètriques ajustades valen més que moltes hores optimitzant codi. Amb elles, i amb les dimensions de les imatges i un esquelet que reserva el lloc de les targetes, vas tancar el CLS de 0,21 a 0,02, sabent a més que un esquelet no compta per a l'LCP i que un salt provocat per l'usuari no compta per al CLS.

Tanques el mòdul amb la infraestructura: Gzip i Brotli generats a la compilació i no al vol, sense recomprimir el que ja està comprimit i comprovant sempre a Network que estan actius; la memòria cau HTTP amb immutable d'un any per als actius amb hash, no-cache per a l'HTML i per al sw.js, i ETag per a la resta; la reconciliació amb el service worker de 07-05, la llista de precaché del qual escrita a mà deixava de funcionar quan hi va haver compilació i ara es genera durant la construcció, sense incloure els trossos diferits; i el pressupost de rendiment de 09-01 convertit per fi en alguna cosa que trenca la integració contínua: un script de bytes i peticions —estable, determinista i barat— més Lighthouse CI amb les seves assercions de regla, i el mapa d'arbre de rollup-plugin-visualizer guardat com a artefacte per al dia en què algú afegeixi una llibreria de 90 kB sense adonar-se'n. Amb l'honestedat final per davant: la fila 7 es va quedar en 52 ms i no en zero, el 0,02 de CLS és una decisió i no un descuit, tot això és laboratori i no camp, i cada millora ha tingut la seva factura en complexitat —un pas de compilació, noranta línies de virtualització, un Ctrl+F trencat, un destruir() a cada classe— que amb sis tasques al tauler no hauria estat justificada.

I aquí és on el mòdul lliura una cosa que no és en cap taula. Perquè Nómada Tasques respongui com respon has hagut de construir a mà, peça a peça, un conjunt molt concret de mecanismes: un render declaratiu que descriu la pantalla a partir de l'estat (06-06), una reconciliació per clau estable que reutilitza els nodes en lloc de destruir-los, un estat centralitzat amb invalidació explícita i memòria cau per versió (09-02), una neteja sistemàtica amb destruir() i AbortController perquè res no quedi penjant (09-03), una llista virtualitzada que manté constant el nombre de nodes (09-04), i una divisió de codi amb càrrega diferida, precàrrega i gestió de la fallada (09-05). Aquesta llista no és casual: és, gairebé punt per punt, el que un framework modern et dóna resolt de fàbrica el primer dia. React et demana una key a les llistes —que és literalment el teu data-id—, Vue reconstrueix només el que canvia, Angular porta divisió de codi a l'encaminador, i tots tres gestionen per tu el cicle de vida i la neteja que tu has escrit a mà. La diferència és que ara saps quin problema resolen, quant costa resoldre'l i què es paga per no resoldre'l tu, que és exactament la posició des de la qual es pot jutjar si compensen en lloc d'adoptar-los per costum. Amb aquesta perspectiva comença el mòdul següent: Per Què Existeixen els Frameworks.

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