Diversity w polskim IT
Jakub T. Jankiewicz
Jakub T. Jankiewicz Senior Front-End Developer

Synchronizacja stanu aplikacji www między zakładkami

Sprawdź, jak synchronizować dane aplikacji pomiędzy otwartymi zakładkami, używając biblioteki sysend.
20.10.20217 min
Synchronizacja stanu aplikacji www między zakładkami

Czy korzystałeś kiedyś z jakieś strony, sklepu lub aplikacji i gdy wylogowałeś się w jednej zakładce — dostałeś błąd, gdy chciałeś zrobić coś w innej zakładce. Mnie często się to zdarza i jest trochę irytujące. Zazwyczaj jedna zakładka ma połączenie z serwerem, i gdy zalogujesz się w jednej zakładce, w drugiej nie będziesz zalogowany, ponieważ jedna zakładka zazwyczaj nie wie nic o drugiej.

W tym artykule przedstawie jak rozwiązać ten problem za pomocą jednej małej biblioteki Open Source w języku JavaScript o nazwie sysend.

Przykład problemu synchronizacji

Jeśli nigdy nie spotkałeś się z problemem synchronizacji aplikacji między zakładkami, przedstawię ci jeszcze jeden przykład problemu.

Otwierasz sobie stronę, np. sklep, czyli aplikacje zazwyczaj typu client-server. Wyszukujesz sobie kilka produktów, które zamierzasz kupić. Masz otwarte np. 10 zakładek z tym co cię interesuje, po zastanowieniu się zostajesz z 3-ma zakładkami. I teraz logujesz się w jednej i chcesz dodać coś do koszyka w drugiej zakładce.

Mamy dwa problemy:

  1. nie widać, że jesteś zalogowany w drugiej zakładce.
  2. po dodaniu wszystkich 3 produktów, aby zobaczyć całość — musisz odświeżyć stronę.

Rozwiązanie problemu kilku zakładek

Oba problemy możesz naprawić, synchronizując zakładki ze sobą. A dokładnie reprezentacje stanu na stronie internetowej przy pomocy biblioteki sysend wysyłając komunikat do reszty zakładek, że zmienił się stan i aby go odświeżyły. Najprościej jest po prostu pobrać stan z serwera, gdzie w większości przypadków będzie zapisany
cały stan koszyka. Możesz też oczywiście przesyłać stan bezpośrednio z jednej aplikacji typu SPA do drugiej.

Instalacja biblioteki sysend

Aby użyć biblioteki, można zainstalować paczkę z NPM:

npm install sysend

I potem dodać do projektu, gdy np. używa się Webpacka.

const sysend = require('sysend');

Można też dodać bibliotekę poprzez tag script:

<script src="https://cdn.jsdelivr.net/npm/sysend"></script>

Po zainstalowaniu, aby użyć podstawowych funkcji biblioteki, wystarczą dwie metody:

  • sysend.broadcast- wysłanie komunikatu do innych zakładek.
  • sysend.on- dodanie nasłuchiwania na zdarzenie z innych zakładek.

Synchronizacja systemu logowania aplikacji między zakładkami

Jeśli mamy np. kod logowania i funkcje, która wysyła zapytanie HTTP typu POST do serwera:

function login(username, password) {
    const data = new FormData();
    data.append('username', username);
    data.append('password', username);
    return fetch('/login', {
        method: 'POST',
        body: data
    }).then(result => {
       if (result && result.success) {
          updateUserInfo(result);
       }
    });
}

W kodzie załóżmy, że funkcja updateUserInfo uaktualni górny pasek aplikacji. Przykładowo doda tam avatar użytkownika, zmieni linki zaloguj i zarejestruj na wyloguj oraz doda link od ustawień użytkownika.

Teraz, aby zsynchronizować system logowania między zakładkami, wystarczy dodać dwa wywołania:

  • Na początek trzeba dodać nasłuchiwanie na zdarzenie, gdzieś w głównej części programu.
// nasłuchiwanie na zdarzenie login wysłanej z innej zakładki
sysend.on('login', function(result) {
    updateUserInfo(result);
});

Można to uprościć — przekazując updateUserInfo jako argument:

sysend.on('login', updateUserInfo);
  • Gdy mamy już to dodane wystarczy wysłać komunikat (poprzez sysend.broadcast), gdy wysyłane jest zapytanie HTTP:
function login(username, password) {
    const data = new FormData();
    data.append('username', username);
    data.append('password', username);
    return fetch('/login', {
        method: 'POST',
        body: data
    }).then(result => {
       if (result && result.success) {
          updateUserInfo(result);
          // wysłanie zdarzenia login do innych zakładek
          // co w efekcie zaktualizuje stan o zalogowaniu
          sysend.broadcast('login', result);
       }
    });
}

I to cały kod, jaki jest potrzebny, aby zsynchronizować widok zalogowanego użytkownika.

Problem z CSRF między zakładkami

CSRF to atak hackerski (aby być dokładnym krakerski), gdzie atakujący wysyła zapytanie do serwera i podszywa się pod ofiarę, używając jej przeglądarki i jakiś złośliwej strony. Jednym z rozwiązać tego problemu jest token CSRF, który jest przekazywany i sprawdzany przy każdym zapytaniu HTTP. Także tym w JavaScript (tzw. AJAX).

Jeśli aplikacja posiada zabezpieczenie przed CSRF, może się zdarzyć, że aplikacja nie działa wcale w więcej niż jeden zakładce. Ponieważ, gdy wyślę się zapytanie HTTP do serwera, które zwróci nowy token CSRF, to druga zakłada, że nic nie wie o tym nowym tokenie. Tego typu problem często zdarza mi się w pracy, pracując z aplikacjami JIRA i BitBucket, gdzie nie da się utworzyć dwóch branchy lub PR w dwóch zakładkach, trzeba przejść cały flow w jednej zakładce i dopiero potem utworzyć nowy PR lub branch.

Rozwiązanie tego problemu jest proste, wystarczy przesłać token z jednej zakładki do reszty zakładek.

Jeśli mamy np.

function parseCookies() {
    var result = {};
    cookie.split(/\s*;\s*/).forEach(function(pair) {
        pair = pair.split(/\s*=\s*/);
        var name = decodeURIComponent(pair[0]);
        var value = decodeURIComponent(pair.splice(1).join('='));
        result[name] = value;
    });
    return result;
}

function getCSRFtoken() {
    return parseCookies()['csrftoken'];
}

function secureFetch(url, options = {}) {
    // dodanie nagłówka z tokenem zabezpieczającym
    const headers = {...options.headers, "X-CSRFToken": getCSRFtoken()};
    return fetch(url, {...options, headers});
}

Serwer natomiast musi zapisać token w ciasteczku i sesji i sprawdzać, czy zapisana wartość zgadza się z tą w nagłówku.

Problem występuje gdy mamy otworzone dwie strony, będzie tylko jedna sesja, więc jedna zakładka nadpisze sobie stan drugiej zakładki. Aby naprawić ten problem, wystarczy zachowywać token w zmiennej, i aktualizować zmienną po każdym zapytaniu oraz dwa nowe wywołania synchronizujące zakładki przy pomocy biblioteki sysend:

var CSRFtoken = getCSRFtoken();

// nasłuchiwanie na zdarzenie między zakładkami
sysend.on('CSRFtoken', (newToken) => {
    CSRFtoken = newToken;
});

function secureFetch(url, options = {}) {
    const headers = {...options.headers, 'X-CSRFToken': CSRFtoken};
    options = {...options, headers};
    return fetch(url, {...options, headers}).then(res => {
        // aktualizacja tokenu
        CSRFtoken = getCSRFtoken();
        // wysłanie komunikatu z tokenem CSRF do innych zakładek
        sysend.broadcast('CSRFtoken', CSRFtoken);
        return res;
    });
}

I mamy zabezpieczenie przed CSRF, które działa na więcej niż jednej zakładce. Aby zabezpieczyć się przed ewentualnym wyciekiem tokenu, gdy ktoś znajdzie lukę [XSS](Cross-site scripting), można token zapisać w lokalnej zmiennej. Np. Można np. użyć funkcji init(), gdzie umieścimy cały kod, można też użyć domknięcia lub
użyć let⁣, aby nie tworzyć zmiennej globalnej dostępnej w obiekcie window.

Synchronizacja koszyka zakupowego w aplikacji ReactJS

Przypuśćmy, że mamy prostą aplikację w ReactJS, gdzie można dodawać przedmioty do koszyka:

const { useState, createRef } = React;

const priceMap = {
    'eggs': 12,
    'bread': 7,
    'milk': 3,
    'sugar': 8
}
;
const App = () => {
    let [cart, setCart] = useState({});
    const productRef = createRef();
    const quantityRef = createRef();s
    return (
        <div>
            <h2>Shopping Cart</h2>
            <Cart items={cart} removeItem={(name) => {
                    const {[name]: _, ...newCart} = cart;
                    setCart(newCart);
                }}/>
            <select ref={productRef}>
                <option value="milk">Milk</option>
                <option value="bread">Bread</option>
                <option value="eggs">Eggs</option>
                <option value="sugar">Sugar</option>
            </select>
            <label>
                <span>Quantity</span>
                <input ref={quantityRef} type="number" min="1" max="10" defaultValue={1}/>
            </label>
            <button onClick={() => {
                    const name = productRef.current.value;
                    let quantity = +quantityRef.current.value;
                    if (cart[name]) {
                        quantity += cart[name].quantity;
                    }
                    setCart({...cart, [name]: {name, quantity}});
                }}>Add</button>
        </div>
    );
};

Aby dodać synchronizację między zakładkami — wystarczy dodać taki kod, wewnątrz komponentu App.

sysend.on('cart', (newCart) => {
    setCart(newCart);
});
const updateCart = (cart) => {
    sysend.broadcast('cart', cart);
    setCart(cart);
};

I zamienić wywołanie hooka setCart na wywołanie funkcji updateCart. I to wszystko. Aby zobaczyć tę prostą aplikację w ReactJS, możesz zobaczyć to demo na CodePen. Otwórz tę stronę w dwóch zakładkach i dodaj coś do koszyka w jednej i sprawdź, czy się zaktualizowała druga zakładka.

Ważne jest, aby sysend.on było wewnątrz hooka useEffect, inaczej będzie dodawane nowe zdarzenie, za każdym razem jak nastąpi update.

useEffect(() => {
    const handler = (newCart) => {
        setCart(newCart);
    };
    sysend.on('cart', handler);
    return () => {
        sysend.off('cart', handler);
    };
}, []);

BroadcastChannel

Jeśli się zastanawiasz, dlaczego po prostu nie użyć API dostępnego w przeglądarce, tj. BroadcastChannel, to odpowiedź jest taka, że nie wszystkie przeglądarki obsługują to API. Biblioteka sysend działa także gdy to API jest niedostępne. Posiada też szereg funkcji, które nie były opisane w tym artykule, jak:

  • Prosta obsługa komunikatów między różnymi domenami (Cross-Domain).
  • Unikalne identyfikatory okien/zakładek.
  • Wysyłanie komunikatu do konkretnego okna.
  • API do pobierania wszystkich okien/zakładek, sysend.list().
  • Można sprawdzić, czy zakładka jest główną, tj. pierwszą lub ostatnią, która została.
  • Dzięki funkcji sysend.serializer istnieje możliwość łatwego przekazywania dowolnych danych, np. instancji klas. Można do tego celu użyć np. nadzbiorów JSON takich jak np. json-dry lub formaty binarne takie jak np. BSON albo CBOR.

Podsumowanie

Warto zdawać sobie sprawę, że można w bardzo łatwy sposób ułatwić części użytkownikom, korzystanie z aplikacji synchronizując stan między zakładkami. Dzięki bibliotece sysend komunikacja między zakładkami jest o wiele prostsza. Biblioteka umożliwia także o wiele więcej użytecznych funkcji oraz tworzenie bardziej skomplikowanych aplikacji, które współdzielą stan między zakładkami.

Jeśli chcesz sprawdzić bibliotekę w działaniu (szczególnie funkcje, które nie zostały opisane w artykule), możesz zobaczyć oficjalne demo, które zawiera podstawowe API, czyli wysłanie komunikatów, oraz nowe API do śledzenia otwartych okien i wysłania komunikatów do konkretnego okna.

Jeśli masz jakiś pomysł, w jaki sposób można by wykorzystać tę bibliotekę, koniecznie napisz w komentarzu.

Jeśli zainteresował cię ten artykuł i chciałbyś być na bieżąco z tym co publikuje, możesz mnie śledzić na Twitterze @jcubic.

<p>Loading...</p>