Strona główna / Artykuły / Śledzenie wywołania React setState z kolejki aktualizacji do zatwierdzenia w DOM

Śledzenie wywołania React setState z kolejki aktualizacji do zatwierdzenia w DOM

Prześledź krok po kroku aktualizację stanu w React, od kolejki aktualizacji Hooków, przez planer, fazę renderowania, korelację i zapisanie zmian, aby zrozumieć, dlaczego stan nigdy nie zmienia się natychmiastowo.

2432 słów

Prawie każdy deweloper React w pewnym momencie pisze funkcję do ustawiania stanu, a następnie wywołuje console.log, i jest zaskoczony, gdy widzi wydrukowaną starą wartość. Nazwa setState sugeruje natychmiastową przypisanie, ale React tak nie działa: wywołanie tej funkcji rejestruje prośbę o nowy stan w przyszłości, a zanim cokolwiek pojawi się na ekranie, przechodzi cały szereg operacji. Ten artykuł opisuje pojedynczą aktualizację w tym procesie – od kolejki aktualizacji Hooka, przez planowanie, renderowanie, porównywanie stanów aż po fazę zapisu. Gdy już potrafisz sobie wyobrazić każdy etap, kilka zachowań, które na początku wydają się dziwne, staje się przewidywalnych: dlaczego stan wydaje się asynchroniczny, dlaczego kilka aktualizacji może zostać połączonych w jedno renderowanie, dlaczego React czasami pomija niektóre operacje oraz dlaczego istnieją aktualizacje funkcyjne.

Kod, który zaskakuje wszystkich

Załóżmy przegląd kodu, w którym to wygląda zupełnie rozsądnie:

setCount(count + 1);

console.log(count);

Oczekuje się, że konsola pokaże zwiększoną wartość. Pokazuje natomiast poprzednią. Oto ta sama sytuacja wewnątrz pełnego komponentu:

function Counter() {
    const [count, setCount] = useState(0);

    const handleClick = () => {
        setCount(count + 1);
        console.log(count);
    };

    return (
        <button onClick={handleClick}>
            {count}
        </button>
    );
}

Po pierwszym kliknięciu wielu programistów oczekuje zobaczenia:

1

To, co faktycznie pojawia się w konsoli, to:

0

Powodem jest to, że React jeszcze nie wyrenderował się ponownie. W momencie wykonania console.log React otrzymał jedynie żądanie. Nic z kolejki nie zostało jeszcze zastosowane, funkcja komponentu nie została ponownie wywołana, a DOM się nie zmienił. Kod, który uruchamiasz, należy do bieżącego renderowania, a w ramach tego renderowania count to zwykła stała, która została ustalona podczas pierwszego wywołania funkcji. Nic nie może jej przypisać nowej wartości.

To jest pierwsza zmiana w modelu mentalnym: funkcja setter nie modyfikuje zmiennej stanu, którą odczytujesz. Ona jedynie planuje zadanie do wykonania przez React później.

Co przechowuje React, gdy wywołujesz setter

Gdy piszesz:

setCount(count + 1);

Może się wydawać, że React wewnętrznie robi coś takiego:

count = count + 1

W rzeczywistości tak nie jest. React tworzy obiekt aktualizacji i dodaje go do kolejki należącej do danego Hooka. Koncepcyjnie sytuacja po kliknięciu wygląda w ten sposób:

Current State
      |
      ▼
count = 0
      |
      ▼
User Clicks
      |
      ▼
setCount(1)
      |
      ▼
Update Queue
[ Update: 1 ]

Sam stan pozostaje nietknięty. React jedynie zapisuje sobie notatkę: następnym razem, gdy będą przetwarzane aktualizacje dla tego Hooka, count powinien stać się 1.

Dlaczego aktualizacje przechodzą przez kolejkę

Kolejka ma sens, gdy kilka aktualizacji przychodzi w krótkim czasie od siebie. Rozważmy przetwarzacz, który wywołuje setter trzy razy:

const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
};

Ktoś nowy w Reactzie może oczekiwać, że jeden kliknięcie spowoduje:

count = 3

Rzeczywisty wynik jest taki:

count = 1

Wszystkie trzy wywołania zostały dokonane podczas tego samego renderowania, a to renderowanie pokazało:

count = 0

Zatem każde count + 1 daje ten sam wynik, a React otrzymuje trzy identyczne żądania:

setCount(1)
setCount(1)
setCount(1)

Kolejka zawiera zatem trzy wpisy, które wszystkie mówią „ustaw na 1”:

[1]
[1]
[1]

Przetwarzanie ich w kolejności nadal kończy się tak samo:

1

a nie inaczej:

3
Ta sama logika obowiązuje, gdy wartości się różnią. Załóżmy, że podczas renderowania wywołane zostaną te trzy funkcje:

setCount(1)
setCount(6)
setCount(4)

Kolejka będzie wtedy zawierać:

[1]
[6]
[4]

Każda pozycja całkowicie zastępuje poprzedni stan, więc po przetworzeniu istotny jest tylko ostatni, a wynik wynosi 4. Zwykłe wartości służą do zastępowania, a nie jako instrukcje umożliwiające rozwijanie tego, co było wcześniej. Właśnie z tego powodu istnieją aktualizacje funkcyjne.

Aktualizacje funkcyjne obliczają wynik na podstawie najnowszego stanu

Zmień teraz obsługę tak, aby każde wezwanie przekazywało funkcję:

const handleClick = () => {
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
};

Tym razem kolejka zawiera coś bardziej przypominającego trzy małe programy niż trzy wartości:

prev => prev + 1
prev => prev + 1
prev => prev + 1

Gdy React przetwarza kolejkę, przekazuje wynik każdej funkcji do następnej:

0 → 1
1 → 2
2 → 3

a ostateczny stan jest następujący:

count = 3

Różnica polega na tym, że funkcja aktualizacji nie przechwytuje wartości z bieżącego renderowania. Opisuje ona, jak wywnioskować następny stan na podstawie stanu, jaki ma React w momencie przetwarzania danej pozycji. Używaj tej formy zawsze wtedy, gdy nowy stan zależy od poprzedniego, szczególnie gdy kilka aktualizacji może być ułożonych w kolejce lub gdy aktualizacja ma miejsce wewnątrz funkcji zwrotnej utworzonej podczas starszego renderowania.

Pełny cykl życia jednej aktualizacji

Biorąc pod uwagę kolejkę, prześledź pojedynczy kliknięcie, które wywołuje:

setCount(count + 1);

Spojrzenie w uproszczeniu na wszystko, co dzieje się dalej:

User Click
     |
     ▼
Create Update
     |
     ▼
Place Update Into Queue
     |
     ▼
Notify React Scheduler
     |
     ▼
Schedule Render
     |
     ▼
Render Phase
     |
     ▼
Reconciliation
     |
     ▼
Commit Phase
     |
     ▼
DOM Updated

Poniższe sekcje omawiają każdy etap.

Krok 1: tworzy się rekord aktualizacji

Wywołanie setCount(1) nigdy bezpośrednio nie powoduje ponownego renderowania niczego:

setCount(1);

React tworzy rekord aktualizacji, który można sobie wyobrazić jako notatkę głoszącą:

Apply this update later.

To rekord jest powiązany z wewnętrznym stanem Hooka. Dane Hooka znajdują się obok Fiber komponentu – wewnętrznego węzła, który React przechowuje dla każdej instancji komponentu – a kolejka aktualizacji również tam się znajduje:

Fiber
   |
   └── useState
           |
           ├── Current State
           └── Update Queue

To także powód, dla którego Hooki muszą być wywoływane w tym samym porządku przy każdym renderowaniu: React znajduje stan i kolejkę każdego Hooka na podstawie jego pozycji na tej liście.

Krok 2: React planuje wykonywanie zadań

Mając do dyspozycji aktualizację, React musi zdecydować, kiedy ją przetworzyć. To jest zadanie planera. React niekoniecznie renderuje treść natychmiast po wywołaniu funkcji setter; balansuje między szybkością reakcji a unikaniem niepotrzebnego obciążenia.

Wyobraź sobie użytkownika szybko piszącego w polu tekstowym:

A
AB
ABC
ABCD
ABCDE

Renderyzowanie kosztownych elementów interfejsu użytkownika synchronicznie po każdym naciśnięciu klawisza, bez możliwości ustalania priorytetów, sprawiłoby, że aplikacje o dużej objętości wydawałyby się powolne. Planowanie pozwala Reactowi łączyć aktualizacje, które przychodzą jednocześnie, a dzięki funkcjom równoległym, takim jak przejścia, traktować pilne aktualizacje, np. te związane z wprowadzanym tekstem, inaczej niż mniej pilne, np. listę filtrowanych wyników. Ta możliwość stanowi istotny element przyczyniły się do tego, że architektura Reacta skupia się na planowanych, a nie natychmiastowych aktualizacjach.

Krok 3: faza renderowania uruchamia komponent ponownie

Gdy React decyduje, że nadszedł czas na przetworzenie oczekujących aktualizacji, rozpoczyna nowy proces renderowania. Renderyzowanie polega po prostu na ponownym wywołaniu funkcji komponentu:

function Counter() {
    const [count, setCount] = useState(0);

    return <h1>{count}</h1>;
}

Ważnym szczegółem jest to, co robi useState podczas tej wywołania. Zanim zwróci wartość, przetwarza kolejki aktualizacji w odniesieniu do poprzedniego stanu. Począwszy od:

Previous State = 0

React przegląda kolejkę:

Queue:
[ +1 ]Process QueueResult:
1

a wartość, którą useState zwraca, jest teraz:

count = 1

którą komponent wykorzystuje do tej renderizacji. Dlatego nowa wartość staje się widoczna dopiero w następnej renderizacji – jest obliczana tam, a nie w momencie wywołania funkcji ustawiającej.

Krok 4: tworzony jest nowy drzewo elementów

Rozruch komponentu zwraca świeże drzewo elementów React. W porównaniu z poprzednią renderizacją wygląda ono w ten sposób:

Previous Render
<h1>0</h1>

New Render
<h1>1</h1>

DOM przeglądarki nadal nie został zmodyfikowany. W tym momencie React posiada jedynie zaktualizowany plan zamierzonej interfejsu użytkownika.

Krok 5: proces porównywania znajduje różnice

Następnie React porównuje poprzedni wynik:

Old Tree

z nowym:

New Tree

sprawdzając, co dokładnie się zmieniło. W tym przykładzie, przed:

Before:
<h1>0</h1>

i po:

After:
<h1>1</h1>

Tylko tekst wewnątrz nagłówka się różni, więc to jedyna zmiana, którą rejestruje React. To właśnie to porównanie oznacza termin „rekoncyliacja”. Aby lepiej zrozumieć, jak React decyduje, co zachować, a co zastąpić, zapoznaj się z budową modelu mentalnego dla rekoncyliacji, stanu i Hooków w React.

Krok 6: faza komitowania wpływa na DOM

Gdy lista zmian jest już znana, React przechodzi do fazy komitowania i aplikuje je w rzeczywistym DOM:

DOM Before
<h1>0</h1>

DOM After
<h1>1</h1>

Dopiero teraz użytkownik widzi nową wartość. To właśnie ten moment wyobrażają sobie większość ludzi, gdy korzystają z funkcji setter, choć jest to ostatni etap w całym procesie.

Dlaczego React nie aplikuje aktualizacji natychmiast

Rozważmy funkcję obsługującą, która jednocześnie aktualizuje kilka elementów stanu:

setCount(c => c + 1);
setLoading(false);
setUser(data);

Gdyby każde wezwanie wywoływało własne renderowanie, otrzymalibyśmy:

Render 1
Render 2
Render 3

To oznacza trzy operacje renderowania, z których dwie są bezużyteczne, a ponadto mogą pojawić się ekrany pośrednie pokazujące niespójne kombinacje stanu. Zamiast tego React grupuje aktualizacje. Wezwania są układane w kolejkę:

Update
Update
Update

a następnie przetwarzane razem:

      |
      ▼Single Render

Budowanie grup zadań zapobiega zbędnym renderom i gwarantuje, że użytkownik zawsze widzi spójny stan końcowy. To główny powód, dla którego React woli planowanie zadań zamiast wykonywania każdej aktualizacji natychmiast. React może również całkowicie pominąć pewne operacje: jeśli aktualizacja generuje wartość identyczną z obecną, według oceny funkcji Object.is, React może zrezygnować z ponownego renderowania dzieci komponentu.

Jak to wygląda w praktyce

Weźmy pole wyszukiwania, w którym każde naciśnięcie klawisza aktualizuje trzy elementy stanu:

query
results
loading

Naturalne jest założenie, że każdy z tych mechanizmów aktualizacji powoduje oddzielny render. Profilowanie takiego komponentu zazwyczaj pokazuje coś innego: aktualizacje dokonane razem w ramach tego samego zdarzenia są grupowane, więc komponent renderuje się rzadziej, niż sugerowałoby proste przeczytanie kodu. Zadaniem Reacta nie jest tylko aplikowanie aktualizacji, ale także ich efektywne wdrażanie.

Gdy kilka komponentów ma niezrealizowane aktualizacje

Aktualizacje nie ograniczają się do jednego komponentu. Rozważmy taki drzewo:

App
 ├── Header
 ├── Sidebar
 └── Dashboard

Jeśli kilka aktualizacji trafia do różnych części tego drzewa, React nie musi ślepo odbudowywać wszystkiego. Drzewo Fiber umożliwia Reactowi śledzenie:

  • kterych komponentów wymagają działania
  • gdzie w drzewie pochodzi każda aktualizacja
  • których poddrzew należy odwiedzić, a które można pominąć

To właśnie pozwala Reactowi być znacznie bardziej selektywnym niż podejście „ponownego renderowania całej aplikacji przy każdej zmianie”. Należy zauważyć, że komponent, który się ponownie renderuje, domyślnie nadal renderuje swoje dzieci; to memoizacja umożliwia pominięcie niezmienionych poddrzew.

Ponowne spojrzenie na przestarzałe console.log

Z powrotem do fragmentu z początku:

setCount(count + 1);
console.log(count);

Konsola wyświetla:

0

Ponieważ kod nadal jest wykonywany w ramach bieżącego renderowania. Kolejka nie została przetworzona, następne renderowanie jeszcze się nie wydarzyło, a aktualizacja czeka na swoją kolej. Dokładniejszy opis:

Current Render
count = 0

Request Update
Future Render
count = 1

Jeśli potrzebujesz nowej wartości natychmiast, oblicz ją w lokalnej zmienniej i użyj jej, albo odczytaj ją w następnym renderowaniu lub w efekcie, który od niej zależy. Patrząc w ten sposób, zachowanie wcale nie jest dziwne.

Jak połączone są te elementy

Wszystkie te etapy tworzą jedną łańcuchową sekwencję:

  • Stan jest przechowywany razem z danymi Hook komponentu.
  • Dane Hook są przypisywane do Fiber komponentu.
  • Wezwanie funkcji setter tworzy aktualizację.
  • Aktualizacje są dodawane do kolejki Hooka.
  • Schedulator wybiera odpowiedni moment na ich przetworzenie.
  • Faza renderowania ponownie wywołuje komponent i aplikuje zawartość kolejki.
  • Rozrachunek porównuje nowe drzewo elementów z tym starym.
  • Faza komitowania aplikuje różnice do DOM-u.
  • Koncepcje, które często są omawiane oddzielnie, w rzeczywistości stanowią kolejne kroki w jednym procesie. Mówiąc krótko: wywołanie funkcji setter pozostawia stan bez zmian i zamiast tego prosi o zaplanowane aktualizowanie, które React realizuje podczas późniejszego renderowania, porównuje je z poprzednim wynikiem i komituje do DOM-u tylko tam, gdzie występują różnice.

    Główne wnioski

    • Funkcja setter nigdy nie modyfikuje stanu na miejscu; zamiast tego umieszcza aktualizację w kolejce, aby React mógł nią zająć się później.
    • Aktualizacje są kolejkowane według Hooków, więc kilka z nich może zostać przetworzonych podczas jednego renderowania.
    • Zwykłe wartości zastępują stan; funkcje aktualizujące wyprowadzają następny stan z najnowszego, co zapobiega użyciu przestarzałych wartości.
    • React planuje wykonywanie zadań zamiast renderowania przy każdym wezwaniu, co umożliwia grupowanie operacji i ustalanie priorytetów.
    • Renderowanie i aktualizacja DOM to oddzielne etapy: faza renderowania tworzy opis, a faza commit zmienia zawartość przeglądarki.
    • Kolejka aktualizacji znajduje się razem ze stanem Hook w strukturze Fiber komponentu.

    Traktowanie funkcji setState jako żądania zamiast polecenia to niewielka zmiana sformułowania, która ma ogromne konsekwencje. To właśnie dzięki temu możliwe jest grupowanie operacji, planowanie ich wykonywania, synchronizacja stanu oraz jednoczesne renderowanie. Naturalnym następnym pytaniem jest to, jak React decyduje, czy zaimprowizowane aktualizacje powodują jeden proces renderowania, czy wiele, co jest tematem automatycznego grupowania operacji wprowadzonego w React 18.