Strona główna / Artykuły

Projektowanie frontendów z priorytetem pracy offline jako replik: kolejki, ponawiania prób, konflikty

Dowiedz się, dlaczego podejście typu offline-first przekształca przeglądarkę w replikę danych, oraz jak lokalne magazyny, trwałe kolejki mutacji, idempotentne próby ponowne i zasady rozwiązywania konfliktów współpracują ze sobą.

Wydawca: REACTAPP.TOP12 min czytania2736 słów

Źródła: The Architecture Behind an Offline-First Frontend

Frontend, który dobrze funkcjonuje przy szybkiej, stabilnej łączności, niewiele znaczy. Interesujące przypadki pojawiają się wtedy, gdy żądanie utyka, Wi-Fi przerywa połączenie, pociąg wjeżdża do tunelu, laptop przechodzi między sieciami lub telefon traci sygnał chwilę po naciśnięciu przycisku Save. Tradycyjna architektura frontendów traktuje wszystko to jako wyjątki: wywołanie API, oczekiwanie, aktualizacja stanu i kontynuacja pracy.

Projektowanie z naciskiem na pracę offline wychodzi od przeciwnej zasady: sieć może zawieść, a aplikacja musi i tak pozostać użyteczna. Brzmi to jak udoskonalenie aspektu UX, ale jest to zmiana strukturalna. Gdy tylko przeglądarka potrafi odczytywać dane, przyjmować zmiany, przechowywać je i synchronizować później, przestaje być cienkim klientem i staje się jednym węzłem systemu rozproszonego. Ten przewodnik wyjaśnia, co to oznacza w praktyce: gdzie powinny znajdować się dane, jak bezpiecznie kolejkować i ponawiać operacje zapisu, jak interfejs użytkownika powinien komunikować stan synchronizacji oraz dlaczego obsługa konfliktów jest ostatecznie decyzją produktową.

Wykluczenie sieci z krytycznej ścieżki

Załóżmy prostą aplikację do notatek. Ktoś otwiera notatkę, edytuje jej tytuł i zapisuje ją. W tradycyjnym projekcie proces wygląda w ten sposób:

User → React → Server
              ↓
            Data

Serwer znajduje się bezpośrednio w trakcie interakcji. Powolny serwer oznacza powolną interfejs użytkownika; utrata połączenia sprawia, że zatrzymuje się proces zapisywania; nieudana prośba zmusza aplikację do natychmiastowej decyzji o tym, co zrobić ze zmianami użytkownika.

Projekt oparty na pracy offline przenosi znaczną część tej odpowiedzialności do przeglądarki:

User → React → Local Store
                    ↓
                Sync Later

Serwer nie traci na znaczeniu. W większości systemów nadal jest miejscem, gdzie przechowywana jest wspólna informacja. Zmienia się tylko to, że nie musi być dostępny przy każdej interakcji. To rozdzielenie, pomiędzy potwierdzeniem działania użytkownika a przekazaniem go do serwera backendowego, stanowi podstawę, na której opiera się wszystko inne.

Przeglądarka przechowuje teraz ważne dane stanu

Jeśli aplikacja musi nadal funkcjonować bez połączenia, potrzebuje trwałego przechowywania na urządzeniu. W przypadku czegokolwiek więcej niż zwykłych ustawień oznacza to zazwyczaj użycie IndexedDB lub bazy danych wyższego poziomu budowanej na jego bazie. Konkretna biblioteka ma mniejsze znaczenie niż konsekwencje: przeglądarka kontroluje teraz stan, którego utrata byłaby nie do przyjęcia.

Należy pomyśleć o zapisanych w pamięci tymczasowej rekordach i nie wysłanych projektach, zmianach czekających na publikację, metadanych synchronizacyjnych takich jak daty i numery wersji oraz informacjach o tym, co zostało już przesłane. To rodzi pytanie, na które aplikacja typu proste żądanie-odpowiedź nigdy nie musi odpowiadać: co powinno się stać, gdy wersja w przeglądarce i na serwerze się różnią? W aplikacji typu offline-first nie jest to przypadek szczególny – to norma.

Kopowanie danych do pamięci tymczasowej to nie to samo, co praca offline-first

Tę różnicę łatwo zatarć. Aplikacja może przechowywać w pamięci tymczasowej odpowiedzi API, a mimo to być całkowicie zależna od sieci. Załóżmy, że pobiera listę projektów i przechowuje wynik lokalnie, dzięki czemu lista pojawia się ponownie bez połączenia. To przydatne, ale dotyczy to tylko operacji odczytu.

Projekt staje się naprawdę „offline-first”, gdy użytkownicy mogą zmieniać dane podczas braku połączenia: tworzyć projekty, edytować dokumenty, zaznaczać zadania jako wykonane, dostosowywać ustawienia. Te działania nie mogą po prostu zniknąć tylko dlatego, że nie wysłano żadnego żądania HTTP. Aplikacja potrzebuje miejsca, gdzie można zapisywać to, co użytkownik zamierzał zrobić, aby ta intencja przetrwała do chwili jej realizacji.

Każda operacja zapisu w trybie offline staje się obowiązkiem synchronizacji

Gdy ktoś edytuje dokument offline, zwykłym podejściem jest najpierw zastosowanie zmiany w lokalnym magazynie, a następnie zapisanie operacji, która musi jeszcze dotrzeć do serwera. W uproszczonej formie:

User Saves
    ↓
Save Locally
    ↓
Update UI
    ↓
Add To Queue
    ↓
Sync When Online

Zwróć uwagę na kolejność: zapis lokalny i aktualizacja interfejsu następują przed umieszczeniem czegokolwiek w kolejce, dlatego interfejs może odpowiedzieć natychmiast. Kolejka na końcu wymaga większej uwagi, niż zwykle otrzymuje. Zamiast stosu błędnych żądań, jest to trwały rejestr zadań, których ukończenia użytkownik oczekuje od systemu.

To stawia konkretne wymagania. Kolejka musi przetrwać ponowne załadowanie strony, restart przeglądarki, tymczasowe błędy serwera oraz zmiany w sieci, co oznacza, że powinna znajdować się w trwałym przechowaniu, a nie w tablicy JavaScript. Musi również zachowywać się poprawnie, gdy jedna operacja zostanie wysłana dwa razy lub więcej. To drugie wymaganie prowadzi do jednego z najtrudniejszych sposobów awarii w całym projekcie.

Kiedy sukces wygląda dokładnie jak porażka

Rozważmy tę sekwencję. Użytkownik zapisuje dane. Przychodzi żądanie. Serwer zapisuje dane pomyślnie. Następnie, zanim odpowiedź dotrze z powrotem, połączenie zostaje przerwane. Klient wnioskuje, że zapis nie powiódł się; serwer wie, że udało mu się to.

Client
  |
  | Save
  v
Server
  |
  | Saved
  X
Response Lost
  |
  v
Client Retries

Jeśli klient po prostu spróbuje ponownie, serwer może zastosować tę samą zmianę dwa razy. Dla idempotentnej operacji „set title” może to nie mieć żadnych konsekwencji, natomiast w przypadku operacji takich jak „add item”, „create order” czy „increment counter” powoduje to duplikaty.

Dlatego właśnie idempotencja jest kluczowa w systemach typu offline-first. Każda mutacja wymaga stabilnej tożsamości, aby serwer mógł rozpoznać, że ponowna próba to ta sama operacja logiczna, a nie nowa. Powszechnym rozwiązaniem jest generowanie identyfikatora operacji na stronie klienta w momencie pierwszego zapisania zmiany, przechowywanie go razem z mutacją w kolejce oraz wysyłanie go przy każdej próbie. Serwer śledzi, które identyfikatory już przetworzył, a przy ich ponownym pojawieniu się potwierdza odbiór bez ponownego ich aplikowania.

To nie jest modyfikacja dotycząca wydajności. Służy do zapobiegania temu, by niepewna sieć przekształcała zwykłe próby ponownych działań w powtarzające się operacje. To praktyczny detal, który warto dobrze zaplanować: identyfikator musi zostać utworzony raz i zachowany wraz z wpisem do kolejki, a nie regenerowany przy każdej prośbie, w przeciwnym razie każda próba ponownego działania będzie traktowana jak nowa.

Optymistyczne interfejsy użytkownika jako wyraz intencji

Optymistyczne aktualizacje są zazwyczaj przedstawiane jako sposób na sprawienie, by aplikacje wydawały się szybsze. W aplikacjach skupionych na pracy offline mają one większe znaczenie. Jeśli użytkownik ukończy zadanie w trybie offline, a interfejs czeka na odpowiedź serwera przed jej odzwierciedleniem, aplikacja wydaje się po prostu uszkodzona. Zmiana dokonana lokalnie powinna pojawić się natychmiast, a synchronizacja może później zaktualizować wersję zdalną.

To pozwala na przydatne podzielenie obowiązków:

  • Lokalny stan odzwierciedla to, co zrobił użytkownik.
  • Stan synchronizacji wskazuje, czy system udostępniony nadrobił opóźnienie.
  • Są one powiązane, ale nie identyczne, a traktowanie ich oddzielnie pozwala interfejsowi komunikować się bardziej szczerymi informacjami niż pojedynczy wskaźnik online/offline. Na przykład element może być oznaczony jako:

    • zapisany na tym urządzeniu
    • w kolejce do synchronizacji
    • synchronizowany obecnie
    • synchronizowany
    • wymagający uwagi użytkownika

    Użytkownik, który widzi „w kolejce do synchronizacji”, rozumie, że praca jest bezpieczna, ale jeszcze nie została udostępniona, co jest o wiele bardziej uspokajające niż niejednoznaczny ikonka spinowania lub komunikat o błędzie.

    Pozwól lokalnemu magazynowi przechowywać dane, a Reactowi je renderować

    Częstym źródłem przypadkowej złożoności jest sytuacja, w której dane są przechowywane w IndexedDB, ładowane do stanu React, kopiowane ponownie do oddzielnego magazynu po stronie klienta, a następnie wszystko jest ręcznie synchronizowane. Aplikacja posiada wtedy kilka wersji tych samych rekordów, co prowadzi do użycia przestarzałych wartości, problemów z konkurencją, niepotrzebnego ponownego renderowania oraz błędów synchronizacji występujących wyłącznie w części frontendowej.

    Czystszy model przekazuje odpowiedzialność za trwałe dane odpornemu lokalnemu magazynowi, przy czym React pełni rolę interfejsu do nich:

    Local Store
         ↓
      React UI
         ↓
       User
         ↓
    Local Store
    

    Kluczowym elementem jest pętla: interfejs użytkownika odczytuje dane z lokalnego magazynu, użytkownik podejmuje działanie, a to działanie zapisuje zmiany z powrotem do lokalnego magazynu, który następnie ponownie uruchamia interfejs użytkownika. React zajmuje się obsługą interakcji i prezentacją, natomiast baza danych odpowiada za stan po stronie klienta, który musi być trwały. W praktyce oznacza to zazwyczaj subskrypcję komponentów na zapytania do lokalnego magazynu zamiast kopiowania wyników do stanu komponentu. Korzyści rosną wraz z rozmiarem aplikacji, ponieważ istnieje dokładnie jedno miejsce, w którym należy sprawdzić dane, gdy wartość wydaje się błędna.

    Gdy oba urządzenia mają rację

    Rozwiązywanie konfliktów to moment, w którym podejście „offline-first” przestaje być kwestią przechowywania danych i staje się problemem systemów rozproszonych. Załóżmy, że ktoś otworzy ten sam dokument na laptopie i telefonie, a oba urządzenia stracą połączenie z siecią. Na laptopie tytuł zmienia się na Frontend Architecture, a na telefonie na Offline Architecture. Każde urządzenie przyjęło ważną modyfikację, a żadne z nich nie wie o istnieniu drugiego. Gdy oba urządzenia ponownie nawiążą połączenie, dojdzie między nimi kolizja:

    Laptop              Phone
       |                   |
       v                   v
    "Frontend App"     "Offline App"
       |                   |
       +--------+----------+
                |
                v
             Conflict
    

    Nie istnieje uniwersalnie poprawne rozwiązanie. Czy powinna przeważyć najnowsza zmiana? Czy wersja serwera powinna mieć pierwszeństwo? Czy pola można połączyć? Czy użytkownik powinien wybrać? Czy różne pola powinny podlegać różnym zasadom?

    Kluczowym spostrzeżeniem jest to, że rozwiązywanie konfliktów to decyzja produktowa, która wygląda jak decyzja techniczna. Zasada „ostatni wpis zwycięża” może być w pełni akceptowalna na przykład dla preferencji interfejsu użytkownika. W przypadku dokumentów współpracowych, danych finansowych, liczb magazynowych lub ważnych zapisów biznesowych może być nie do przyjęcia, ponieważ ciche odrzucenie jednej ze stron skutkuje utratą rzeczywistej pracy lub powstaniem błędnych sum. Architektura musi określić, co oznacza „poprawność” dla każdego rodzaju danych, dlatego właściciele produktów muszą brać udział w tych dyskusjach, zamiast pozostawiać to osobie piszącej kod synchronizacji. Należy również pamiętać, że pojęcie „najnowszy” samo w sobie jest trudne do zdefiniowania: zegary urządzeń się odchylają, więc zasady oparte na czasach ostatniej modyfikacji po stronie klienta mogą wybrać niewłaściwego zwycięzcę, chyba że kolejność pochodzi od czegoś bardziej niezawodnego, takiego jak wersje przypisane przez serwer.

    Zmiany zdalne wymagają tego samego punktu wejścia

    Model „offline-first” nie oznacza synchronizacji wyłącznie w momencie otwierania aplikacji. Inni użytkownicy i inne urządzenia edytują te same dane, a serwer nadal otrzymuje zmiany z innych źródeł. Dlatego kanał w czasie rzeczywistym dodaje drugi kierunek przepływu danych:

    Local Changes
          ↓
         Sync
          ↓
        Server
          ↓
      New Changes
          ↓
     Local Store
          ↓
          UI
    

    To prowadzi do ważnej zasady: zmiany przychodzące z serwera powinny trafiać do tego samego lokalnego modelu danych, który jest już używany przez interfejs użytkownika. Jeśli lokalne edycje odbywają się jedną ścieżką, a aktualizacje zdalne inną, komponenty React muszą radzić sobie z skomplikowaną logiką mającą na celu połączenie tych dwóch źródeł. Używanie jednego lokalnego magazynu danych jako punktu spotkania upraszcza proces renderowania – interfejs nie musi wiedzieć, czy zmiana pochodzi z tego urządzenia, czy z innego.

    Załóżmy, że sam przeglądarka może zawieść się

    Istnieje jeszcze jeden sposób awarii, który łatwo przeoczyć: przeglądarka może zniknąć, zanim sieć wróci do działania. Karta zostaje zamknięta, przeglądarka się zawiesza, system operacyjny zamyka proces, bateria laptopa się wyczerpuje lub proces znika w trakcie synchronizacji. Wszelkie prace w toku, które istnieją tylko w pamięci, giną razem z nim.

    Dlatego stan synchronizacji musi być trwały. Poważna aplikacja typu offline-first powinna traktować przeglądarkę jako niewiarygodny węzeł, a jej lokalna baza danych musi zawierać wystarczająco dużo informacji, aby po ponownym uruchomieniu aplikacji można było odtworzyć prace w toku: jakie operacje są w przygotowaniu, które były w trakcie wykonywania oraz jaki był ostatni znany stan serwera. Przydatnym testem jest zamknięcie karty w trakcie synchronizacji podczas rozwoju i sprawdzenie, czy po następnym uruchomieniu nic nie zostało utracone ani powtórzone.

    Service workers to tylko jeden element, a nie cała architektura

    Pracownicy usług są niezwykle cenni, szczególnie do przechowywania w pamięci tymczasowej zasobów aplikacji oraz przerywania żądań sieciowych; jeśli potrzebujesz odświeżenia wiedzy na temat ich działania, zapoznaj się z włączaniem obsługi pracy offline za pomocą pracowników usług. Jednak rejestracja takiego pracownika nie sprawia, że aplikacja będzie działać przede wszystkim w trybie offline:

    • Zarejestrowany pracownik w niczym nie zapobiega utracie niesoхранionych zmian użytkownika.
    • Zapisane w pamięci tymczasowej pliki umożliwiają uruchomienie aplikacji offline, ale nie określają, dokąd trafiają dane zapisywane poza siecią.
    • Dostarczanie przestarzałych odpowiedzi API z pamięci tymczasowej nie stanowi polityki rozwiązywania konfliktów.

    Model offline-first to cecha architektury, a nie funkcja przeglądarki, którą można włączyć. To, co faktycznie sprawia, że działa, to lokalna warstwa danych, trwałe modyfikacje, proces synchronizacji, wyraźne radzenie sobie z błędami, polityka rozwiązywania konfliktów oraz interfejs użytkownika akceptujący fakt, że synchronizacja zdalna może potrwać trochę czasu.

    Złożoność się przemieszcza; nie znika

    Nieprzyjemną rzeczywistością jest to, że oprogramowanie oparte na modelu offline-first nie jest prostsze. Złożoność jest jedynie przeniesiona w inne miejsce. Tradycyjna aplikacja może po prostu wysłać żądanie i czekać na odpowiedź. Aplikacja typu offline-first musi rozwiązywać serię trudniejszych problemów:

    • A co, jeśli nie da się połączyć z serwerem?
    • A co, jeśli przeglądarka zamknie się przed zakończeniem synchronizacji?
    • A co, jeśli żądanie dotarło, ale odpowiedzi nie?
    • A co, jeśli ta sama informacja ulegnie zmianie na innym urządzeniu?
    • A co, jeśli obie zmiany są ważne?
  • Który wynik uznaje się za poprawny dla tego produktu?
  • Są to klasyczne problemy systemów rozproszonych, a obecność przeglądarki jako jednego z uczestników wcale ich nie upraszcza. Zanim zdecydujesz się na podejście typu „offline-first”, warto sprawdzić, czy produkt rzeczywiście wymaga zapisywania danych offline. W przypadku panelu kontrolnego, który użytkownicy tylko czytają, zapisy z pamięci cache w połączeniu z wyraźną informacją o trybie offline mogą zapewnić większość korzyści przy znacznie mniejszych kosztach.

    Dane lokalne wymagają takiego samego podejścia do bezpieczeństwa jak dane przechowywane na serwerze

    Dane przechowywane na urządzeniu to nadal dane, z całym zestawem związanych z nimi obowiązków. Jeśli w przeglądarce znajdują się informacje poufne, musisz wziąć pod uwagę to, co się stanie, jeśli urządzenie zostanie skompromitowane, jeśli będzie używane przez kilka osób lub jeśli jeden użytkownik wyloguje się, a drugi przejmie kontrolę nad nim. Lokalne przechowywanie danych powinno opierać się na tych samych zasadach bezpieczeństwa co przechowywanie danych w tle:

    • Zachowuj tylko to, czego klient rzeczywiście potrzebuje.
    • Zdefiniuj zasady wygaśnięcia oraz to, co dzieje się po wylogowaniu.
    • Nie traktuj pamięci przeglądarki jako bezpiecznego schowka.
    • Ustal świadomie, które dane w ogóle mogą znajdować się na urządzeniu.

    Podejście offline-first poprawia dostępność, ale ten korzyść nie powinna kosztować ujawnienia informacji, które od początku nie powinny być przechowywane lokalnie.

    Projektowanie z myślą o awariach, a nie o idealnym scenariuszu

    Cechą wyróżniającą podejście offline-first jest to, że architektura staje się jaśniejsza, gdy przestajesz projektować pod idealny scenariusz. Pytania zmieniają się:

    • Zamiast „jak pobrać te dane?”, zastanów się, gdzie powinny one znajdować się w przypadku braku połączenia sieciowego.
    • Zamiast „jak spróbować ponownie wysłać tę prośbę?”, zastanów się, jak klient może dowiedzieć się, czy serwer już ją przetworzył.
  • Zamiast zastanawiać się, jak wprowadzić dane do stanu React, zapytaj, która część systemu faktycznie je posiada.
  • Zamiast traktować sprzeczne żądania jako błąd do naprawienia, zapytaj, co oznacza poprawność w tym produkcie.
  • Jednolite odpowiadanie na drugą wersję każdego pytania prowadzi do bardziej solidnego projektu.

    Celem jest doświadczenie, którego użytkownicy ledwo zauważają

    Użytkownicy nie są zainteresowani silnikami synchronizacji, transakcjami bazodanowymi, politykami opóźnień, algorytmami łączenia lub replikacją. Chcą dalej pracować. Oczekują, że projekt pozostanie dostępny po przerwaniu połączenia, że zadanie wykonane pociągiem pozostanie ukończone, a aplikacja nie stanie się bezużyteczna w momencie zmiany ikony Wi-Fi. Mechanizmy pod spodem mogą być skomplikowane; doświadczenie użytkownika nie powinno takie być.

    Główne wnioski

    Jedyna idea, która łączy ten projekt, brzmi następująco: frontend oparty na pracy offline to system rozproszony, w którym przeglądarka przechowuje drugą kopię danych. Patrząc na to w ten sposób, poszczególne elementy nabierają jaśniejszych ról:

    • IndexedDB to nie tylko miejsce przechowywania w przeglądarce; stanowi ono część warstwy danych aplikacji.
    • Kolejka mutacji to nie narzędzie do ponawiania prób; jest to trwała rejestraция intencji użytkownika, a każda pozycja wymaga stabilnego ID w celu możliwości ponownych prób w sposób idempotentny.
    • Optymistyczne interfejsy użytkownika to nie trik szybkościowy; zapewniają one użyteczność produktu w czasie, gdy backend musi się zsynchronizować.
    • Rozwiązywanie konfliktów to nie przypadek krawędziowy; stanowi ono część sposobu, w jaki produkt definiuje poprawność.
    • Synchronizacja to nie coś, co dodaje się na końcu; jest to element architektury.

    Sieci zawodzą, serwery działają wolno, urządzenia tymczasowo się nie zgadzają, a przeglądarki znikają bez uprzedzenia. Dobrze zaprojektowana aplikacja typu offline-first zapewnia, że praca użytkownika przetrwa to wszystko, dotrzymując obietnicy nawet wtedy, gdy połączenie nie jest możliwe.

    Pozycje pokrewne

  • Zrób mniej pracy najpierw: lista weryfikacyjna wydajności React przed memoizacją — Zmniejsz koszty aplikacji React, unikając niepotrzebnych operacji: używaj funkcji debounce do zadań wyszukiwania, implementuj paginację, umieszczaj stan w odpowiednich miejscach, stosuj stabilne klucze, przenoś obliczenia na stronę serwera i używaj ładowania opóźnionego, a dopiero potem zastosuj memoizację w razie potrzeby.