Dennis Nedry siedzi w sterowni Parku Jurajskiego i po raz dwudziesty tego dnia przestawia widok mapy na satelitarny. "Nie zamierzam klikać tego samego przy każdym odświeżeniu strony" - burczy pod nosem. I ma rację: nie każda informacja zasługuje na podróż do serwera. Preferowany widok mapy, motyw kolorystyczny, ostatnio oglądany sektor, roboczy stan formularza zgłoszenia incydentu - to wszystko spokojnie może zostać w przeglądarce zwiedzającego. JavaScript daje na to dwa wbudowane mechanizmy: localStorage i sessionStorage. Oba przechowują pary klucz-wartość po stronie klienta, oba mają identyczne API i oba działają bez jednej linijki kodu serwerowego. Różnią się w gruncie rzeczy jedną rzeczą - tym, jak długo pamiętają.
| Cecha | localStorage | sessionStorage | |-------|--------------|----------------| | Trwałość | Przetrwa zamknięcie i ponowne otwarcie przeglądarki | Czyszczony w chwili zamknięcia karty lub okna | | Zasięg | Wspólny dla wszystkich kart i okien tej samej domeny | Odizolowany do bieżącej karty | | Limit pojemności | ~5-10 MB na domenę | ~5-10 MB na kartę | | Zastosowanie | Preferencje i ustawienia użytkownika | Tymczasowe dane sesji, stan formularza |
Jedno zdanie warto zapamiętać na całe życie: dane w localStorage trwają permanentnie, dopóki ktoś ich świadomie nie usunie, a dane w sessionStorage znikają po zamknięciu karty. To jest ta jedyna prawdziwa różnica między nimi i drugiej nie ma.
Przy okazji wyprostujmy trzy nieporozumienia, bo krążą wyjątkowo uparcie. Po pierwsze, sessionStorage nie ma większego limitu pojemności - obie przestrzenie mieszczą mniej więcej tyle samo, czyli około 5-10 MB. Po drugie, oba magazyny przechowują wyłącznie stringi; żaden z nich nie potrafi zapisać obiektu, więc zdanie w stylu "localStorage bierze tylko teksty, a sessionStorage obiekty" jest zwyczajnie nieprawdziwe. Po trzecie, oba działają we wszystkich współczesnych przeglądarkach - localStorage nie jest wynalazkiem wyłącznie Chrome, a sessionStorage nie jest jego bardziej uniwersalnym zamiennikiem. Wybierasz między nimi wyłącznie na podstawie tego, czy dane mają przeżyć zamknięcie karty.
Całe API mieści się w pięciu nazwach:
setItem, getItem, removeItem, clear i key, do tego dochodzi właściwość length. Ta sama piątka obsługuje oba magazyny, więc uczysz się jej raz i masz z głowy. setItem przyjmuje klucz i wartość, getItem zwraca wartość zapisaną pod kluczem - albo null, jeśli takiego klucza nigdy nie było. To null, nie undefined, i na tym drobiazgu potyka się zaskakująco wiele warunków. removeItem kasuje pojedynczy wpis, clear wymiata wszystko, co należy do danej domeny, a key(i) pozwala przejść po magazynie indeks po indeksie, kiedy chcesz zobaczyć, co tam właściwie siedzi.1// setItem - zapisuje dane
2localStorage.setItem('parkTheme', 'dark');
3localStorage.setItem('language', 'en');
4sessionStorage.setItem('currentSector', 'B-4');
5
6// getItem - odczytuje dane
7const theme = localStorage.getItem('parkTheme'); // "dark"
8const missing = localStorage.getItem('nothere'); // null
9
10// removeItem - usuwa jeden klucz
11localStorage.removeItem('parkTheme');
12
13// clear - usuwa wszystkie dane
14localStorage.clear();
15
16// length i key() - iteracja po magazynie
17console.log(localStorage.length); // ile kluczy w magazynie
18for (let i = 0; i < localStorage.length; i++) {
19 const key = localStorage.key(i);
20 console.log(key, localStorage.getItem(key));
21}Zwróć uwagę na trzecią linijkę.
sessionStorage obsługujemy dokładnie tak samo jak localStorage - zmienia się wyłącznie nazwa obiektu przed kropką. Numer sektora zapisany w sessionStorage zniknie razem z kartą, więc to idealne miejsce na dane robocze, których nie chcemy oglądać jutro rano. clear() bywa za to zdradliwe: kasuje absolutnie wszystko, co twoja domena kiedykolwiek zapisała, łącznie z wpisami pochodzącymi z zupełnie innych części aplikacji. W prawdziwym projekcie bezpieczniej jest usuwać klucze po nazwie albo trzymać je pod wspólnym prefiksem i czyścić tylko ten prefiks. Pętla z key() przydaje się właśnie do tego drugiego podejścia.Magazyn przyjmuje wyłącznie stringi i nie zamierza o tym dyskutować. Jeśli podasz mu obiekt, nie zgłosi błędu - po cichu zamieni go na tekst tą samą konwersją, którą znasz z doklejania obiektu do stringa. Efekt jest zawsze identyczny i zawsze bezużyteczny: w magazynie ląduje napis
[object Object], a wszystkie właściwości przepadają bezpowrotnie. To jedna z tych pomyłek, które nie wysypują aplikacji od razu, tylko czekają cierpliwie do momentu, w którym ktoś spróbuje odczytać zapisane dane.1// UWAGA - obiekt zostanie zamieniony na "[object Object]"
2const dino = { name: 'Rex', species: 'T-Rex' };
3localStorage.setItem('dino', dino);
4console.log(localStorage.getItem('dino')); // "[object Object]"Rozwiązaniem jest JSON - format tekstowy, który potrafi wiernie opisać obiekt i tablicę.
JSON.stringify zamienia strukturę na string przed zapisem, JSON.parse odtwarza ją po odczycie. Ta para chodzi zawsze razem i to właśnie ona sprawia, że w localStorage możesz trzymać cały zestaw preferencji zwiedzającego, listę ostatnich incydentów albo zagnieżdżony obiekt powiadomień o karmieniu i alarmach bezpieczeństwa. Zagnieżdżenie nie jest tu żadnym problemem - JSON radzi sobie z obiektem w obiekcie tak samo dobrze jak z płaską listą kluczy. Jedyne, o czym trzeba pamiętać, to że JSON nie zna funkcji, dat ani undefined: data wróci z magazynu jako zwykły string i trzeba ją ręcznie przepuścić przez new Date(), funkcja zniknie po drodze bez ostrzeżenia, a właściwość ustawiona na undefined po prostu nie pojawi się w zapisanym tekście. Przy zwykłych ustawieniach użytkownika żadne z tych ograniczeń nie przeszkadza.1// Zapisywanie obiektu
2const userPrefs = {
3 theme: 'dark',
4 fontSize: 16,
5 language: 'en',
6 notifications: { security: true, feeding: true }
7};
8
9localStorage.setItem('userPreferences', JSON.stringify(userPrefs));
10
11// Odczyt z powrotem
12const storedPrefs = JSON.parse(localStorage.getItem('userPreferences'));
13console.log(storedPrefs.theme); // "dark"
14
15// Zapisywanie tablicy
16const recentIncidents = ['INC-001', 'INC-002', 'INC-003'];
17localStorage.setItem('recentIncidents', JSON.stringify(recentIncidents));
18
19const incidents = JSON.parse(localStorage.getItem('recentIncidents') || '[]');Samo wyrażenie zapisujące obiekt warto rozłożyć na cztery kawałki, bo dokładnie w takiej kolejności będziesz je składać: najpierw obiekt magazynu
localStorage, potem wywołanie .setItem(, potem klucz razem z otwarciem konwersji 'key', JSON.stringify(, a na samym końcu obiekt i dwa domykające nawiasy object)). Te dwa nawiasy na końcu to nie literówka - pierwszy zamyka JSON.stringify, drugi setItem. Odczyt jest tą samą trasą w drugą stronę: JSON.parse opakowuje localStorage.getItem('key') i oddaje gotowy obiekt. Ostatnia linijka pokazuje jeszcze jeden nawyk wart przejęcia - || '[]' podstawia pustą tablicę, kiedy klucza nie ma, dzięki czemu JSON.parse nigdy nie dostaje null.W sterowni parku nic nie działa idealnie i z magazynem przeglądarki jest tak samo.
JSON.parse wybuchnie wyjątkiem, jeśli pod kluczem siedzi tekst, którego nikt nie zapisał przez stringify - a wystarczy, że użytkownik ręcznie pogrzebał w narzędziach deweloperskich albo że starsza wersja aplikacji zapisała dane w innym formacie. setItem też potrafi rzucić błędem: w trybie prywatnym Safari zapis bywa zablokowany, a przy przekroczeniu limitu pojemności dostaniesz wyjątek przepełnienia. Dlatego oba wywołania w produkcyjnym kodzie opakowuje się w try...catch i zwraca sensowną wartość zastępczą zamiast pozwolić aplikacji się wyłożyć.1// Odczyt odporny na brak klucza i uszkodzony JSON
2function getStoredData(key, defaultValue = null) {
3 try {
4 const item = localStorage.getItem(key);
5 return item ? JSON.parse(item) : defaultValue;
6 } catch (error) {
7 console.error(`Parsowanie klucza ${key} nieudane:`, error);
8 return defaultValue;
9 }
10}
11
12const settings = getStoredData('userSettings', { theme: 'light', language: 'pl' });
13
14// Bezpieczny zapis - tryb prywatny Safari, brak miejsca w magazynie
15function safeSetItem(key, value) {
16 try {
17 localStorage.setItem(key, value);
18 return true;
19 } catch (error) {
20 console.error('Zapis do localStorage nieudany:', error);
21 return false;
22 }
23}Obie funkcje mają wspólną filozofię: awaria magazynu nie może zatrzymać aplikacji.
getStoredData zawsze coś zwróci - albo prawdziwe dane, albo wartość domyślną, którą sam podałeś jako drugi argument. safeSetItem zwraca true lub false, więc kod wywołujący może zdecydować, czy pokazać komunikat, czy po prostu udawać, że nic się nie stało. Zauważysz, że to dokładnie ten sam wzorzec, który stosuje się przy pracy z siecią: zakładamy, że operacja może się nie udać, i mamy przygotowany plan B. Park Jurajski nauczył wszystkich, że systemy bez planu B kończą się źle.Rozrzucone po całym kodzie wywołania
setItem i getItem szybko stają się problemem: nikt nie pamięta, pod jakim kluczem co leży, a domyślne wartości powielają się w pięciu miejscach. Odpowiedzią jest jedna klasa, która trzyma cały zestaw preferencji pod jednym kluczem w magazynie i sama pilnuje wartości domyślnych. Reszta aplikacji widzi wtedy wyłącznie get i set, a to, że pod spodem leci JSON.stringify i JSON.parse, przestaje ją obchodzić. Zwróć uwagę na metodę get - sprawdza obecność klucza operatorem in, a nie zwykłym warunkiem prawdziwości, dzięki czemu zapisane false nie zostanie pomylone z brakiem ustawienia.1class UserPreferences {
2 constructor() {
3 this.storageKey = 'jurassicParkPrefs';
4 this.defaults = {
5 theme: 'light',
6 language: 'en',
7 alertSound: true,
8 mapZoom: 2,
9 favoriteSection: 'overview'
10 };
11 }
12
13 get(key) {
14 const stored = localStorage.getItem(this.storageKey);
15 const prefs = stored ? JSON.parse(stored) : {};
16 return key in prefs ? prefs[key] : this.defaults[key];
17 }
18
19 set(key, value) {
20 const stored = localStorage.getItem(this.storageKey);
21 const prefs = stored ? JSON.parse(stored) : {};
22 prefs[key] = value;
23 localStorage.setItem(this.storageKey, JSON.stringify(prefs));
24 }
25
26 setMultiple(updates) {
27 const stored = localStorage.getItem(this.storageKey);
28 const prefs = stored ? JSON.parse(stored) : {};
29 Object.assign(prefs, updates);
30 localStorage.setItem(this.storageKey, JSON.stringify(prefs));
31 }
32
33 reset() {
34 localStorage.removeItem(this.storageKey);
35 }
36
37 getAll() {
38 const stored = localStorage.getItem(this.storageKey);
39 const prefs = stored ? JSON.parse(stored) : {};
40 return { ...this.defaults, ...prefs };
41 }
42}
43
44const prefs = new UserPreferences();
45prefs.set('theme', 'dark');
46prefs.set('alertSound', false);
47console.log(prefs.get('theme')); // "dark"
48console.log(prefs.getAll()); // Wszystkie preferencje razem z fabrycznymigetAll pokazuje jeszcze jedną sztuczkę: rozpakowanie this.defaults przed zapisanymi preferencjami sprawia, że użytkownik zawsze dostaje komplet ustawień, nawet jeśli w magazynie leży tylko jedno z nich. Nazwy metod to oczywiście twoja decyzja. W zadaniu praktycznym zbudujesz PreferencesManager do zapisywania i odczytywania preferencji użytkownika w Parku Jurajskim, a jego metody nazywają się save(), load(), remove() i clear(). Wzorzec jest identyczny: save() bierze obiekt preferencji, przepuszcza go przez JSON.stringify i oddaje do setItem, load() idzie tą samą drogą w przeciwną stronę przez getItem i JSON.parse, remove() kasuje jeden klucz, a clear() sprząta cały zestaw.Preferencje to nie jedyne dane warte zapamiętania. Karta dinozaura pobrana z API nie zmienia się co sekundę, więc odpytywanie serwera przy każdym otwarciu tego samego profilu jest marnowaniem czasu i transferu. Rozwiązaniem jest cache z TTL, czyli czasem życia wpisu. Zapisujemy wartość razem ze znacznikiem
expiresAt wyliczonym jako Date.now() + ttlMs, a przy odczycie porównujemy go z aktualnym czasem. Wpis przeterminowany usuwamy i zwracamy null, dokładnie tak, jakby go nigdy nie było. Klasa poniżej przyjmuje magazyn w konstruktorze, więc ten sam kod obsłuży localStorage i sessionStorage - wystarczy podać inny obiekt.1class DataCache {
2 constructor(storage = localStorage, defaultTtlMs = 5 * 60 * 1000) {
3 this.storage = storage;
4 this.defaultTtlMs = defaultTtlMs;
5 }
6
7 set(key, value, ttlMs = this.defaultTtlMs) {
8 const item = {
9 value,
10 expiresAt: Date.now() + ttlMs
11 };
12 this.storage.setItem(`cache:${key}`, JSON.stringify(item));
13 }
14
15 get(key) {
16 const stored = this.storage.getItem(`cache:${key}`);
17 if (!stored) return null;
18
19 const item = JSON.parse(stored);
20
21 if (Date.now() > item.expiresAt) {
22 this.storage.removeItem(`cache:${key}`);
23 return null; // Wpis przeterminowany
24 }
25
26 return item.value;
27 }
28
29 getOrFetch(key, fetchFn, ttlMs) {
30 const cached = this.get(key);
31 if (cached !== null) return Promise.resolve(cached);
32
33 return fetchFn().then(value => {
34 this.set(key, value, ttlMs);
35 return value;
36 });
37 }
38
39 clear(prefix) {
40 const keysToRemove = [];
41 for (let i = 0; i < this.storage.length; i++) {
42 const key = this.storage.key(i);
43 if (key && key.startsWith(`cache:${prefix || ''}`)) {
44 keysToRemove.push(key);
45 }
46 }
47 keysToRemove.forEach(key => this.storage.removeItem(key));
48 }
49}
50
51// W praktyce
52const cache = new DataCache(localStorage, 10 * 60 * 1000); // TTL 10 minut
53
54async function getDinosaurData(id) {
55 return cache.getOrFetch(
56 `dino:${id}`,
57 () => fetch(`/api/dinosaurs/${id}`).then(r => r.json()),
58 5 * 60 * 1000 // 5 minut
59 );
60}Najciekawsza jest tu metoda
getOrFetch, bo zamyka cały schemat w jednym wywołaniu: sprawdź cache, a jeśli pusto - pobierz, zapisz i zwróć. Kod wywołujący nie musi wiedzieć, skąd przyszły dane. Warto też zauważyć, po co jest prefiks cache: doklejany do każdego klucza. Dzięki niemu metoda clear potrafi wyczyścić wyłącznie wpisy cache'u, nie ruszając preferencji użytkownika zapisanych obok - to właśnie ta bezpieczniejsza alternatywa dla globalnego localStorage.clear(), o której była mowa wcześniej. Zwróć jeszcze uwagę, że klucze do usunięcia najpierw zbieramy do tablicy, a kasujemy dopiero potem: usuwanie w trakcie iterowania po length przesuwałoby indeksy i część wpisów zostałaby pominięta.WAŻNE - w Web Storage nigdy nie przechowuj danych wrażliwych:
Powód jest prosty i twardy: Web Storage jest dostępny dla dowolnego JavaScriptu działającego na stronie. Nie ma tam flag ochronnych, nie ma szyfrowania, nie ma kontroli dostępu. Wystarczy jeden skrypt wstrzyknięty atakiem XSS - własny albo doklejony przez zewnętrzną bibliotekę - i cała zawartość magazynu wyjeżdża na serwer napastnika w jednej linijce. Dennis Nedry przekonał się w parku, że najsłabszym punktem systemu nigdy nie jest płot, tylko wygodna furtka zostawiona przez kogoś, komu się spieszyło. Motyw kolorystyczny, ostatnio odwiedzona podstrona czy stan formularza mogą tam spokojnie leżeć. Token dostępu do systemu ogrodzeń - nigdy.
Web Storage to prosty i wygodny magazyn po stronie klienta: