Gdzie useState przechowuje swoją wartość: elementy React versus Fibers
Dlaczego komponent funkcyjny React zapomina o wszystkim pomiędzy wywołaniami, dlaczego elementy nie mogą przechowywać stanu oraz jak pole memoizedState w strukturze fiber utrzymuje wartości useState przy życiu.
Komponent funkcjonalny to po prostu funkcja, a funkcje zapominają o swoich zmiennych lokalnych w momencie zwrotu. Mimo to useState zwraca zaktualizowaną wartość przy każdym renderowaniu, jakby funkcja ją pamiętała. Ten tekst odpowiada dokładnie na jedno wąskie pytanie: gdzie fizycznie znajduje się ta wartość pomiędzy renderowaniami? Pod koniec będziesz w stanie odróżnić dwa obiekty, które React tworzy dla każdego komponentu – element i fiber – oraz wyjaśnić, który z nich przechowuje stan i dlaczego.
Zagadka: funkcja bez pamięci
Zacznijmy od najbardziej znanej komponenty, czyli licznika:
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Kliknij raz – pokaże się 1, kliknij ponownie – pokaże się 2. Nic zaskakującego, dopóki nie spojrzy się na to przez pryzmat zwykłego JavaScriptu. Counter to funkcja. Za każdym razem, gdy funkcja jest wywoływana, jej lokalne zmienne są tworzone z niczego, a po jej zakończeniu znikają. Kolejne wywołanie rozpoczyna się od zera.
Zgodnie z tą logiką, gdy React po raz drugi wywoła Counter(), linia useState(0) powinna ponownie zwrócić wartość 0. Tymczasem zwraca 1. Coś poza funkcją musi przechowywać tę wartość między kolejnymi wywołaniami. To nie może być ciało funkcji, ponieważ tak po prostu nie zachowują się funkcje. Więc co to jest?
Odrzucenie elementu
Istnieje oczywisty kandydat, który okazuje się błędny, a jego usunięcie sprawia, że prawdziwa odpowiedź staje się jaśniejsza.
Wiersz <button onClick={...}>{count}</button> to JSX. Przeglądarki nigdy nie uruchamiają JSX; kompilator taki jak Babel lub kompilator TypeScript przepisuje go na zwykłe wywołanie funkcji przed wysłaniem kodu. Koncepcyjnie wynik jest następujący:
React.createElement("button", { onClick: fn }, count)
Dzięki automatycznemu środowisku wykonywania JSX wprowadzonemu w React 17, skompilowane wywołanie to faktycznie jsx() z modułu react/jsx-runtime, a nie React.createElement, ale oba pełnią tę samą funkcję. Niezależnie od nazwy funkcji, zwraca ona zwykły obiekt:
{
type: "button",
props: { onClick: fn, children: 1 },
}
React nazywa to elementem. Jest to opis, a nie rzecz rzeczywista: krótka informacja mówiąca o tym, że tutaj powinien pojawić się przycisk z tym obsługownikiem kliknięcia i wyświetlającym tę liczbę. Prawdziwe elementy zawierają kilka dodatkowych pól, takich jak key, ref oraz wewnętrzny marker $typeof, ale żadne z nich nie przechowuje historii.
A teraz kluczowe spostrzeżenie. Każde wywołanie Counter tworzy zupełnie nowy element. Kliknij przycisk – uruchamia się Counter(), zwracany jest nowy obiekt, a poprzedni staje się bezużyteczny. Gdyby stan przechowywany był w elemencie, nie byłoby sposobu na połączenie renderowania dwóch obiektów z renderowaniem jednego, ponieważ obiekt do renderowania jednego przestaje istnieć w momencie rozpoczęcia renderowania drugiego.
To celowe podejście oparte na jednorazowym użyciu. Elementy są tanie właśnie dlatego, że są budowane od zera przy każdym renderowaniu i nie muszą być synchronizowane z niczym innym. Oznacza to również, że nie mogą znajdować się w miejscu, gdzie przechowywana jest wartość count.
Obiekt, który przetrwa: fiber
Oprócz elementów React przechowuje dla każdego zamontowanego komponentu inny, dłużej istniejący obiekt. Nie jest on odbudowywany przy każdym renderowaniu – powstaje po raz pierwszy, gdy komponent zostanie zamontowany, a następnie jest utrzymywany i aktualizowany tak długo, jak długo komponent pozostaje na ekranie. To właśnie jest fiber.
Buńki są często opisywane jako coś tajemniczego, ale na najniższym poziomie buńka to zwykły obiekt JavaScript. Nie kryje się za nią żadna specjalna struktura w czasie wykonywania. Jeśli zatrzymasz się w debuggerze wewnątrz mechanizmu synchronizacji React i przyjrzysz się jednej z nich, zobaczysz zwykły obiekt z zestawem właściwości – takiego, który sam mógłbyś stworzyć za pomocą nawiasów kwadratowych. Możesz na nie spojrzeć również z przeglądarki: React przypisuje wewnętrzne klucze do węzłów DOM, które wskazują na odpowiednie buńki, dzięki czemu narzędzia React DevTools mogą je znaleźć.
Zamiast wymieniać wszystkie pola naraz, pomocne jest budowanie buńki dla Counter po jednej właściwości. Zaraz po uruchomieniu aplikacji minimalna wersja wygląda tak:
{
type: Counter,
}
type: do kogo należy ta ewidencja
type to najprostsze pole. Określa ono, który komponent jest śledzony przez tę wiórkę. type: Counter oznacza, że obiekt ten służy do zarządzania instancją Counter, przechowując odwołanie do samej funkcji.
Elementy hosta również mają swoje wiórki. Przycisk renderowany przez Counter ma własną wiórkę, a jej type to ciąg znaków "button", a nie funkcja. Dlatego wiórka <Counter /> ma wartość { type: Counter }, natomiast wiórka <button> ma wartość { type: "button" }: to samo pole, ten sam cel – odwołuje się albo do twojego komponentu, albo do wbudowanej tagi.
type również odgrywa rolę w procesie pogodzenia stanów. Gdy React porównuje nowy element z istniejącym elementem typu fiber w tej samej pozycji, zgodny type umożliwia ponowne użycie tego elementu i jego stanu, natomiast inny type zmusza do wyrzucenia starego elementu i utworzenia nowego. Dlatego zmiana między dwoma różnymi komponentami w tym samym miejscu resetuje ich stan.
Sam jednak type nic nie mówi o tym, w jaki sposób stan jest przechowywany. Do tego potrzebny jest następny element.
memoizedState: gdzie faktycznie znajduje się liczba
useState wymaga miejsca poza funkcją, aby przechowywać aktualną wartość, ponieważ lokalne zmienne funkcji są resetowane przy każdym wywołaniu. Tym miejscem jest pole w elemencie fiber o nazwie memoizedState. „Memoized” po prostu oznacza zapamiętane: dane są przechowywane z poprzedniego wywołania zamiast być obliczane na nowo.
Dodanie tego do szkicu daje:
{
type: Counter,
memoizedState: { count: 0 },
}
To jest uproszczony obraz. W rzeczywistej implementacji memoizedState w komponencie funkcyjnym odnosi się do pierwszego elementu łączonej listy obiektów hook, po jednym na każde wezwanie hooka, a liczba 0 znajduje się w polu memoizedState samego tego hooka, a nie w obiekcie { count: 0 }. React nie wie, że twoja zmienna nazywa się count; wie tylko „wartość pierwszego hooka”. Aby zrozumieć, gdzie znajduje się stan, wystarczy ta uproszczona wersja.
Teraz następuje kliknięcie. React ponownie wywołuje Counter(). Gdy wykonanie dociera do useState(0), hook nie zwraca wartości 0 w twoim kodzie. Zamiast tego sprawdza przechowywany stan fibry, znajduje aktualną wartość (która wynosi 1 po przetworzeniu kliknięcia) i tę zwraca. Argument do useState to jedynie wartość początkowa – jest używana podczas pierwszego renderowania, a potem ignorowana. Od tego momentu źródłem prawdy jest fibra, a nie litera w ciele funkcji.
To jest pełna odpowiedź na początkowe zagadnienie. Liczba nie znajduje się w funkcji, która zapomina o wszystkim pomiędzy wywołaniami, ani w elemencie, który jest usuwany po każdym renderowaniu. Znajduje się ona w fibrze – odrębnym obiekcie, który React przechowuje i aktualizuje przy każdym renderowaniu Counter.
Dwie praktyczne konsekwencje
Ten model wyjaśnia kilka zachowań, które w przeciwnym razie wydają się arbitralne:
- Zmiana argumentu funkcji
useStatepo utworzeniu komponentu nie ma wpływu na przechowywaną wartość, ponieważ React odczytuje ją tylko raz. Jeśli musisz sformatować stan na podstawie parametrów wejściowych, zmieńkeykomponentu, aby React utworzył nową strukturę danych. - Ponieważ struktura danych jest wyszukiwana według pozycji w drzewie, stan należy do miejsca, gdzie komponent jest renderowany, a nie do definicji funkcji. Dwa elementy
<Counter />umieszczone obok siebie mają dwie oddzielne struktury danych i dwa niezależne liczniki.
Coz jeszcze zawiera struktura danych poza tymi dwoma polami
type i memoizedState wystarczają, by rozwiązać problem stanu, ale rzeczywista wiązka przesyła o wiele więcej informacji. Przechowuje odwołanie do węzła DOM, który wygenerowała, wskaźniki do jej rodzica, pierwszego dziecka i następnego brata w drzewie, aby React mógł przemieszczać się po nim, a także poprzednie wartości props do porównania z nowymi. To właśnie te pola decydują o tym, w jaki sposób React określa, co się zmieniło, oraz jak przemieszcza się po całym drzewie komponentów – co stanowi odrębny problem od tego, gdzie znajduje się dana wartość.
Tym, co można teraz jasno określić, jest granica pomiędzy tymi dwoma obiektami, ponieważ ich zatarcie jest przyczyną wielu nieporozumień dotyczących renderowania:
Element Fiber
-------- -----
Created by React.createElement Created internally by React
New object every render Same object, updated in place
Discarded right after Persists for the component's
React reads it entire mounted lifetime
Holds no history Holds memoizedState, the real
remembered value across renders
Describes what should exist Is the thing that actually exists,
with real memory attached to it
Prosty sposób, by to zapamiętać: element to żądanie skierowane do Reacta, natomiast fiber to własny zapis Reacta tego, co obecnie istnieje. Każde renderowanie tworzy nową serię tanich, pozbawionych pamięci elementów i przekazuje je drzewu fiber, które już istniało z poprzedniego renderowania i przechowuje wszystko, co musi przetrwać. Jedna strona opisuje, druga pamięta.
Jedna niewyjaśniona kwestia: fibers występują parami
Stwierdzenie, że fiber jest „aktualizowany na miejscu”, to przydatne uproszczenie, ale nie jest pełną prawdą. React faktycznie przechowuje dwie wersje każdego fiber – jedną odpowiadającą temu, co obecnie jest na ekranie, oraz drugą przygotowywaną do następnej aktualizacji – i przepina się między nimi. To rozwiązanie umożliwia Reactowi pracę nad aktualizacją bez zakłócania widocznego interfejsu i zasługuje na osobne wyjaśnienie.
Główne wnioski
- Zmienne lokalne komponentu funkcyjnego są tworzone na nowo przy każdym wywołaniu, więc sam komponent nie może przechowywać stanu.
- JSX jest kompilowane do wywołań, które tworzą elementy: proste, jednorazowe opisy odbudowywane przy każdym renderowaniu.
- Fibry to zwykłe obiekty, które istnieją przez cały czas, gdy komponent jest zamontowany;
typewskazuje, jaki komponent jest śledzony przez daną fibrę. useStateodczytuje i zapisuje wartości za pośrednictwemmemoizedStatefibry, który w rzeczywistości odnosi się do listy obiektów hooków dopasowanych według kolejności wywołań.- Początkowa wartość przekazana do
useStatema znaczenie tylko podczas montowania; po tym czasie źródłem prawdy jest fibra.
Literatura pokrewna
- Przemyślenie o stanie React: Gdzie naprawdę powinny znajdować się twoje dane — Ten artykuł wyjaśnia, jak zmniejszyć błędy w React poprzez przenoszenie stanu do URL, DOM lub wartości pochodnych zamiast nadmiernego używania useState.
- Jak brauzer rysuje i gdzie mieści się React — Dowiedz się, jak krytyczna ścieżka renderowania, proces synchronizacji, Fiber oraz planer współpracują ze sobą, aby przekształcać aktualizacje React w piksele na ekranie.
- Dlaczego istnieją reguły hooków: Fiber, listy hooków i dispatchery — Przewodnik po wewnętrznych mechanizmach React: węzły Fiber, podwójne buforowanie, kanały przesyłania danych, lista łączona hooków oraz sposób, w jaki każda z wbudowanych rodzin hooków przechowuje swój stan i planuje działania.