Synchronizacja koszyka zakupowego między kartami przeglądarki: BroadcastChannel kontra localStorage
Dlaczego zdarzenia przechowywania w localStorage zawierają identyczne dane, dlaczego rozwiązania oparte na Date.now są niestabilne, oraz jak BroadcastChannel w połączeniu z trwałym magazynem rozwiązuje problem znaczników koszyka pomiędzy kartami.
Znaki koszyka w różnych kartach wydają się zadaniem trwającym pięć minut, dopóki ticket z działu QA nie udowodni, że pierwszy pomysł w rzeczywistości cichutko wysyła komunikaty. Poniższy przewodnik opiera się na typowym scenariuszu rozmowy kwalifikacyjnej: karty z tego samego źródła, brak wspólnej pamięci, brak połączenia z serwerem — tylko narzędzia platformy przeglądarki.
Scenariusz
Klient ma otwarte dwie karty produktów. Dodaje przedmiot do karty A. Znak w nagłówku karty B powinien się aktualizować. W przeciwnym razie karta B nadal pokazuje stary licznik, a klient zakłada, że dodanie nie powiodło się.
Ograniczenia postawione przez rozmówcę: karty nie mogą dzielić się zbiorkami JavaScript, a sam sygnał nie może być przesyłany przez dwukierunkowe połączenie sieciowe. Powiadomienie musi być przekazane za pomocą platformy.
Pierwsza próba: wydarzenia przechowywania
Większość kandydatów ucieka się do localStorage w połączeniu z obsłuchaczem storage. Zapis w jednej karcie powiadamia pozostałe karty z tego samego źródła.
// Tab that adds the item
function addToCart(sku) {
cart.add(sku);
renderBadge();
localStorage.setItem('cart-sync', JSON.stringify({
type: 'CART_ADD', sku, qty: 1
}));
}
// Every other tab
addEventListener('storage', (e) => {
if (e.key !== 'cart-sync') return;
const msg = JSON.parse(e.newValue);
if (msg.type === 'CART_ADD') { cart.add(msg.sku); renderBadge(); }
});
To szkic często działa poprawnie. Potem pojawia się zgłoszenie błędu.
Raport QA, który to psuje
Aby to odtworzyć: dodaj taki sam SKU dwa razy. Karta A pokazuje ilość 2, a karta B pozostaje na poziomie 1. Po ponownym załadowaniu karty B w końcu pokazuje 2.
Nic nie wywołuje błędów. Łogi są spokojne. Mechanizm nadzoru wydaje się poprawny — dlaczego więc inny komputer przeoczył aktualizację?
Podpowiedzią jest to, że odświeżenie naprawia sytuację: stan trwały był prawidłowy; zawiodł jedynie sygnał w czasie rzeczywistym.
Pierwotna przyczyna: ignorowane są równe wartości
Algorytm HTML setItem rezygnuje, gdy przychodzący ciąg znaków pokrywa się z tym, co jest już przechowywane dla danej klucza — mówiąc inaczej zgodnie ze specyfikacją: „jeśli poprzednia wartość jest równa nowej, przestać”. Nie ma zapisu na dysku, nie ma transmisji, nie ma budzenia innego komputera.
Tożsame działania w koszyku mogą zostać zserializowane do tych samych bajtów:
{"type":"CART_ADD","sku":"SKU-1029","qty":1}
Karta A nadal zapisuje dane lokalnie po swoim własnym wywołaniu add. Karta B nigdy nie otrzymuje zdarzenia storage. Po ponownym załadowaniu karta B ponownie odczytuje dane z pamięci i wygląda normalnie — klasyczny problem w testach jakościowych.
Dlaczego duplikaty wydają się rzadkie, ale takie nie są
Sekwencje zmieniające wartości (najpierw A, potem B, a następnie znowu A) nadal funkcjonują poprawnie. Problemy pojawiają się przy identycznych, kolejnych danych:
- Dwukrotne kliknięcia, które dodają ten sam SKU
- Wiadomości typu „heartbeat” publikujące niezmieniony stan
"ONLINE" - Powtarzające się komunikaty
SESSION_EXPIRED, gdy karta nadal się uruchamia
To właśnie w takich momentach partnerzy najbardziej potrzebują ostrzeżenia.
„Unikalność” daty jest krucha
Często stosowana poprawka polega na wstawieniu wartości Date.now() do pliku JSON, dzięki czemu ciągi znaków się różnią:
localStorage.setItem('cart-sync', JSON.stringify({
type: 'CART_ADD', sku, qty: 1,
t: Date.now() // force the value to differ
}));
Zegary milisekundowe kolidują, gdy dwie operacje zapisu następują w tej samej milisekundzie. To, czy patch zadziała w takim przypadku, zależy od szczęścia planeru — czasem tak, a czasem nie. Poprawność zależna od drobności zegara ścianowego to nie jest prawdziwa poprawność. W sytuacjach, gdy trzeba pozostać przy przechowywaniu danych, lepiej używać crypto.randomUUID() (lub innego silnego unikalnego tokena).
Czyszczenie powoduje podwójną dostawę
Zachowywanie unikalnych danych na zawsze jest skomplikowane, dlatego ludzie natychmiast po setItem wywołują removeItem. To powoduje wysłanie dwóch powiadomień: jednego dotyczącego zapisu, a drugiego usunięcia.
event 1 → { key:'cart-sync', oldValue: null, newValue: '{"type":"CART_ADD",…}' }
event 2 → { key:'cart-sync', oldValue: payload, newValue: null }
O ile obsługa nie ignoruje warunku newValue === null, o tyle peer aplikuje mutację koszyka dwa razy. Projekt oparty na przechowywaniu jako autobusie wymaga teraz unikalności, mechanizmów ochrony przed wartościami null, parsowania oraz czyszczenia — ponieważ API to magazyn klucz/wartość, który czasami wysyła sygnały, a nie kolejka wiadomości.
Najlepsze narzędzie: BroadcastChannel
Gdy chodzi o wysyłanie wiadomości, a nie ich przechowywanie, należy użyć API do komunikacji:
const bus = new BroadcastChannel('cart-sync');
// send — the same message, as many times as you like
bus.postMessage({ type: 'CART_ADD', sku, qty: 1 });// receive
bus.onmessage = (e) => {
if (e.data.type === 'CART_ADD') { cart.add(e.data.sku); renderBadge(); }
};addEventListener('pagehide', () => bus.close());
Powtarzane identyczne obiekty nadal są dostarczane. Nie ma skrócenia poprzez sprawdzanie równości. Strukturalne klonowanie umożliwia przechowywanie bardziej złożonych typów niż JSON (Date, Map, Set, tablice typowane). Traktuj przechowywanie jak bazę danych z opcjonalnymi komunikatami o zmianach, a BroadcastChannel jako komunikat bez bazy danych.
Kwestie dodatkowe istotne w produkcji
Samo-dostarczanie. Obiekt kanału nie otrzymuje własnej wiadomości, natomiast inna instancja tego samego kanału w tym samym dokumencie tak – podobnie jak iframy z tego samego źródła. Należy usunąć duplikaty, jeśli na jednej stronie znajduje się kilku subskrybentów.
Synchroniczne vs asynchroniczne. Doręczanie jest kolejkowane w pętli zdarzeń odbiorcy (asynchronicznie). Klonowanie podczas wywołania postMessage jest synchroniczne i natychmiast odrzuca wartości, których nie da się sklonować (DataCloneError w przypadku funkcji).
Karty otwarte później. Kanały nie odtwarzają historii. Karta otwarta po dodaniu nadal pokazuje stary znacznik, chyba że odczytuje trwałe zapisy. Zastosowany wzorzec:
// The shape that actually ships:
// durable store = the truth, channel = the doorbell
async function addToCart(sku) {
await idbPut('cart', sku); // atomic, survives reloads
bus.postMessage({ type: 'CART_CHANGED' }); // just a signal
}
bus.onmessage = async () => renderBadge(await idbCount('cart'));
Trwałe dane stanowią prawdę; kanał jest jedynie dzwonkiem informującym o zmianie tej prawdy.
Licznik w pamięci przechowawczej. Czytanie, modyfikowanie i zapisywanie między kartami powoduje utratę aktualizacji; platforma nie oferuje mechanizmu blokowania. Nie twórz rozproszonego licznika w localStorage.
Gdzie przechowywanie nadal ma przewagę. Ustawienia takie jak temat czy lokalizacja, które muszą zostać odczytane synchronicznie przed rysowaniem i rzadko się zmieniają. Do przechowywania wartości użyj zdarzeń storage; do przekazywania zdarzeń użyj BroadcastChannel.
Kontrola własna
Za pomocą prostego podejścia do przechowywania z identycznym obciążeniem, ile zdarzeń peer powstanie przy dwóch identycznych dodaniach wykonanych z odstępem jednej sekundy?
localStorage.setItem('cart-sync', '{"sku":"SKU-1029"}');
// … one second later, same product added again …
localStorage.setItem('cart-sync', '{"sku":"SKU-1029"}');
Odpowiedź: jedno zdarzenie. Czas upływu nie ma znaczenia; tylko zmieniona strona wyzwalająca powiadamianie.
Zakończenie rozmowy
Najpierw użyj BroadcastChannel do sygnałów między kartami, zachowaj IndexedDB lub podobne jako główny źródło danych koszyka, a jeśli zaproponuje się przechowywanie jako autobus komunikacyjny, wspomnij o szybkim zwrocie przy identycznych wartościach. Wspomnij także o ograniczeniach ponownego odtwarzania z późniejszych kart, efekcie wielokanałowego echo oraz o tym, dlaczego liczniki bez blokad w localStorage nie działają.
Główne wnioski
Synchronizacja interfejsu między kartami to problem komunikacyjny przybrany w szaty przechowywania danych. Traktuj trwały stan oraz powiadomienia jako oddzielne warstwy, wybieraj API odpowiadające każdej z tych warstw i testuj identyczne sekwencje działań – tutoriale omijają takie przypadki, podczas gdy użytkownicy w środowisku produkcyjnym muszą radzić sobie z podwójnym kliknięciem.
Dodatkowe uwagi dotyczące środowiska produkcyjnego
Brauzery mobilne mogą agresywnie usuwać karty w tle; odświeżanie znacznika przy visibilitychange, które ponownie odczytuje dane z trwałego przechowania, rozwiązuje problemy z pominięciem wiadomości podczas zamrożenia aplikacji. Połącz to z użyciem BroadcastChannel dla elementów widocznych na pierwszym planie.
Testy automatyczne powinny przeprowadzać działania w dwóch kontekstach (karty Playwright) i sprawdzać zarówno przechowywanie identycznych danych, jak i dostarczanie ich za pomocą BroadcastChannel. Udokumentuj wybrany schemat kluczy trwałych, aby przyszła synchronizacja serwerowa mogła je połączyć bez konieczności tworzenia dodatkowego źródła prawdy.
Znaki funkcjonalne czasami kontrolują zachowanie „żywego odznaczenia”. Zachowaj bezwarunkowe zapisywanie danych; tylko element dzwonka może być opcjonalny. W przeciwnym razie karta z ustawieniem wyłączonego znaku będzie trwale odróżniać się od kart z ustawieniem włączonego znaku.
Z punktu widzenia bezpieczeństwa nigdy nie umieszczaj tokenów autoryzacyjnych w wiadomościach z localStorage. SKU produktów są w porządku; tajemnice sesji – nie. Lepiej używać niewidocznych identyfikatorów i pozwalać każdej karcie na odczyt uprzywilejowanych danych z kanałów httpOnly lub pamięci po zweryfikowanej kontroli sesji.
Jeśli sklep internetowy obejmuje kilka poddomen, BroadcastChannel nie będzie mógł między nimi przesyłać danych. Opcjami są wspólny worker na podstawowej domenie lub wydarzenia wysyłane przez serwer zidentyfikowane numerem koszyka. Należy wskazać na tę ograniczenie już we wczesnych etapach przeglądów projektu.
Internacjonalizacja odznaczenia (reguły liczby mnogiej) powinna być realizowana w każdej karcie po odczytaniu liczby – nie wysyłaj wcześniej sformatowanych ciągów znaków, chyba że można zagwarantować identyczność we wszystkich lokalizacjach.
Na koniec dokonaj pomiarów: zapisz, jak często występują dodawanie duplikatów SKU w raportach analitycznych. Jeśli częstotliwość ta jest znacząca, pułapka przechowywania o jednakowej wartości stanowiłaby ukryty incydent produkcyjny czekający na pierwszego zaawansowanego użytkownika korzystającego z wielu kart.
Przyporządkowanie wywiadu do dokumentu projektowego
Gdy przygotowujesz to dla zespołu, oddziel trzy decyzje: (1) który trwały magazyn przechowuje dane koszyka, (2) który mechanizm komunikacji budzi inne karty, oraz (3) w jaki sposób narzędzie redukujące odznaki interpretuje komunikaty. Sprowadzenie tych decyzji do zasady „po prostu użyj localStorage” powoduje powstanie pułapki równości.
Krótki dokument projektowy może zawierać diagram sekwencyjny: kliknięcie użytkownika → modyfikacja danych w IndexedDB → wysłanie wiadomości przez BroadcastChannel → inne karty unieważniają zapytania o odznaki. Należy uwzględnić możliwe błędy: brak obsługi kanału (rzadkie w nowoczesnych przeglądarkach, ale warto sprawdzić), specyfiki trybu prywatnego oraz sytuacje, gdy przeglądarki z wieloma profilami izolują przechowywanie danych.
Lista kontrolna testów
- Dwukrotnie dodaj identyczne SKU z dzwonkiem tylko do przechowywania (oczekiwany błąd)
- Dwukrotnie dodaj za pomocą BroadcastChannel (oczekiwane dwa aktualizacje)
- Otwórz trzecią kartę po dodaniach (oczekiwana poprawna liczba z trwałego odczytu, a nie z ponownej transmisji)
- Szybkie dodawanie w ciągu jednego milisekundy przy unikalności zapisu czasu (oczekiwany przerywany błąd)
- setItem + removeItem bez ochrony przed wartością null (oczekiwana podwójna liczba)
Autoryzacja tej listy kontrolnej w procesie CI zapobiega regresjom, gdy ktoś „uproszcza” rozwiązanie do zdarzeń przechowywania.
Dlaczego rekruterom podoba się to pytanie
Pozytywnie ocenia się umiejętność czytania specyfikacji, a nie zapamiętywania nazw API. Kandydaci, którzy tylko przeglądali MDN, nie potrafią odpowiedzieć na pytanie o wartość zwracaną przy warunku równości. Ci, którzy tworzyli interfejsy z wieloma kartami, sami wspominają o BroadcastChannel i trwałych magazynach danych, bez żadnych wskazówek. Dodatkowe pytania dotyczące późno otwieranych kart i liczników bez blokad pokazują, czy odpowiedź pochodzi z fragmentu posta na blogu, czy to doświadczenie praktyczne.
W przypadku wersji do zabrania domu, poproś o małe repozytorium z demo zawierające dwa ścieżki oraz plik README opisujący wybrane warstwy. Recenzenci powinni otworzyć dwa okna i kliknąć – praktyczny dowód jest lepszy niż akapit teorii.
Powiązane wzorce
Wskaźniki obecności, wskaźniki pozycji kursora w pracy zespołowej (lekkie) oraz mechanizmy wylogowania wykorzystują ten sam model. Edycja dokumentów współdzielonych zazwyczaj wymaga CRDT lub serwera; nie należy używać BroadcastChannel jako protokołu zapewniającego spójność danych. Trzymajmy się przykładu koszyka zakupów: synchronizacja informacji o stanie koszyka w czasie ostatecznym, autorytatywny i trwały koszyk, opcjonalna korekta danych przez serwer później.
W środowisku produkcyjnym należy utrzymać prostotę rozwiązania: jeden trwały koszyk, jeden mechanizm komunikacji typu doorbell, wyraźne testy na duplikację działań oraz brak polegania na precyzyjnych zegarach mierzących czas w milisekundach. To właśnie ta prostota zapewnia spójność informacji o stanie koszyka, nawet gdy użytkownicy otwierają większą liczbę okien niż w przykładowej demonstracji.
To połączenie rozwiązań wystarczy. Gotowe.
Literatura pokrewna
- Dwadzieścia pytań z interviewu o React-u, które rozróżniają użycie od zrozumienia — Virtual DOM, klucze, efekty, memoizacja, Context, SSR i hydratacja — wyjaśnione poprzez trudne kwestie, które faktycznie badają pytający, a nie definicje z podręczników.
- Jak contexty zamknięcia V8 przechowują pamięć poza tym, co wykorzystuje jedna funkcja — V8 tworzy jeden wspólny kontekst na każde wezwanie dla każdej zmiennych zewnętrznych, o których wspomina funkcja wewnętrzna — dzięki temu nieużywane elementy i stałe słuchacze mogą przechowywać duże wartości.