Strona główna / Artykuły / Dlaczego istnieją zasady hooków: Fiber, listy hooków i dyspektorzy

Dlaczego istnieją zasady hooków: Fiber, listy hooków i dyspektorzy

Przewodnik po wewnętrznej strukturze Reacta: węzły Fiber, podwójne buforowanie, kanały, lista łączona hooków oraz to, jak każda z wbudowanych rodzin hooków przechowuje swój stan i planuje wykonywanie zadań.

2852 słów

Większość programistów React potrafi wyrecytować zasady hooków, ale znacznie mniej osób potrafi wyjaśnić, dlaczego ich naruszenie psuje stan zamiast po prostu wywołać przydatny błąd. Odpowiedź kryje się w mechanizmach wewnętrznych React: każde wezwanie hooka staje się węzłem na liście powiązanej przechowywanej w Fiber, a React znajduje każdy węzeł wyłącznie na podstawie kolejności wezwań hooków. Ten przewodnik omawia Fiber, obiekt hooka, mechanizmy dispatchingu używane przez React podczas renderowania oraz wewnętrzne zasady każdej rodziny hooków, dzięki czemu zasady przestają wydawać się arbitralne i stają się wynikiem projektu.

Nie będziesz potrzebował tej wiedzy do tworzenia formularzy czy pobierania danych. Okazuje się ona przydatna podczas debugowania przestarzałych wartości, wyboru między useEffect a useLayoutEffect lub gdy zastanawiasz się, dlaczego memoizowane elementy i tak są ponownie renderowane.

Krótka podsumowanie: co zastąpiły hooki i jakie zasady im towarzyszą

Hooks pojawiły się w React 16.8, a liczba wbudowanych hooków wzrosła do około siedemnastu. Rozwiązały one trzy długo istniejące problemy związane z komponentami klasowymi: logikę opartą na stanie trudno było ponownie wykorzystać w różnych komponentach, powiązana logika była rozproszona między metodami cyklu życia, co powodowało przeciążenie komponentów, a same klasy JavaScript (wiązanie this, zrozumienie cykli życia) myliły wielu programistów.

Razem z hookami pojawił się zestaw zasad:

  1. Zawołuj hooki na najwyższym poziomie ciała komponentu funkcyjnego.
  2. Zawołuj hooki na najwyższym poziomie ciała własnego hooka.
  3. Nigdy nie zawołuj hooki wewnątrz warunków lub pętli.
  4. Nigdy nie zawołuj hooki po warunkowym wcześniejszym return.
  5. Nigdy nie zawołuj hooki wewnątrz obsługiwaczy zdarzeń.
  6. Nigdy nie zawołuj hooki w komponentach klasowych.
  • Nigdy nie wywołuj hooków wewnątrz funkcji zwrotnych przekazywanych do useEffect, useMemo lub useReducer.
  • Nigdy nie wywołuj hooków wewnątrz bloków try, catch lub finally.
  • Naruszenia tych zasad powodują ostrzeżenia, błędy lub, co gorsza, subtelne błędy programistyczne. Krótka wyjaśnienie brzmi tak: hooki komponentu tworzą listę łączoną jednostronnie przymocowaną do węzła Fiber, który jest obiektem JavaScript z stanem przechowywanym przez React dla każdego komponentu. Aby zrozumieć, dlaczego to ma znaczenie, zacznij od samego Fiber. Jeśli już dobrze go znasz, przejdź bezpośrednio do sekcji poświęconej obiektowi hooków. Aby łatwiej zapoznać się z procesem synchronizacji i stanem, sprawdź tworzenie modelu mentalnego dla synchronizacji, stanu i hooków w React.

    React Fiber: silnik, w którym funkcjonują hooki

    Fiber to silnik rekonsylacji w React, wprowadzony w React 16 jako całkowite przepisanie sposobu, w jaki React oblicza i stosuje aktualizacje interfejsu użytkownika.

    Problem ze starym rekonsyliatorem typu stack

    Zanim pojawił się Fiber, React używał tak zwanego rekonsyliatora typu stack. Przy każdej aktualizacji przeszukiwał drzewo komponentów w sposób rekurencyjny, a rekurencyjne przeszukiwanie stosu wywołań w JavaScript nie może zostać przerwane w połowie: gdy już się rozpocznie, trwa aż do zakończenia przetwarzania całego drzewa. W przypadku dużego drzewa, które zajmowało cały główny wątek, animacje zastygały, naciśnięcia klawiszy były opóźnione, a interfejs działał nierównomiernie. Co gorsza, nie istniało sposobu na to, aby pilna aktualizacja, np. naciśnięcie klawisza, uprzedziła długie, mniej ważne procesy renderowania już trwające.

    To, co umożliwia Fiber

    Fiber to zwykły obiekt JavaScriptu reprezentujący jedną jednostkę pracy, powiązaną z instancją komponentu lub węzłem DOM. Ponieważ React sam śledzi te jednostki zamiast polegać na stosie wywołań, uzyskuje trzy zalety:

    • Pauzowanie i kontynuowanie. React może zatrzymać się w środku procesu, pozwalając przeglądarce zająć się czymś pilniejszym, takim jak dane wprowadzone przez użytkownika, a następnie kontynuować od tego samego miejsca.
    • Przyznawanie priorytetów. Pilne aktualizacje mogą przejąć pierwszeństwo przed mniej ważnymi.
    • Ponowne użycie lub odrzucenie. Jeśli użytkownik przechodzi do innej strony podczas trwania procesu renderowania, nieukończoną pracę można po prostu wyrzucić.

    Kształt węzła Fiber

    Każdy element React, czy to komponent, element DOM hostujący lub węzeł tekstowy, ma odpowiadający mu Fiber. Jest to duży obiekt zawierający właściwości komponentu, jego stan oraz odnośnik do jego reprezentacji w DOM.

    Zamiast przechowywać dzieci w tablicach, włókna tworzą drzewo za pomocą trzech wskaźników:

    • child prowadzi do pierwszego dziecka danego włókna.
    • sibling prowadzi do następnego włókna na tym samym poziomie.
    • return prowadzi z powrotem do rodzica.

    Przetwarzanie aktualizacji oznacza śledzenie tych powiązań: schodzenie w dół za pomocą child tak daleko, jak to możliwe, przemieszczanie się w bok za pomocą sibling oraz wspinanie się z powrotem za pomocą return, gdy dana gałąź zostanie zakończona. Ponieważ jest to zwykły pętla nad wskaźnikami, a nie rekurencja, React może zatrzymać się pomiędzy dowolnymi dwoma włóknami.

    Dwukrotne buforowanie z dwoma drzewami

    Pojęcie włókien zapożycza się z programowania graficznego, konkretnie z techniki dwukrotnego buforowania. W każdym momencie React przechowuje w pamięci dwa drzewa włókien:

    • Bieżące drzewo odzwierciedla dokładnie to, co jest na ekranie. React nie modyfikuje go podczas obliczania aktualizacji.
    • Drzewo w trakcie przeróbek (WIP) jest tworzone w tle, gdy coś się zmienia. React klonuje aktualne elementy, które wymagają aktualizacji, i tworzy nową wersję obok starej.

    Gdy drzewo WIP jest gotowe, React zmienia wskaźnik korzenia. Drzewo WIP staje się bieżącym, a ekran odzwierciedla nowy stan. Każdy element przechowuje wskaźnik alternate do swojego odpowiednika w drugim drzewie, dzięki czemu stan hooków jest przenoszony z jednej renderizacji na drugą.

    Faza renderowania i faza zatwierdzenia

    Ta architektura dzieli każdą aktualizację na dwie fazy.

    Faza renderowania może być przerywana. React przechodzi przez drzewo, wywołuje funkcje komponentu, uruchamia hooki i porównuje wyniki z aktualnym drzewem, jednocześnie budując w pamięci tymczasowe drzewo. Ponieważ React kontroluje pętlę przeglądania, jego planer może przekazać kontrolę przeglądarce co kilka milisekund. Jeśli użytkownik wpisuje tekst podczas trwania renderowania o niskiej priorytecie, React może je wstrzymać, obsłużyć wprowadzony tekst i wznowić renderowanie. Może nawet usunąć całe tymczasowe drzewo, gdy nowsza, bardziej pilna aktualizacja sprawi, że stanie się ono przestarzałe. Ponieważ renderowanie może zostać wykonywane kilka razy lub w ogóle nie dojść do końca, funkcje komponentu muszą być czyste – nie mogą mieć żadnych efektów ubocznych podczas renderowania.

    Faza komitowania jest synchroniczna. Gdy drzewo WIP zostanie ukończone, React aplikuje obliczone zmiany do rzeczywistego DOM-u jednorazowo. Ten krok nie może zostać wstrzymany, ponieważ przerwanie go w połowie mutacji DOM-u spowodowałoby pokazanie użytkownikowi częściowo zaktualizowanej, niespójnej interfejsu. Tutaj działają efekty układu, stąd planowane są efekty pasywne, a także dołączane są referencje.

    Szlaki: jak React decyduje, co przerwać

    Aby wiedzieć, co może przerwać co innego, React oznacza każdą aktualizację szlakiem. Szlaki są reprezentowane jako bity w maskie bitowej, co ułatwia łączenie i porównywanie priorytetów. Ogólnie rzecz biorąc:

    • Szlak synchroniczny dla dyskretnych, pilnych interakcji, takich jak kliknięcia i naciśnięcia klawiszy (ciągłe zdarzenia, takie jak przebywanie nad elementem lub przewijanie, mają swój własny szlak o wysokim priorytecie).
    • Szlaki przejściowe dla zadań, które mogą zostać przerwane: aktualizacje w tle, ponowne renderowanie na podstawie danych, zmiana kart.
  • Ponowne wypróbowanie ścieżek dla granic Suspense, które są w trakcie rozwiązywania.
  • Nigdy nie modyfikujemy bezpośrednio Fiber, a jednak stanowi ono podstawę najważniejszych funkcji React 18 i 19. Renderyzacja równoległa, Suspense, useTransition oraz useDeferredValue – wszystkie one zależą od możliwości przerwania procesu renderowania.

    Obiekt hook i dlaczego kolejność wywołań jest kluczowa

    Komponenty funkcyjne nie mają instancji do przechowywania stanu, dlatego React przechowuje go w Fiber w polu o nazwie memoizedState. Dla komponentów funkcyjnych to pole wskazuje na pierwszy hook w liście jednostronnie powiązanych elementów.

    Każde wywołanie hooka podczas renderowania odpowiada obiektowi o mniej więcej takim kształcie:

    {
      memoizedState: any,        // The internal state of the hook
      baseState: any,            // The state before any unprocessed updates
      baseQueue: Update | null,  // Updates that were skipped due to priority
      queue: UpdateQueue | null, // Circular linked list of pending state updates
      next: Hook | null          // Pointer to the next hook in the component
    }
    

    memoizedState haka przechowuje jego wartość (stan dla useState, zapis efektu dla useEffect, sparowane dane zbuforowane dla useMemo), queue przechowuje aktualizacje w kolejce, baseState oraz baseQueue rejestrują aktualizacje pominęte, ponieważ ich ścieżka nie była przetwarzana, a next odnosi się do następnego haka.

    Zwróć uwagę na to, czego brakuje: nie ma klucza ani nazwy. Podczas ponownego renderowania React po prostu przechodzi przez listę od początku, łącząc pierwsze wywołanie hooka z pierwszym węzłem, drugie z drugim i tak dalej. Jeśli hook jest wywoływany wewnątrz if a warunek się zmieni, każde kolejne wywołanie zostaje połączone z niewłaściwym węzłem, co powoduje wyciek stanu z jednego hooka do drugiego. To jest główny powód istnienia Zasad Hooków: gwarantują one, że te same hooki są wykonywane w tym samym porządku przy każdym renderowaniu. Zasady dotyczące pętli, wczesnych powrotów, bloków try oraz funkcji zwrotnych to wszystko warianty tego samego wymogu.

    Jedna nowoczesna wyjątek potwierdza tę zasadę: API use w React 19 może być wywoływane warunkowo, właśnie dlatego, że nie polega w ten sam sposób na pozycji w tej liście.

    Dispatchery: ta sama nazwa hooka, różne implementacje

    useState, który importujesz, jest jedynie cienką otoczką. W czasie wykonywania przekierowuje żądanie do dyspetera, który React zainstalował dla bieżącej fazy. Starsze wersje React udostępniają go poprzez ReactCurrentDispatcher; w nowszych wersjach dyspeter znajduje się w wewnętrznym obiekcie współdzielonym przez React, ale zasada pozostaje identyczna.

    • HooksDispatcherOnMount jest aktywny podczas pierwszego renderowania. useState mapuje się na mountState, który tworzy nowy obiekt hooka, ustawia jego początkowy stan i dodaje go na koniec listy.
    • HooksDispatcherOnUpdate jest aktywny podczas ponownych renderowań. useState mapuje się na updateState, który przesuwa się wzdłuż istniejącej listy (faktycznie workInProgressHook = workInProgressHook.next), przetwarza kolejkę zaległych operacji i zwraca nowy stan.
  • ContextOnlyDispatcher jest instalowany za każdym razem, gdy React nie renderuje komponentu. Każdy hook wywołany przez niego powoduje błąd, stąd pojawia się komunikat o „nieprawidłowym wywołaniu hooka”, gdy używamy go poza komponentem.
  • Taki układ tłumaczy również, dlaczego wywoływanie hooków wewnątrz obsługi zdarzeń nie działa: zanim obsługa zostanie uruchomiona, renderowanie już się zakończyło, a wtedy aktywny jest dispatcher powodujący błędy.

    Jak każda rodzina hooków działa wewnętrznie

    Wszystkie hooki opierają się na strukturze listy powiązanych elementów, ale znacznie różnią się tym, co przechowują oraz w jakim momencie wykonują swoje zadania.

    Hooki stanu: useState i useReducer

    Wewnętrznie useState to w rzeczywistości useReducer z wbudowanym reduktorem, który albo zwraca nową wartość, albo wywołuje funkcję aktualizacji z poprzednią wartością. Obie te metody wykorzystują ten sam model wykonania:

    • Zapisywanie. Hook przechowuje stan bazowy (ostatnią zapisaną wartość) oraz kolejkę aktualizacji, która jest cyklicznym списkiem powiązanych elementów reprezentujących czekające zmiany.
    • Rozdzielanie zadań. Wywołanie funkcji ustawiającej, na przykład setCount(c => c + 1), tworzy obiekt aktualizacji zawierający tę akcję, dodaje go do kolejki i oznacza wątek jako wymagający przetworzenia poprzez przydzielenie mu pasma (starsze wersje używały do tego celu czasów wygaśnięcia).
    • Rozwiązywanie. Podczas następnego renderowania React przegląda kolejkę i aplikuje każdą akcję po kolei, aby utworzyć nowy memoizedState. Aktualizacje, których pasmo nie jest uwzględnione w bieżącym renderowaniu, są przechowywane w baseQueue i wykonywane później, co zapewnia zachowanie kolejności pomimo różnych priorytetów.

    To także powód, dla którego funkcje aktualizujące są bezpiecznym wyborem, gdy następny stan zależy od poprzedniego: są one stosowane kolejno w odniesieniu do stanu, który kolejkowa struktura obliczyła dotychczas.

    Haki efektów: useInsertionEffect, useLayoutEffect i useEffect

    Każdy hak efektów przechowuje rekord efektu w swoim stanie, zawierający funkcję konfiguracji, funkcję czyszczenia oraz tablicę zależności. Rekordy te są również łączone w osobistej liście w updateQueue struktury fiber, a efekty, których zależności uległy zmianie, są oznaczone, aby faza realizacji wiedziała, które z nich należy uruchomić. Te trzy haki różnią się momentem wykonywania:

    • useInsertionEffect jest wykonywany przed efektami układu, przed jakimkolwiek kodem, który może odczytywać strukturę układu. Istnieje on dla bibliotek CSS-in-JS, które muszą wcześnie wstrzyknąć reguły <style>, aby style były już gotowe w momencie pomiaru układu, unikając tym samym wielokrotnych obliczeń stylów.
    • useLayoutEffect jest wykonywany synchronicznie po tym, jak React zmienił DOM, ale przed tym, zanim przeglądarka zdąży nadać mu kolor. Główny wątek jest zablokowany, dopóki efekt i jego czyszczenie się nie zakończą, więc nadaje się do pomiaru i dostosowywania węzłów DOM przed tym, zanim użytkownik cokolwiek zobaczy, ale nie do zadań bardziej obciążających.
  • useEffect jest pasywny. Zazwyczaj wykonywany jest po narysowaniu przez przeglądarkę, planowany za pomocą schedulera React (który wykorzystuje MessageChannel, a w razie problemów setTimeout), więc nie opóźnia aktualizacji wizualnej. Należy pamiętać, że gdy aktualizacja pochodzi od bezpośredniego wprowadzenia danych przez użytkownika, React może wykonać pasywne efekty przed narysowaniem.
  • Haki dotyczące wydajności: useMemo i useCallback

    Są to bufory, które unikają kosztownych ponownych obliczeń lub utrzymują referencje stabilne pomiędzy renderami.

    • Zapisywanie. Hak przechowuje parę: zbuforowaną wartość oraz tablicę zależności, na podstawie której została obliczona.
    • Wykonywanie. Podczas ponownego renderowania React porównuje każdą nową zależność z tą z pamięci podręcznej za pomocą Object.is. Jeśli wszystko się zgadza, funkcja fabryczna nie jest wywoływana, a zwracana jest wartość z pamięci podręcznej. Jeśli coś się różni, React wywołuje funkcję fabryczną, przechowuje nową wartość oraz zależności i zwraca wynik.
    • useCallback jest równoważny useMemo(() => fn, deps): przechowuje obiekt funkcji, który przesłałeś, zamiast wartości uzyskanej po jej wywołaniu. W oryginalnym kodzie Reacta jest implementowane oddzielnie, ale zachowanie jest takie samo.

    Ponieważ porównanie jest powierzchowne, zależność w postaci świeżo utworzonego obiektu lub tablicy przy każdym renderowaniu całkowicie unieważnia funkcję cache’owania.

    Hooki do wartości zmiennych: useRef i useImperativeHandle

    Refs przechowują informacje, które nie są używane do renderowania, takie jak węzeł DOM lub identyfikator timeoutu.

    • useRef to chyba najprostszy hook w całym kodzie. Podczas montowania tworzy { current: initialValue } i przechowuje go jako stan hooka; każde kolejne renderowanie zwraca dokładnie ten sam obiekt. Zmiany wprowadzane do current nigdy nie wpływają na kolejkę aktualizacji ani na odpowiednie ścieżki, więc nie wywołują renderowania.
    • useImperativeHandle umożliwia dostosowanie tego, co widzi rodzic poprzez ref, poprzez dołączenie własnych metod do niego. Wewnętrznie zachowuje się jak useLayoutEffect: jest wykonywany synchronicznie podczas komitowania, dzięki czemu handle jest gotowy zanim uruchomią się efekty rodzica. W React 19 komponenty funkcyjne mogą otrzymywać ref jako zwykłą właściwość, więc forwardRef nie jest już konieczny do jego użycia.

    Hook kontekstu: useContext

    useContext wyróżnia się tym, że nigdy nie zajmuje miejsca na liście hooków.

    • Czytanie. Odczytuje wartość od najbliższego pasującego Providera znajdującego się powyżej komponentu i zapisuje ten kontekst w liście zależności fibera.
    • Rozprzestrzenianie. Gdy wartość Providera ulega zmianie, React szuka poniżej niego fiberów, których zależności obejmują ten kontekst, i planuje ich ponowną renderizację. Dzieje się tak nawet wtedy, gdy komponent pośredni unika przetwarzania za pomocą React.memo lub shouldComponentUpdate, dlatego memoizacja rodzica nie chroni odbiorców kontekstu.

    Hooks współbieżne: useTransition i useDeferredValue

    To połączenie umożliwia kontrolę nad systemem torów, co pozwala przerywać długie procesy renderowania.

    • useTransition zwraca wartość [isPending, startTransition]. Aktualizacje dokonane wewnątrz startTransition(() => setQuery(text)) otrzymują ścieżkę przejścia zamiast priorytetowej. Jeśli podczas renderowania przejścia nastąpi kliknięcie lub naciśnięcie klawisza, React porzuca drzewo WIP, obsługuje pilną aktualizację, a następnie rozpoczyna przejście od nowa.
    • useDeferredValue otacza wartość, a nie funkcję ustawiania. React faktycznie przechowuje dwie wersje: najpierw renderuje z poprzednią wartością, aby ekran pozostał responsywny, a następnie planuje render w tle o niskim priorytecie z nową wartością.

    Hooki specjalistyczne: useId i useSyncExternalStore

    Część hooków rzadko pojawia się w kodzie aplikacji, ale jest niezbędna dla autorów bibliotek.

    • useId zapobiega niezgodnościom przy hydratacji w renderowaniu po stronie serwera. Wykorzystuje on ID uzyskane na podstawie pozycji komponentu w drzewie. Ponieważ kształt drzewa jest taki sam na serwerze i w przeglądarce podczas początkowej hydratacji, ID są zgodne bez konieczności używania globalnego licznika.
    • useSyncExternalStore zastępuje ręcznie pisane subskrypcje useEffect do zewnętrznych magazynów danych, takich jak Redux czy Zustand. Wymaga on dwóch funkcji: jednej do rejestracji słuchacza zmian w magazynie oraz getSnapshot, która zwraca aktualną wartość magazynu. React odczytuje ten obraz podczas renderowania, a jeśli wartość magazynu ulegnie zmianie w trakcie renderowania, przeprowadza synchroniczne ponowne renderowanie, dzięki czemu żadna część interfejsu nie pokazuje innej wersji magazynu niż inne części. To zapobiega problemom związanych z różnicami w danych, które mogłyby powstać przy równoczesnym renderowaniu.

    Główne wnioski

    • Stan hooka to lista powiązana w obrębie fiber, określana wyłącznie kolejnością wywołań; każda z reguł hooków ma na celu utrzymanie tej samej kolejności podczas każdego renderowania.
    • Fiber przekształca proces renderowania w pętlę, którą można przerwać, a podwójne buforowanie umożliwia Reactowi przygotowanie nowego drzewa bez ingerencji w to, co jest na ekranie.
    • Faza renderowania może być wykonywana wielokrotnie i musi pozostać czysta; faza commit jest wykonywana raz, synchronicznie, i to w niej uruchamiane są efekty.
    • Dyspetycy wyjaśniają zarówno podział na montaż i aktualizację, jak i błąd występujący, gdy hook jest wywoływany poza procesem renderowania.
    • Znajomość tego, gdzie każdy hook przechowuje swoje dane oraz kiedy jest wykonywany, ułatwia wybór odpowiedniego momentu uruchomienia efektów, zapewnia skuteczność mechanizmu memoizacji oraz pomaga w rozumieniu kontekstu i jednoczesnych aktualizacji.

    Literatura pokrewna

  • Minimalny hook useFetch: Kiedy można pominąć React Query i jego ograniczenia — Stwórz mały hook useFetch z buforowaniem, wykorzystujący AbortController oraz mechanizm TTL, wraz z towarzyszącym mu hookiem useMutation, i dowiedz się dokładnie, jakie funkcje React Query rezygnujesz z ich użycia.