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ń.
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:
- Zawołuj hooki na najwyższym poziomie ciała komponentu funkcyjnego.
- Zawołuj hooki na najwyższym poziomie ciała własnego hooka.
- Nigdy nie zawołuj hooki wewnątrz warunków lub pętli.
- Nigdy nie zawołuj hooki po warunkowym wcześniejszym
return. - Nigdy nie zawołuj hooki wewnątrz obsługiwaczy zdarzeń.
- Nigdy nie zawołuj hooki w komponentach klasowych.
useEffect, useMemo lub useReducer.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:
childprowadzi do pierwszego dziecka danego włókna.siblingprowadzi do następnego włókna na tym samym poziomie.returnprowadzi 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.
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.
useStatemapuje się namountState, który tworzy nowy obiekt hooka, ustawia jego początkowy stan i dodaje go na koniec listy. - HooksDispatcherOnUpdate jest aktywny podczas ponownych renderowań.
useStatemapuje się naupdateState, który przesuwa się wzdłuż istniejącej listy (faktycznieworkInProgressHook = workInProgressHook.next), przetwarza kolejkę zaległych operacji i zwraca nowy stan.
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 wbaseQueuei 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.
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 docurrentnigdy 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ćrefjako zwykłą właściwość, więcforwardRefnie 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.memolubshouldComponentUpdate, 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ątrzstartTransition(() => 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
useEffectdo zewnętrznych magazynów danych, takich jak Redux czy Zustand. Wymaga on dwóch funkcji: jednej do rejestracji słuchacza zmian w magazynie orazgetSnapshot, 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
- Gdzie useState zachowuje swoją wartość: React Elements versus Fibers — Dlaczego komponent funkcyjny w React zapomina o wszystkim pomiędzy wywołaniami, dlaczego elementy nie mogą przechowywać stanu oraz jak pole memoizedState w fibrze utrzymuje wartości useState przy życiu.
- Typowanie hooków React: useState, useEffect, useReducer oraz custom hooks — Dowiedz się, jak prawidłowo typować useState, useEffect, useReducer oraz custom hooks w TypeScript, a także kiedy wybór TypeScript zamiast zwykłego JavaScripta faktycznie się opłaca.