Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Web Storage - localStorage i sessionStorage

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

localStorage kontra sessionStorage

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

Podstawowe operacje

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.

Przechowywanie złożonych danych z JSON

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
.

Bezpieczny odczyt i zapis

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.

Klasa UserPreferences

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 fabrycznymi

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

DataCache z TTL (Time To Live)

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.

Ostrzeżenia dotyczące bezpieczeństwa

WAŻNE - w Web Storage nigdy nie przechowuj danych wrażliwych:

  • haseł ani ich skrótów,
  • tokenów uwierzytelniających (zamiast tego użyj ciasteczek httpOnly),
  • numerów kart płatniczych,
  • danych osobowych (RODO, CCPA),
  • tokenów sesji.

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.

Podsumowanie

Web Storage to prosty i wygodny magazyn po stronie klienta:

  1. localStorage - dane trwają permanentnie i są współdzielone między wszystkimi kartami tej samej domeny
  2. sessionStorage - dane należą do jednej karty i znikają po jej zamknięciu
  3. JSON.stringify i JSON.parse - obowiązkowe przy zapisie obiektów i tablic, bo magazyn zna wyłącznie stringi
  4. UserPreferences - wzorzec klasy, która trzyma cały zestaw ustawień pod jednym kluczem i pilnuje wartości domyślnych
  5. DataCache z TTL - cache odpowiedzi API, który sam kasuje przeterminowane wpisy i oszczędza niepotrzebne zapytania
  6. try...catch - zapis i odczyt mogą zawieść (tryb prywatny, przepełniony magazyn, uszkodzony JSON), więc zawsze miej plan B
  7. Bezpieczeństwo - żadnych haseł, tokenów ani danych osobowych, bo dosięgnie je każdy skrypt na stronie
Przejdź do CodeWorlds