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:
- nie widać, że jesteś zalogowany w drugiej zakładce.
- 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.