Zagadnienie

Niejednokrotnie tworząc Single Page Application (SPA) zastanawiałeś się jak zapisać niezbędne do działania aplikacji dane po stronie przeglądarki. Być może słyszałeś o localStorage, sessionStorage, cookies czy indexedDB, ale nie do końca wiesz czym się do siebie różnią i w jakim przypadku będą najlepszą opcją dla Twojej aplikacji. Może dopiero zaczynasz swoją przygodę z web developmentem i chcesz dowiedzieć się jakie opcje przechowywania danych posiadają przeglądarki i kiedy w nich korzystać.

Czytając ten artykuł dowiesz się czym są localStorage, sessionStorage, cookies i indexedDB. Poznasz wady, zalety oraz wykorzystanie poszczególnych rozwiązań, a także zobaczysz jak się z nimi pracuje.

Zbudujemy prostą aplikację z trzema przyciskami wywołującymi kolejno zapisywanie, pobieranie i usuwanie danych z każdego z zasobów. Dane, które chcemy zapisać do zasobu wpisujemy w pole Dane do zapisania. Pobrane dane wyświetlą się poniżej. Wszystkie operacje będziemy też obserwować w developer tool przeglądarki.

Cookies

Przez wiele lat (do momentu pojawienia się HTML5) cookies były jedyną forma przechowywania danych użytkownika w przeglądarce. Jest to zasób w postaci par klucz – wartość zapisywanych jako String na dysku użytkownika.

Dostęp do zasobu: Ciasteczka są wymieniane między serwerem a klientem(przeglądarką) w każdym żądaniu http. Dlatego obie strony tego żądania mają do nich dostęp. Dodatkowo użytkownik może zmodyfikować/wyczyścić ciasteczka w przeglądarce.

Okres przechowywania: Ciasteczka mogą mieć ustawioną datę ważności i wygasać w momencie przekroczenia tej daty. Jeśli data nie jest ustawiona, to ciasteczka wygasają w momencie zakończenia sesji.

Limity wielkości: Minimalna ilość ciasteczek pochodzących z danej domeny określona w standardzie Request for Comments to 20. Przeglądarki mogę zwiększyć ten limit. Zazwyczaj waha się on między 30 a 50 ciasteczek. Niektóre przeglądarki (IE, Opera, Safari) limitują także ilość miejsca, jakie dana domena może wykorzystać na swoje ciasteczka do 4KB.

Zalety: umożliwia wymianę danych między klientem a serwerem. Ma dosyć zrozumiałe API, które jednak mogłoby zostać ulepszone np. poprzez dodanie metody pozwalającej wybrać pojedyncze ciasteczko.

Wady: Cookies nie nadają się do przechowywania skomplikowanych struktur danych. Wszystkie ciasteczka są wymieniane między serwerem a klientem w każdym żądaniu http, dlatego mogą niepotrzebnie zmniejszać przepustowość.

Wykorzystanie: ID sesji, podstawowe dane/preferencje użytkownika. Sprawdzanie tożsamości użytkowników, aby nie musiały być potwierdzane na każdej stronie aplikacji.

Z poziomu JavaScript dostęp do cookies mamy poprzez document.cookie. Jest to dostęp do wszystkich ciasteczek zdefiniowanych dla danej domeny, a nie jak nazwa mogłaby wskazywać do pojedynczego ciasteczka.

Ciasteczko dodajemy wpisując jego nazwę i wartość. Opcjonalnie po średniku możemy dodać datę ważności jako okres podany w sekundach np. max-age=360 lub konkretną datę expires=Fri, 15 Jan 2021 20:00:00 GMT. Skorzystamy z obu opcji zapisując 2 ciasteczka.

dataStoreBtn.addEventListener('click', () => {
   document.cookie = `dataInput1=${dataInput.value}; max-age=600`;
   document.cookie = `dataInput2=1; expires=Fri, 15 Jan 2021 20:00:00 GMT`;
});

Otwieramy development tool przeglądarki (F12) i sprawdzamy, czy cookies zostały zapisane. W Firefox wszystkie dane zapisane w pamięci przeglądarki znajdziemy w zakładce Dane.

Przystępujemy do pobrania danych. Niestety w odpowiedzi otrzymujemy wszystkie ciasteczka, stąd aby uzyskać wartość pojedynczego ciasteczka musielibyśmy dodatkowo pociąć otrzymany String.

dataRetrieveBtn.addEventListener('click', () => {
    let cookieValue = document.cookie;
    dataDisplay.innerHTML = cookieValue;
});

Na koniec usuniemy cookie dataInput1 poprzez ustawienie daty ważności na datę przeszłą. W development tool widzimy, że pozostało już tylko jedno ciasteczko dataInput2.

dataDeleteBtn.addEventListener('click', () => {
    document.cookie = `dataInput1=${dataInput.value}; expires=Fri, 15 Jan 2021 09:11:17 GMT`;
});

LocalStorage i sessionStorage

LocalStorage i SessionStorage przechowują dane w postaci pary klucz – wartość. Zarówno klucz jak i wartość przechowane są jako String.

Dostęp do zasobu: Programista ma dostęp do zasobu poprzez kod JavaScript. Użytkownik może zmodyfikować/wyczyścić localstorage i sessionStorage poprzez development tool.

Okres przechowywania: LocalStorage przechowuje dane do momentu ich usunięcia przez programistę lub użytkownika.

SessionStorage jest gromadzony tak długo jak długo trwa sesja, czyli dopóki strona jest otwarta w przeglądarce. Zasób jest czyszczony po zamknięciu zakładki/przeglądarki.

Limity wielkości: Limity są zależne od przeglądarki. Dla przeglądarek desktopowych w większości to 10MB. Jedynie Safari udostępnia 5MB. Dla przeglądarek mobilnych wahają się  między 2MB a 10MB.

Zalety: LocalStorage i sessionStroage są bardzo łatwe w użyciu. Posiadają też całkiem spory limit wielkości.

Wady: Oba zasoby nie nadają się do przechowywania skomplikowanych struktur danych.

Wykorzystanie: ID sesji, podstawowe dane/preferencje użytkownika. Zasoby localStorage mogą być wykorzystane do zapewnienia ciągłości użytkowania aplikacji w czasie braku połączenia internetowego lub do identyfikowania powracających na stronę użytkowników miedzy sesjami.

W JavaSript localStorage i sessionStorage to property obiektu window, dlatego w kodzie możemy odwoływać się do nich bezpośrednio. Są bardziej intuicyjne w użyciu od cookies. Przykład przedstawia jedynie localStorage, ponieważ dla sessionStorage wygada on identycznie. Wartość ustawiamy za pomocą metody setItem().

dataStoreBtn.addEventListener('click', () => {
    localStorage.setItem('dataInput1', dataInput.value);
    localStorage.setItem('dataInput2', 1);
});

W development tool dane localStorage znajdziemy w zakładce Lokalna pamięć, sessionStorage w zakładce Pamięć sesji.

Pobieramy dane metoda getItem(). W przeciwieństwie do ciasteczek,  tutaj jesteśmy w stanie pobrać pojedyncze wartości.

dataRetrieveBtn.addEventListener('click', () => {
    dataDisplay.innerHTML = localStorage.getItem('dataInput1');
});

Usuwanie danych jest równie proste jak pozostałe operacje.

dataDeleteBtn.addEventListener('click', () => {
    localStorage.removeItem('dataInput1');
});

IndexedDB

IndexedDB to baza danych wbudowana w przeglądarkę. Może przechowywać pary klucz wartość, obiekty bez konieczności ich serializacji, a także pliki i obiekty blob. Posiada asynchroniczne API operujące na zdarzeniach.

Dostęp do zasobu: asynchroniczne API.

Okres przechowywania: IndexedDB przechowuje dane do momentu ich usunięcia przez programistę lub użytkownika.

Limity wielkości: Tu znowu limity są zależne od przeglądarki. Chrome pozwala na użycie do 80% miejsca na dysku, Firefox – do 50% dostępnej na dysku przestrzeni, IE10 lub nowszy do 250MB, Safari do 1GB.

Zalety: Jest to baza danych, zatem może przechowywać skomplikowane struktury danych.

Wady: API jest nie do końca intuicyjne. W porównaniu do localStorage/sessionStorage czy ciasteczek potrzebujemy o wiele więcej kodu do osiągniecia zamierzonego celu. IE wspiera indexedDB jedynie częściowo.

Wykorzystanie: Przechowywanie skomplikowanych struktur danych, których nie możemy przechowywać w ciasteczkach lub localStorage.

Wykonanie operacji zapisu, pobrania i usunięcia danych w indexedDB będzie wymagało od nas nieco więcej pracy. Najpierw musimy otworzyć połącznie z bazą. W tym celu skorzystamy z metody open() z nazwą bazy i jej wersją podanymi jako argumenty metody. Jeśli baza jeszcze nie istnieje, polecenie ją utworzy. W przeciwnym wypadku otworzy połączenie z istniejącą bazą.

const request = indexedDB.open('ExampleDB', 1);

Metoda zwraca nam request, w którym możemy nasłuchiwać na zdarzenia onsuccess i onerror. W obu mamy dostęp do obiektu event. Dostęp do bazy, na której później będziemy mogli wykonywać operacje mamy poprzez event.target.result.

let dataBase;

request.onsuccess = (event) => {
    dataBase = event.target.result;
}

request.onerror = (event) => {
    console.log('Błąd indexedDb', event)
}

Co prawda uzyskaliśmy dostęp do bazy nasłuchując na zdarzenie onsuccess, jednak w tym momencie tylko otworzyliśmy połączenie z bazą. Abyśmy mogli przeprowadzać na niej operacje musimy utworzyć store (odpowiednik tabeli) obsługując zdarzenie onupgradeneeded. Jako argumenty metody createObjectStore podajemy nazwę tworzonego store i obiekt opcji. W opcjach możemy podać keyPath – unikalny identyfikator obiektów i autoIncrement – dodający generator kluczy do store (domyślnie ustawiony na false).

let dataBase;

request.onupgradeneeded = (event) => {
    dataBase = event.target.result;
    objectStore = dataBase.createObjectStore('zbior', { keyPath: 'id'});
}

W development tool widzimy nowoutworzona bazę.

Do utworzenia nowego rekordu w store musimy wywołać metodę transaction() na naszej bazie. Jako parametry metody podajemy nazwę store i tryb, w którym będziemy pracować. W naszym wypadku będzie to readwrite. Następnie wywołujemy store, do którego dodajemy rekord, a na nim wywołujemy metodę add. Jako argument metody należy podać dodawany obiekt.

dataRetrieveBtn.addEventListener('click', () => {
    const request = dataBase.transaction('zbior', 'readonly').objectStore('zbior').get(1);

    request.onsuccess = function() {
        dataDisplay.innerHTML = request.result.dataInput;
      }

      request.onerror = function() {
        console.log('Nie znaleziono obiektu');
      }
});

W zbiorze pojawił się nowy rekord.

Identyczny request zbudujemy do pozyskania danych z bazy zamieniając metodę add() na get(). Metoda get() jako argument przyjmuje unikalny identyfikator obiektu. Dzięki temu pobraliśmy dane z bazy.

dataRetrieveBtn.addEventListener('click', () => {
    const request = dataBase.transaction('zbior', 'readonly').objectStore('zbior').get(1);

    request.onsuccess = function() {
        dataDisplay.innerHTML = request.result.dataInput;
      }

      request.onerror = function() {
        console.log('Nie znaleziono obiektu');
      }
});

Podobnie z usuwaniem obiektu. Tym razem metodę get() zamienimy na delete().

dataDeleteBtn.addEventListener('click', () => {
    const request = dataBase.transaction('zbior', 'readwrite').objectStore('zbior').delete(1);

      request.onerror = function() {
        console.log('Nie znaleziono obiektu');
      }
});

Po uruchomieniu metody widzimy pusty zbiór.

Podsumowanie

Jak widać najbardziej wszechstronnym zasobem do zapisywania danych w pamięci przeglądarki jest indexedDB, ponieważ pozwala zapisywać złożone obiekty a limity wielkości zapisu są bardzo duże. Niemniej jednak ich zapisanie kosztowało nas całkiem sporo pracy i linijek kodu, dlatego powinniśmy wykorzystywać indexeDB w ostateczności, kiedy localStorage i cookies nie pozwalają na zapisanie tak skomplikowanych danych. W znakomitej większości przypadków ilość miejsca dostępnego dla localStorage/sessionStorage oraz cokkies jest wystarczająca. Dlatego jeśli potrzebujemy wymiany  danych z serwerem używamy cookies, w przeciwnym wypadku localStorage posiada tak proste API, że aż szkoda z niego nie skorzystać 😊.