Strona główna / Artykuły

Dlaczego setState nigdy nie odświeża interfejsu natychmiast: planowanie aktualizacji w React

Śledź aktualizację stanu w React od obiektu z kolejki aktualizacji, przez oznaczanie ścieżek i planowanie, aż do pętli pracy od korzenia, która pomija nienaruszone poddrzewa.

Wydawca: REACTAPP.TOP8 min czytania1762 słów

Źródła: The Fiber Architecture In React Part 3: What Actually Happens When You Call setState

Prawie każdy deweloper React ma ciche założenie: wywołanie funkcji do zmiany stanu, takiej jak setCount(count + 1), powoduje natychmiastową ponowną renderizację komponentu, zanim zostanie wykonywana następna linia kodu. W rzeczywistości tak nie jest, a różnica czasowa pomiędzy wywołaniem a ponowną renderizacją tłumaczy zasady grupowania operacji, dlaczego niepowiązane komponenty pozostają nietknięte oraz dlaczego odczyt stanu tuż po jego zmianie zwraca stary wartość. Ten artykuł śledzi pojedynczą aktualizację w ramach architektury fiber React, od momentu jej zapisania do chwili, gdy nowa wartość trafia do stanu komponentu, aby można było zrozumieć, co faktycznie robią funkcje do zmiany stanu.

Komponent pod mikroskopem

Minimalny licznik wystarcza, aby pokazać cały mechanizm. Przechowuje jeden element stanu i zwiększa go po kliknięciu.

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Wywołanie setCount(count + 1) brzmi jak zwykła funkcja synchroniczna, więc naturalne jest wyobrażenie sobie, że React ponownie wywołuje Counter() jako bezpośredni skutek tej linii kodu. Nic takiego się jednak nie dzieje. Funkcja setter zwraca się bez wyświetlania niczego. Zamiast tego rejestruje prośbę i prosi o wykonanie zadania później.

Pierwszy krok: setter rejestruje aktualizację

Gdy uruchamia się setCount(1), React alokuje w pamięci mały obiekt aktualizacji opisujący tę prośbę. Koncepcyjnie wygląda to tak:

{
  action: 1,
  next: null,
}

Pole action przechowuje to, co przekazano, w tym wartość 1. Pole next istnieje, ponieważ jedna aktualizacja rzadko stanowi całą historię. Wywołaj funkcję ustawiającą trzy razy w obrębie jednego obsługiwanego zdarzenia, a React utworzy trzy obiekty aktualizacji połączone ze sobą za pomocą next, tworząc małą listę przypisaną do komponentu. W rzeczywistym kodzie źródłowym React aktualizacja zawiera również informację o priorytecie („lane”) i lista zaległych aktualizacji jest przechowywana jako struktura cykliczna, ale istotą jest kolejka zamiarów, a nie ponowne renderowanie.

W uproszczonym modelu fibra aktualizacja jest przypisywana w ten sposób:

{
  type: Counter,
  memoizedState: { count: 0 },   // still 0, untouched
  updateQueue: { action: 1, next: null },   // the note, just added
}

Zwróć uwagę na to, co się nie zmieniło. Zapisany stan nadal wynosi 0, na ekranie nadal wyświetla się 0, a funkcja Counter() nie została jeszcze wywołana. Jedyną różnicą jest to, że obiekt teraz czeka w kolejce. (Dla dokładności: dzięki hukom React przechowuje kolejkę każdego huka w pozycji tego huka na liście huków we włóknie, a nie bezpośrednio w polu o nazwie updateQueue; memoizedState w funkcjonalnym komponencie we włóknie to właśnie ta lista huków. Powyższy uproszczony model służy do nauki i zachowuje zachowanie istotne w tym przypadku.)

To również tłumaczy klasyczne zaskoczenie. Jeśli zapiszesz wartość count zaraz po wywołaniu setCount, zobaczysz starą wartość, ponieważ funkcja komponentu, która przechowywała referencję do count, jeszcze nie została ponownie uruchomiona.

Krok drugi: scheduleUpdateOnFiber oznacza ścieżkę i żąda wykonania zadań

Zaraz po umieszczeniu aktualizacji w kolejce, React wywołuje wewnętrzną funkcję o nazwie scheduleUpdateOnFiber. Ma ona dwie oddzielne funkcje, a ich rozdzielenie ułatwia zrozumienie reszty procesu.

Oznaczanie ścieżki do korzenia

Najpierw React przemieszcza się w górę od fibra Counter, przechodząc przez każdy wskaźnik rodzica, aż dotrze do korzenia drzewa. U każdego przodka po drodze ustawia pole o nazwie childLanes, które w praktyce oznacza „coś poniżej mnie ma niewykonane zadania”. Sam fiber Counter posiada powiązane pole lanes, które oznacza „mam również niewykonane zadania”.

Te pola to nie zwykłe wartości logiczne. Są to maski bitowe, które kodują priorytet, co jest osobnym tematem; zobacz jak React lanes wykorzystuje maski bitowe do kodowania priorytetu aktualizacji. W ramach tego przewodnika wystarczy wiedzieć, że zmieniają się z pustych na niepuste, tworząc ślad od zaktualizowanej komponenty aż na górę.

Odkładanie rzeczywistego renderowania

Z drugiej strony React wymaga, aby praca renderowania została wykonaana wkrótce, a nie wewnątrz obsługiwanego przez Ciebie zdarzenia. Dokładny mechanizm zależy od priorytetu. Zadania obsługiwane przez planer Reacta są zazwyczaj wysyłane za pomocą MessageChannel (z setTimeout jako alternatywą w środowiskach, które tego nie mają), więc są wykonywane później, w kolejnym cyklu pętli zdarzeń. W przypadku oddzielnych zdarzeń o wysokim priorytecie, takich jak kliknięcia, nowoczesne wersje Reacta zazwyczaj wykonują te zadania jako mikrozadanie zaraz po zakończeniu obsługi zdarzenia, co nadal następuje po Twoim kodzie, ale szybciej. Dokładna strategia zmieniała się pomiędzy poszczególnymi wersjami, więc traktuj te szczegóły jako specyficzne dla danej wersji.

Niezależnie od kanału, konsekwencja jest taka sama: renderowanie jest celowo odkładane aż do momentu, gdy funkcja obsługi zakończy swoją pracę. To właśnie umożliwia grupowanie operacji. Trzy wywołania setCount w jednej funkcji obsługi powodują utworzenie trzech zadań do wykonania w kolejce, a pojedyncza późniejsza procedura renderowania przetwarza je wszystkie razem, zamiast wykonywać renderowanie trzy razy.

Krok trzeci: renderowanie zawsze rozpoczyna się od korzenia

Gdy w końcu zostanie wykonywana zaplanowana praca, nie zaczyna się ona od Counter. Zaczyna się od korzeniowej wiązki, specjalnego węzła znajdującego się na szczycie całej aplikacji, który został utworzony podczas wywołania ReactDOM.createRoot. Dzieje się tak niezależnie od tego, jak głęboko w drzewie pochodzi aktualizacja.

Początek od korzenia może wydawać się kosztowny, ale nie oznacza to konieczności odwiedzenia każdej węzła. Ścieżka childLanes ustawiona na drugim kroku jest dokładnie tym, co sprawdza algorytm. Przy każdym węźle React sprawdza, czy poddrzewo poniżej ma jakieś nierozwiązane zadania. Jeśli nie ma ich, React przechodzi dalej i pomija całe poddrzewo, nie schodząc do jego wnętrza. Przodkowie na zaznaczonej ścieżce są przetwarzani szybko, a badane są tylko gałęzie prowadzące do nierozwiązanych zadań.

Pętla, a nie rekurencja

Przechodzenie przez drzewo jest realizowane za pomocą pętli, a nie głębokiej rekurencji:

let node = rootFiber;
while (node !== null) {
  node = processNode(node);   // moves to node.child, node.sibling, or up via node.return
}

Każda wiązka przechowuje trzy wskaźniki: child do jej pierwszego potomka, sibling do następnej wiązki na tym samym poziomie oraz return do jej rodzica. Te trzy powiązania wystarczą, aby krok po kroku przechodzić do potomków, do sąsiadujących wiązek oraz z powrotem, gdy dany gałąź zostanie zakończona. Nie ma tablicy potomków do iteracji. Ponieważ pozycja w drzewie jest przechowywana w zmiennej, a nie na stosie wywołań, React może zawiesić pracę pomiędzy poszczególnymi operacjami i wznowić ją później, co stanowi podstawę równoległego renderowania.

Dlaczego niepowiązane komponenty pozostają nietknięte

To jest mechanizm stojący za efektem, który prawdopodobnie już zaobserwowaliście. Wpisywanie tekstu do pola wejściowego aktualizuje tylko tę część drzewa, która odpowiada temu stanowi. Odległy, niespowiązany komponent nie jest ponownie renderowany ani nawet odwiedzany, ponieważ żadna aktualizacja nie oznaczyła jego gałęzi. To pomijanie nie jest specjalną optymalizacją dodaną później – wynika bezpośrednio z śledzenia ścieżki childLanes.

Pamiętajcie o ograniczeniach tego podejścia. Komponent znajdujący się na oznaczonej ścieżce, który sam ma niewykonane zadania, zostanie ponownie renderowany, a domyślnie tak samo jak wszystkie jego potomki, niezależnie od tego, czy są oznaczone, czy nie, chyba że zostały zapisane w pamięci. Ścieżka podpowiada React, dokąd iść; nie powstrzymuje ona jednak renderującego się rodzica przed ponownym renderowaniem swoich dzieci.

Krok czwarty: dotarcie do Counter i wykorzystanie kolejki

Gdy przebieg procesu dotrze do Counter, wchodzi w grę mechanizm podwójnego buforowania. React sprawdza wskaźnik alternate w bieżącej wiązce i albo ponownie wykorzystuje istniejącą wiązkę w trakcie przetwarzania, albo tworzy nową. To właśnie w tej kopiie w trakcie przetwarzania obliczany jest nowy stan.

Teraz kolejka jest wreszcie odczytywana. React przetwarza oczekującą aktualizację, stosuje odpowiednią akcję do poprzedniego stanu i zapisuje wynik do memoizedState włókna w trakcie przetwarzania. W tym kroku faktycznie wywoływany jest Counter(), a useState zwraca nową wartość. Jeśli zaimprowizowano kilka aktualizacji, są one stosowane w kolejności; aktualizacje funkcyjne takie jak setCount(c => c + 1) otrzymują wynik poprzedniej aktualizacji, dlatego zachowują się prawidłowo przy ich grupowaniu, podczas gdy setCount(count + 1) trzy razy powoduje tylko jedno zwiększenie.

// work-in-progress fiber, right after processing the queue
{
  type: Counter,
  memoizedState: { count: 1 },   // finally updated, using the queued action
  updateQueue: null,             // consumed, nothing left pending
  alternate: Fiber A,
}

Kolejka jest teraz pusta, a nowy stan znajduje się w wątku w trakcie przetwarzania. Zostały zastosowane trzy oddzielne mechanizmy: aktualizacja z kolejki z pierwszego kroku, śledzenie zaznaczonych elementów oraz przeszukiwanie od korzenia z drugiego i trzeciego kroku, a także para wątków bieżącego i w trakcie przetwarzania, która przechowuje wersje przed i po zmianie. Aby zapoznać się z szerszym opisem procesu obejmującym także korelację i zatwierdzenie zmian w DOM, sprawdź opis procesu wywołania React setState z kolejki aktualizacji do zatwierdzenia zmian w DOM.

Pełna sekwencja

Gdy przyjrzymy się temu od początku do końca, jeden kliknięcie przechodzi przez następujące etapy:

  • Obsługa kliknięcia wywołuje funkcję setCount, a React tworzy obiekt aktualizacji i dodaje go do kolejki komponentu.
  • scheduleUpdateOnFiber ustawia lanes w obiekcie Counter oraz childLanes we wszystkich przodkach aż do korzenia.
  • Ta sama funkcja żąda przejścia renderowania, które odbywa się po zwróceniu się obsługi wydarzenia.
  • Przejście renderowania zaczyna się od korzeniowej wiązki i przechodzi przez elementy child, sibling i return, pomijając poddrzewa bez brakujących tras.
  • W obiekcie Counter React przygotowuje wiązkę w trakcie przetwarzania, obsługuje zapisane aktualizacje i oblicza nowy stan.
  • Faza komitowania aplikuje zmiany w DOM, a drzewo w trakcie przetwarzania staje się nowym aktualnym drzewem.
  • Nic z tego nie dzieje się na linii, w której wywołano funkcję ustawiania. Wszystko odbywa się później, w strukturze, której pola zostały stworzone specjalnie po to, by wspierać ten odroczony, możliwy do wznowienia proces.

    Główne wnioski

    • Ustawiacz stanu umieszcza aktualizację w kolejce i planuje wykonanie zadań; nigdy nie renderuje synchronicznie wewnątrz obsługiwanego funkcji.
    • Czytanie stanu zaraz po jego ustawieniu zwraca stare wartości, ponieważ komponent jeszcze nie został ponownie uruchomiony.
    • Grupowanie aktualizacji jest naturalnym skutkiem odroczenia: wszystkie aktualizacje umieszczone przed procesem renderowania są przetwarzane jednocześnie.
    • lanes i childLanes wskazują ścieżkę od zaktualizowanej jednostki do korzenia, co pozwala Reactowi pominąć nienaruszone poddrzewa.
    • Pętla renderowania wykorzystuje wskaźniki child, sibling i return zamiast rekurencji, co umożliwia Reactowi zawieszanie i wznowianie pracy.
    • Komponent po ponownym renderowaniu nadal domyślnie renderuje swoje dzieci; ślad tras umożliwia ograniczenie zakresu poszukiwań przez React, a nie określa to, co renderuje rodzic.

    Literatura pokrewna