Strona główna / Artykuły / Warstwowanie stanu w aplikacji strumieniowej React: Context, Redux Toolkit i RTK Query

Warstwowanie stanu w aplikacji strumieniowej React: Context, Redux Toolkit i RTK Query

Prześledź interfejs do strumieniowania wideo, od tworzenia właściwości po zagnieżdżone dostawcy, aż po kawałki Redux Toolkit i RTK Query, i dowiedz się, jaki rodzaj stanu pasuje do której narzędzia.

1694 słów

Prawie każde rozwijane aplikacje React dochodzą do momentu, w którym komponent korzeniowy jest przykryty warstwą dostawców, a timer odtwarzania w jakiś sposób ponownie renderuje ikonę powiadomienia. Rozwiązaniem rzadko jest pojedyncza biblioteka; chodzi raczej o zrozumienie, że różne rodzaje stanu wymagają różnych rozwiązań. Ten przewodnik wykorzystuje frontend do strumieniowania wideo jako przykład badawczy, przechodząc od przekazywania wartości przez propy poprzez Context API aż po Redux Toolkit i RTK Query, kończąc praktyczną zasadą wyboru między nimi.

Przykład: frontend do strumieniowania

Załóżmy, że mamy do czynienia z podstawowymi funkcjami usługi strumieniowania anime lub filmów:

  • logowanie i opcje subskrypcji – darmowe lub premium
  • lista oglądanych treści oraz opcja „kontynuuj oglądanie”
  • stan odtwarzacza: aktualny odcinek, postęp odtwarzania oraz ustawienia jakości
  • przegląd katalogu i wyszukiwanie z filtrami gatunkowymi
  • powiadomienia o nowych odcinkach oraz przypomnienia o subskrypcji
  • Każdy z tych elementów musi znajdować się gdzieś w drzewie komponentów, a kilka oddalonych od siebie komponentów musi je odczytywać. To połączenie jest dokładnie tym miejscem, gdzie wczesne decyzje dotyczące stanu albo przynoszą korzyści, albo miesiącami później przeradzają się w powolne i kosztowne modyfikacje.

    Etap pierwszy: przenoszenie danych przez pośredniki

    Pierwszym odruchem jest przeniesienie stanu do najbliższego wspólnego przodka i przekazanie go dalej. W małych aplikacjach to właściwa decyzja. Jednak w interfejsie strumieniowym ścieżka od korzenia do przycisku może być długa:

    App → MainLayout → ContentSection → AnimeGrid → AnimeCard → PlayButton

    Załóżmy, że PlayButton musi znać poziom subskrypcji, aby zdecydować, czy pokazać ikonę blokady premium. Ten poziom musi być przekazywany przez MainLayout, ContentSection i AnimeGrid, z których żaden go nie wykorzystuje. Istnieją one w tej łańcuchu jedynie jako przewoźnicy.

    Jeden przenoszony parametr jest do przyjęcia. Problemy zaczynają się, gdy druga, niepowiązana wartość, np. lista do obserwacji, musi przebywać tą samą ścieżką. Każdy komponent pośredni przenosi teraz parametry, których nie rozumie, co utrudnia ich ponowne użycie w innych miejscach, testowanie w izolacji oraz zrozumienie ich funkcjonowania. Zanim sięgniesz po bibliotekę, warto przeczytać dlaczego samo przenoszenie parametrów nie jest powodem do instalacji Redux lub Zustand; kompozycja często skraca te łańcuchy. W tym aplikacji jednak dane rzeczywiście są globalne.

    Etap drugi: Kontekst i piramida dostawców

    API Context w React to naturalny następny krok. Tworzysz AuthContext, otaczasz drzewo elementów AuthProvider, a PlayButton odczytuje dane bezpośrednio za pomocą useContext(AuthContext). W ten sposób problem z głębokim nawigowaniem znika.

    Następnie pojawiają się inne globalne kwestie, każda z własnym dostawcą danych, i struktura korzenia aplikacji zaczyna wyglądać w ten sposób:

    <AuthProvider>
      <SubscriptionProvider>
        <WatchlistProvider>
          <PlayerProvider>
            <NotificationProvider>
              <ThemeProvider>
                <App />
              </ThemeProvider>
            </NotificationProvider>
          </PlayerProvider>
        </WatchlistProvider>
      </SubscriptionProvider>
    </AuthProvider>
    

    To właśnie programiści nazywają piekłem dostawców: korzeń aplikacji staje się zbiorem nawarstwionych elementów otaczających, z których każdy dodaje warstwę pośrednictwa. Wizualny chaos to dopiero najmniejszy z problemów.

    Diagnozowanie wymaga mapy

    Gdy odtwarzanie nie działa poprawnie, najpierw musisz dowiedzieć się, który dostawca danych kontroluje ten stan, a następnie prześledzić strukturę nawarstwień, aby znaleźć miejsce, w którym następują zmiany. Samo drzewo nie podaje ci tej informacji.

    Każda aktualizacja dociera do wszystkich użytkowników

    Zmiana wartości kontekstu powoduje ponowne renderowanie wszystkich komponentów, które korzystają z tego kontekstu, nawet tych, które wykorzystują tylko jego niewielką część. Kontynuowanie oglądania wymaga zapisywania wartości playbackProgress co kilka sekund. Jeśli ta wartość znajduje się w PlayerContext razem z ustawieniami jakości, komponent, który jedynie wyświetla informację o jakości, nadal jest renderowany przy każdym odświeżeniu.

    Kolejność dostawców staje się domyślną umową

    WatchlistProvider potrzebuje identyfikatora zalogowanego użytkownika dostarczanego przez AuthProvider, dlatego musi być umieszczony w jego obrębie. W kodzie JSX nie ma nic, co wyraźnie wskazywałoby na tę zależność, a zmiana kolejności dostawców podczas refaktoryzacji może spowodować awarie aplikacji, których trudno jest zlokalizować.

    Rzeczywisty objaw: zespół łączy kontekst gracza z kontekstem powiadomień, a analiza pokazuje, że aktualizacje postępów wyzwalają ponowne renderowanie powiadomień. Nic nie jest widocznie uszkodzone, ale React Profiler pokazuje znacznie więcej renderowań, niż wymaga interfejs użytkownika.

    Kontekst można dostosować, na przykład poprzez rozdzielenie szybko zmieniających się wartości na osobny kontekst lub zastosowanie mechanizmu memoizacji do wartości dostarczanych przez providerów, ale każde rozwiązanie tymczasowe dodaje więcej providerów i większą złożoność. W takiej sytuacji dedykowany store jest często prostszy w użyciu.

    Etap trzeci: Redux Toolkit slices

    W tym etapie wiele zespołów sięga po lżejsze rozwiązania, takie jak Zustand. Redux Toolkit pozostaje solidnym wyborem, gdy masz kilka powiązanych fragmentów stanu, potrzebujesz możliwości debugowania w czasie rzeczywistym oraz cenisz jedno przewidywalne źródło prawdy, a ponadto eliminuje większość zbędnego kodu, który sprawiał problemy w klasycznym Redux.

    Każdy problem staje się osobną częścią stanu. Część odpowiadająca graczowi zawiera aktualny odcinek, postęp i jakość, a także definiuje funkcje redukcji służące do zmiany odcinka i aktualizacji postępu. Zauważ, że te funkcje redukcji wydają się modyfikować state bezpośrednio; Redux Toolkit pod spodem wykorzystuje Immer, dzięki czemu takie przypisania bezpiecznie tworzą nowy, niezmienialny stan:

    // playerSlice.js
    const playerSlice = createSlice({
      name: 'player',
      initialState: {
        currentEpisode: null,
        playbackProgress: 0,
        quality: '1080p',
      },
      reducers: {
        setEpisode: (state, action) => {
          state.currentEpisode = action.payload;
        },
        updateProgress: (state, action) => {
          state.playbackProgress = action.payload;
        },
      },
    });
    

    Nestowanie zniknęło. Jedna tag <Provider store={store}> otacza aplikację, a komponenty odczytują tylko to, czego potrzebują, za pomocą useSelector. Ponieważ useSelector porównuje wybraną wartość pomiędzy kolejnymi renderingami, PlayButton wybierający poziom subskrypcji ponownie się renderuje tylko wtedy, gdy zmienia się poziom, a nie za każdym razem, gdy zmienia się postęp. Ten selektywny model subskrypcji eliminuje większość niepotrzebnych renderingów, które powstawały w wersji opartej na Context.

    Kolejną istotną zaletą jest Redux DevTools. Możliwość krok po kroku prześledzenia każdej wysłanej akcji, takiej jak naciśnięcie przycisku odtwarzania, aktualizacja postępów czy zmiana odcinka, oraz dokładne zobaczenie, jak ewoluował stan aplikacji, znacznie ułatwia diagnozowanie błędów w odtwarzaniu w porównaniu z próbą śledzenia wartości poprzez piramidę dostawców danych.

    Etap czwarty: RTK Query do zarządzania stanem serwera

    Znaczna część złożoności aplikacji wcale nie dotyczy stanu interfejsu. Chodzi tu o stan serwera: katalog, wyniki wyszukiwania, szczegóły odcinków oraz lista do oglądania przechowywane na serwerze. Tradycyjne podejście łączy funkcję useEffect z kilkoma wywołaniami useState na każdą prośbę o dane, aby ręcznie śledzić dane, proces ładowania i błędy, co często prowadzi do sytuacji konkurencyjnych i powtórnego pobierania informacji.

    RTK Query zastępuje to fragmentem API. Poniższa definicja określa adres URL bazowy oraz deklaruje dwa punkty końcowe zapytań – jeden do filtrowania anime według gatunku, a drugi do uzyskiwania szczegółów o odcinkach:

    export const catalogApi = createApi({
      reducerPath: 'catalogApi',
      baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
      endpoints: (builder) => ({
        getAnimeList: builder.query({
          query: (genre) => `/anime?genre=${genre}`,
        }),
        getEpisodeDetails: builder.query({
          query: (episodeId) => `/episodes/${episodeId}`,
        }),
      }),
    });
    

    RTK Query generuje hooka React dla każdego punktu końcowego, nazwanego tak samo jak on, który następnie eksportuje się z tego fragmentu:

    export const { useGetAnimeListQuery, useGetEpisodeDetailsQuery } = catalogApi;
    

    Komponent potem pobiera dane, stan ładowania i błędu w jednej linii:

    const { data: animeList, isLoading, error } = useGetAnimeListQuery('action');
    

    Nie ma ręcznie napisanego efektu ani manualnego flagi ładowania. Najważniejszą cechą jest cacheowanie. Jeśli użytkownik otworzy kategorię Action, przejdzie gdzie indziej i wróci, lista z cache’u pojawi się natychmiast, a RTK Query ponownie pobra dane w tle, jeśli są one przestarzałe. Identyczne żądania z różnych komponentów korzystają z jednego połączenia sieciowego.

    W przypadku wiersza „continue-watching” unieważnianie na podstawie tagów zapewnia dokładność interfejsu użytkownika: zapytania określają, jakie dane dostarczają, za pomocą providesTags, natomiast mutacje określają, co zmieniają, za pomocą invalidatesTags. Gdy uruchamiana jest mutacja aktualizująca postęp, RTK Query automatycznie ponownie ładuje dotknięte zapytania, dzięki czemu żaden komponent nie musi ręcznie inicjować ponownego pobierania danych. Tagi wymagają pola tagTypes w elemencie API slice oraz punktu końcowego dla mutacji, z których żaden nie występuje w powyższym fragmencie; nasz przewodnik po wysyłaniu danych za pomocą mutacji RTK Query omawia ten aspekt. Pamiętaj również, że reducer i middleware elementu API slice muszą zostać dodane do store’a, aby caching działał poprawnie.

    Połączenie każdego rodzaju stanu z odpowiednim narzędziem

    Dla takiej aplikacji rozsądny podział wygląda następująco:

    • Stan lokalny z użyciem useState dla wszystkiego, co nigdy nie opuszcza komponentu: pól formularza, przełączników oraz stanów „hover” i „otwarty”.
    • Context API do prostych wartości globalnych, które zmieniają się rzadko, takich jak temat czy język. Ma trudności, gdy istnieje wiele kontekstów lub wartości, które są aktualizowane często.
    • Redux Toolkit do złożonego, powiązanego ze sobą stanu klienta, takiego jak autoryzacja, poziom subskrypcji, stan odtwarzacza i lista obserwowanych elementów, który jest czytany i zapisywany przez wiele niepowiązanych ze sobą komponentów.
    • RTK Query do wszystkiego, co pochodzi z serwera, co eliminuje całą grupę błędów: przestarzałe dane, konkurencyjne żądania oraz zbędne polecenia pobierania danych.

    Powszechnym błędem jest traktowanie tego jako wyboru typu albo wszystko, albo nic – umieszczanie wszystkiego w Context lub wszystkiego w Redux. Te narzędzia rozwiązują różne problemy, a dojrzała aplikacja zazwyczaj łączy je ze sobą.

    Główne wnioski

    • Prop drilling jest sygnałem do pierwszej restrukturyzacji; należy uciekać się do stanu globalnego tylko wtedy, gdy dane są rzeczywiście udostępniane pomiędzy odległymi częściami struktury.
    • Context ponownie renderuje każdego użytkownika przy każdej zmianie, co sprawia, że nie nadaje się do przechowywania wartości wysokoczęstotliwościowych, takich jak postęp odtwarzania.
    • Ukryte zależności kolejnościowe pomiędzy dostawcami stanu stanowią ryzyko konserwacyjne, które rośnie z każdym nowym kontekstem.
    • Subskrypcje oparte na selektorach w Redux Toolkit oraz narzędzia DevTools sprawiają, że udostępniony stan klienta jest zarówno szybszy w obsłudze, jak i łatwiejszy do debugowania.
  • Zachowaj kontrolę nad stanem serwera w efektach tworzonych ręcznie: mechanizmy cache’owania i unieważniania tagów w RTK Query zajmują się aktualnością danych za ciebie, pod warunkiem że magazyn i tagi są poprawnie skonfigurowane.