Strona główna / Artykuły

Scheduler w React i pętla zdarzeń: kto naprawdę decyduje, kiedy wykonywane są zadania

Zobacz, jak współpracujący planer React działa wewnątrz pętli zdarzeń JavaScript, dlaczego przejścia mogą być opóźniane oraz dlaczego żaden planer nie może uratować zablokowanej wątku.

Wydawca: REACTAPP.TOP13 min czytania3017 słów

Źródła: How Does React’s Scheduler Work With the JavaScript Event Loop?

React decyduje nie tylko o tym, co powinno się zmienić na ekranie, ale także o tym, kiedy ma to nastąpić, wykorzystując priorytety, ścieżki przetwarzania oraz możliwość przerwania procesu renderowania. Mimo to React jest po prostu JavaScriptem, a JavaScript posiada już pętlę zdarzeń, która określa, kiedy kod ma zostać wykonywany. Dlaczego więc React dostarcza własny planer zadań i który z tych dwóch rozwiązań faktycznie ma pierwszeństwo? Ten artykuł wyjaśnia tę relację, pokazuje, jak pasują do niej funkcje współbieżne takie jak przejścia i useDeferredValue, oraz tłumaczy ograniczenie, którego nie da się przezwyciężyć żadnym sprytnym planowaniem, zarówno w przeglądarce, jak i w React Native.

Zacznij od stosu wywołań, a nie od React

Zwykły JavaScript wykonywa kod na stosie wywołań, po jednym kroku na raz. W poniższym fragmencie nic nie jest asynchroniczne: zapis do logu, wywołanie funkcji, która robi to samo, oraz kolejny zapis do logu.

console.log("A");

function calculate() {
  console.log("B");
}
calculate();
console.log("C");

Wynik odpowiada dokładnie kolejności kodu:

A
↓
B
↓
C

Teraz zastąp wywołanie funkcji timerem z opóźnieniem równym zeru:

console.log("A");
setTimeout(() => {
  console.log("B");
}, 0);
console.log("C");

Porządek się zmienia:

A
C
B

To zaskakuje ludzi, ponieważ opóźnienie jest dosłownie:

0

Wyjaśnienie brzmi tak, że setTimeout() nigdy nie oznacza „wykonaj to teraz”. Oznacza to, że funkcja callback staje się dostępna do wykonania po upływie określonego czasu, a nadal musi czekać, aż obecnie wykonywany kod się zakończy i system operacyjny zajmie się nią. Decydowanie o tym, kiedy to nastąpi, jest zadaniem pętli zdarzeń.

Pętla zdarzeń w skrócie

Dostateczny jest uproszczony obrazek do rozumienia działania Reacta:

              ┌─────────────┐
              │ Call Stack  │
              └──────┬──────┘
                     │
                     ↓
              JavaScript Runs
                     │
                     ↓
              Stack becomes free
                     │
                     ↓
                Event Loop
                     │
              ┌──────┴──────┐
              ↓             ↓
         Microtasks       Tasks

Kod jest wykonywany na stosie, dopóki ten nie zostanie pusty. Dopiero wtedy pętla zdarzeń przetwarza zadania z kolejki: najpierw są wykonywane mikrozadania (takie jak callbacki zrealizowanych obietnic), a następnie kolejne zadanie (np. callback timera lub zdarzenie wejścia). Prawdziwe przeglądarki i środowiska wykonawcze dodają więcej szczegółów, w tym kroki renderowania pomiędzy zadaniami, ale jedna zasada jest tu najważniejsza: synchroniczny JavaScript, który już się uruchomił, nie może być przerwany przez inny JavaScript. Jeśli wątek jest zajęty, wszystko inne czeka. Więcej informacji na temat kolejek znajdziesz w artykule o zamknięciach i pętli zdarzeń.

Celowo zamrożona aplikacja

Aby zrozumieć, co oznacza ta zasada dla Reacta, rozważmy komponent, którego obsługa naciśnięcia uruchamia pętlę oczekiwania trwającą około pięciu sekund przed aktualizacją stanu. Przykład wykorzystuje Button z React Native, ale zachowanie jest identyczne w React DOM:

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

function handlePress() {
    const start = Date.now();
    while (Date.now() - start < 5000) {
      // intentionally blocking JavaScript
    }
    setCount(c => c + 1);
  }
  return (
    <Button
      title={`Count: ${count}`}
      onPress={handlePress}
    />
  );
}

Przez te pięć sekund wątek JavaScripta:

JavaScript Thread

wygląda tak:

██████████████████████████████       BLOCKED FOR 5 SEC

Ukryte aktualizacje, pilna priorytetowość, drzewo Fiber gotowe do przetworzenia – nic z tego nie ma znaczenia, ponieważ sam React jest napisany w JavaScriptu i potrzebuje swojej kolejki na wątku. Planer nie może przejąć CPU spoza środowiska wykonawczego.

Kto ma kontrolę

Lekko jest wyobrazić sobie React na szczycie, który kieruje JavaScriptem:

React Scheduler
      ↓
Controls JavaScript

Prawdziwy obraz jest odwrotny. Środowisko wykonawcze przyznaje czas wykonywania, a planer Reacta decyduje, jak go wykorzystać:

JavaScript Runtime
      ↓
Provides execution opportunity
      ↓
React Scheduler
      ↓
Coordinates React work

Schedularka funkcjonuje wyłącznie w środowisku dostarczanym przez JavaScript. Potrafi sprytnie organizować pracę samego React, ale działa tylko wtedy, gdy otrzyma wątek do obsługi.

Dlaczego w ogóle potrzebna jest schedularka?

Jeśli React nie może przejąć kontroli nad pętlą zdarzeń, jaki wpływ ma jego schedularka? Wyobraźmy sobie ekran z tysiącami elementów:

App
├── Header
├── Search
├── Filters
├── ProductList
│   ├── Product
│   ├── Product
│   ├── Product
│   ├── ...
│   └── Product × 5000
└── Footer

W modelu czysto synchronicznym renderowanie tego drzewa stanowi jeden długi, nieprzerwany blok działania:

Start Render
     ↓
████████████████████████████
     ↓
Finish Entire Render

Tymczasem użytkownik nadal wchodzi w interakcje:

Type
Tap
Scroll

Jeśli renderowanie to jedno ogromne zadanie, wszystkie te interakcje czekają w kolejce aż zostanie ono zakończone, przez co interfejs wydaje się zablokowany. React potrzebuje możliwości podziału procesu renderowania na mniejsze jednostki oraz współpracy z systemem operacyjnym, aby regularnie zwracać wątek i umożliwiać odbieranie danych wejściowych oraz rysowanie.

Schedularka współpracująca

Wewnętrzny planer Reacta to dokładnie taki rodzaj systemu współpracy. Zamiast starego podejścia:

Render everything NOW

on ciągle zadaje serię pytań dotyczących zaległych zadań:

There is React work pending.What should we work on first?How urgent is it?Should we continue?Should we yield?Did something more important arrive?

Jako proces, pętla wygląda w ten sposób:

Pending React Work
        ↓
     Scheduler
        ↓
Check Priority
        ↓
Perform Some Work
        ↓
Should We Yield?
   ↙             ↘
 YES             NO
  ↓               ↓
Give Host       Continue
a Chance        Rendering

React wykonuje fragment pracy, sprawdza, czy powinien przekazać kontrolę, a jeśli tak, zwraca ją do hosta, aby przeglądarka (lub natywny silnik) mogła przetworzyć dane wejściowe, uruchomić inne zadania i narysować interfejs. Następnie kontynuuje od miejsca przerwy lub ponownie rozważa, nad czym pracować dalej.

Kluczowym słowem jest współpraca. React nic nie odbiera JavaScriptowi; on sam organizuje swoją pracę tak, by nigdy nie zatrzymywać wątku dłużej niż to konieczne. Współpraca jednak funkcjonuje tylko wtedy, gdy wszyscy uczestnicy współpracują.

Dwa poziomy, dwa pytania

Pomaga sformułować tę różnicę jako dwa oddzielne pytania.

  • pętla zdarzeń pyta: kiedy JavaScript może wykonać zadania ułożone w kolejce?
  • planer Reacta pyta: skoro React ma wątek, które zadania Reacta powinny otrzymać pierwszeństwo?

Gdy wszystkie warstwy są ułożone obok siebie, wyglądają w ten sposób:

Browser / Native Runtime
          ↓
    JavaScript Runtime
          ↓
       Event Loop
          ↓
 JavaScript gets to run
          ↓
   React Scheduler
          ↓
 Choose React Work
          ↓
       Fiber
          ↓
   Render / Reconcile
          ↓
       Commit

Każda warstwa działa wewnątrz warstwy znajdującej się nad nią.

Gdy wpisywanie tekstu konkuruje z dużym renderowaniem

Wyobraź sobie React w trakcie renderowania długiej listy produktów:

Rendering Products...
██████████████░░░░░░░░

Następnie użytkownik zaczyna wpisywać zapytanie wyszukiwawcze, po jednym nacisku klawisza na raz:

R
Re
Rea
React

React ma teraz dwa rodzaje zadań konkurujących o ten sam wątek:

Large List Rendering
        +
User Input Update

Dla użytkownika znacznie ważniejsze jest wpisywanie tekstu: każda litera powinna pojawić się natychmiast, nawet jeśli lista jest nieco opóźniona. Priorytetyzowanie naciskania klawiszy i przerywanie renderowania listy umożliwia to osiągnięcie.

Jak Fiber zapewnia punkty przerwy

Rozplanowywanie jest pomocne tylko wtedy, gdy praca może być faktycznie podzielona. To właśnie jest rolą Fiber: każda wątek reprezentuje pracę nad jedną częścią drzewa, dzięki czemu renderowanie staje się sekwencją małych kroków zamiast jedną monolityczną operacją:

App Fiber
   ↓
Header Fiber
   ↓
Search Fiber
   ↓
List Fiber
   ↓
Product Fiber
   ↓
Product Fiber
   ↓
...

Dzięki renderowaniu równoległemu React może przetworzyć niektóre z tych kroków, zatrzymać się, zająć się czymś pilniejszym, a następnie kontynuować lub zacząć od nowa:

Start Rendering
      ↓
Perform Some Fiber Work
      ↓
Pause
      ↓
Handle More Urgent Work
      ↓
Continue / Restart

Dokumentacja React opisuje renderowanie równoległe jako możliwe do przerwania dokładnie w tym sensie: renderowanie może zostać rozpoczęte, wstrzymane, wznowione później lub porzucone, jeśli stało się przestarzałe, wszystko to zanim cokolwiek zostanie zapisane. To zupełnie inne stwierdzenie niż to, że React uruchamia JavaScript równolegle, co nie jest prawdą.

Równoległość to nie konwergencja

Frazę:

Concurrent React

Często wyobraża się dedykowane wątki dla każdego zadań:

Thread 1 → InputThread 2 → Product ListThread 3 → React Rendering

Ten model jest błędny. Konkurencyjność React polega na przepływie zadań w ramach jednego wątku, co przypomina to:

Work A
██████░░░░░░        ↓ pauseWork B
████        ↓Work A
██████

Zadanie A jest wykonywane przez chwilę, następnie zawiesza się, potem wykonuje się zadanie B, a dopiero później kontynuuje się zadanie A. Różne elementy renderowania są koordynowane w czasie, ale kod komponentu nigdy nie jest wykonywany na kilku wątkach jednocześnie.

Pilne zadania pojawiające się podczas przejścia

Załóżmy, że React renderuje kosztowną aktualizację umieszczoną w ramach przejścia:

startTransition(() => {
  setResults(expensiveResults);
});

Póki renderowanie jest jeszcze trwające, użytkownik wpisuje kolejną literę. Sekwencja wydarzeń wygląda wtedy mniej więcej tak:

Transition Render
██████████░░░░░░░
          ↓      User Types          ↓Urgent Update
████████          ↓React can reconsider
lower-priority work

Ponieważ przejście nie jest pilne, React może je odłożyć, najpierw obsłużyć wpisany znak, a dopiero potem wrócić do zadania o niższej priorytecie, często uruchamiając je ponownie z nowym stanem.

Odkładanie wartości zamiast aktualizacji

useDeferredValue stosuje tę samą zasadę co w trybie odczytu. Zamiast oznaczać aktualizację stanu jako nieważną, tworzy się opóźnioną kopię wartości:

const deferredQuery = useDeferredValue(query);

Wejście nadal korzysta z rzeczywistego, aktualnego stanu:

<TextInput
  value={query}
  onChangeText={setQuery}
/>

podczas gdy kosztowny komponent otrzymuje opóźnioną kopię:

<ExpensiveList query={deferredQuery} />

W rezultacie pole może już pokazywać:

React

podczas gdy lista przez chwilę nadal odzwierciedla poprzedni zapytanie:

Reac

Tymczasem React przygotowuje nową listę na boku, a kolejne naciśnięcie klawisza sprawia po prostu, że odrzuca tę próbę i zaczyna od nowa od najnowszej zapytania. To nie sprawia, że kosztowny komponent działa szybciej; celem jest wyłącznie uniknięcie sytuacji, w której mniej ważne procesy renderowania blokują interakcje, na których skupia się użytkownik. Wybór między tymi dwoma narzędziami jest zazwyczaj prosty: użyj startTransition, gdy sam kontrolujesz aktualizację stanu, a useDeferredValue, gdy otrzymujesz wartość jedynie jako parametr.

Ograniczenie: planowanie nie może przerwać twojego kodu

Wszystko powyżej zależy od regularnego przywracania wątku przez React. Rozważmy teraz funkcję, która wykonywa ogromne obliczenia synchroniczne:

function calculateEverything() {
  for (let i = 0; i < 10_000_000_000; i++) {
    // huge synchronous calculation
  }
}

Podczas jej wykonywania wątek JavaScript:

JS THREAD

jest całkowicie zajęty:

████████████████████████████Scheduler waiting...React waiting...Input waiting...

Planer, React oraz czekające na przetworzenie dane wszystkie czekają jednocześnie. Planowanie współpracujące może odbywać się tylko pomiędzy etapami pracy samego Reacta; nie może przerwać pętli napisanej przez użytkownika. Dlatego renderowanie równoległe nie zastępuje efektywnego JavaScriptu. Intensywne obliczenia nadal wymagają redukcji, podziału na mniejsze części, zapisywania w pamięci lub przeniesienia poza główny wątek, na przykład do procesu pomocniczego.

Dlaczego renderowanie i zapisanie pozostają oddzielone

To samo podejście wyjaśnia, dlaczego React nalega na podział aktualizacji na dwie fazy:

Render

i:

Commit

Renderowanie to moment, w którym React określa, jak powinien wyglądać interfejs użytkownika, a w trybie równoległym ta praca może zostać przerwana. React odmawia pokazywania nieukończonego rezultatu. Dlatego podczas fazy renderowania:

Render

React może zakończyć niektóre wątki, zostać przerwany i wznowić lub rozpocząć od nowa działanie, a dopiero gdy cała struktura jest gotowa, dokonuje zapisu:

Fiber A ✓
Fiber B ✓
Fiber C ...
      ↓
Interrupted
      ↓
Continue / Restart
      ↓
Entire Tree Ready
      ↓
COMMIT
      ↓
Visible UI

Dlatego przerwany proces renderowania nigdy nie tworzy ekranu, który łączy różne stany:

Half old UI
+
Half new UI

Zapis stosuje zawsze tylko spójny, ukończony wynik.

Dlaczego renderowanie musi być czyste

„Utrzymuj komponenty w stanie czystym podczas renderowania” to powszechna rada, a możliwość przerwania procesu renderowania pokazuje dokładnie, dlaczego jest to ważne. Spójrz na komponent, który w swoim ciele wywołuje funkcję analityczną:

function Product() {
  analytics.track("Product Rendered");
return <View />;
}

W warunkach równoczesnego renderowania React może przejść przez taką sekwencję:

Starts Render
    ↓
Pauses
    ↓
Abandons Work
    ↓
Renders Again

Każda z tych prób uruchamia funkcję komponentu, więc to wywołanie:

analytics.track(...)

Może zostać uruchomiony dla renderów, które nigdy nie zostały zapisane i których nikt nie widział. Twoje metryki będą liczyć fikcyjne wyświetlenia produktów. React potrzebuje swobody, by:

Render
Pause
Retry
Abandon
Restart

bez tego, aby jakiekolwiek z tych prób zmieniały rzeczywisty świat. Efekty uboczne powinny znajdować się w funkcjach effects lub obsługiwaczach zdarzeń, które działają tylko przy zapisanych operacjach. Aby uzyskać praktyczny przewodnik dotyczący wyboru miejsca, w którym powinna znajdować się logika, zapoznaj się z wyborem odpowiedniego narzędzia React: derive, handle, fetch, defer lub effect.

Kąt widzenia React Native

W aplikacji React Native ścieżka od naciśnięcia klawisza do ekranu przechodzi przez te same warstwy, z rendererem natywnym na końcu:

User Types
    ↓
JS Event Handler
    ↓
setState()
    ↓
React Scheduler
    ↓
Fiber Work
    ↓
React Native Renderer
    ↓
Native UI Update

Zablokuj wątek JavaScriptu kosztownymi operacjami synchronicznymi, a łańcuch zatrzyma się na drugim kroku:

User Types
    ↓
JS Thread Busy
████████████████
    ↓
React can't process JS work yet

Aplikacja wydaje się zamarznięta, mimo że sam wątek interfejsu użytkownika napisanego w języku natywnym może być w pełni sprawny. Podczas diagnozowania wolnego działania warto rozróżnić możliwe przyczyny, ponieważ każda z nich wymaga innego rozwiązania:

Is React rendering too much?ORIs JavaScript itself blocked?ORIs expensive native work happening?ORIs the UI thread overloaded?

Zbyt wiele wywołań renderowania wymaga zastosowania mechanizmów memoizacji, wirtualizacji lub przejść. Zablokowany wątek JavaScripta wymaga przeniesienia lub podziału obciążających zadań. Drogie operacje wykonywane w języku natywnym lub przeciążony wątek interfejsu użytkownika wskazują natomiast na konieczność profilowania w tym języku.

Pełny proces

Gdy połączymy wszystkie elementy, pojedyncza interakcja przechodzi przez te etapy:

USER INTERACTION
       ↓
JavaScript Runtime
       ↓
Event Handler
       ↓
setState()
       ↓
Update Created
       ↓
Lane / Priority
       ↓
React Scheduler
       ↓
Choose Work
       ↓
Fiber Render
       ↓
Can Yield / Restart
       ↓
Reconciliation
       ↓
Completed Work
       ↓
Commit
       ↓
UI Updated

A wszystko to odbywa się w obrębie środowiska wykonawczego JavaScripta:

┌─────────────────────────────┐
│     JavaScript Runtime      │
│                             │
│ Event Loop gives JS a chance│
│            ↓                │
│     React coordinates       │
│        its own work         │
└─────────────────────────────┘

Pętla zdarzeń ustala kolejność wykonywania zadań przez JavaScript; w ramach każdej takiej tury React decyduje, jak ją wykorzystać.

Pytania, na które warto umieć odpowiedzieć

Czy planer React to to samo co pętla zdarzeń?

Nie. Pętla zdarzeń należy do środowiska wykonawczego hosta. Planer React koordynuje prace React w ramach czasu przyznanego przez to środowisko.

Czy React uruchamia komponenty równolegle na kilku wątkach?

Nie. Równoległe renderowanie pozwala React na pauzowanie, wznowienie, ponowne uruchomienie lub porzucenie pracy nad renderowaniem na pojedynczym wątku. Kod komponentów nie staje się równoległy.

Czy React może przerwać proces renderowania?

Tak, w przypadku równoległego renderowania. React może umieścić pilne aktualizacje na początku i odrzucić przestarzałe prace nad renderowaniem, zanim zostaną one zapisane.

Czy planer może uratować zablokowany wątek?

Nie. Długo trwający synchroniczny JavaScript zmusza React, podobnie jak wszystko inne, do oczekiwania, aż wątek zostanie zwolniony.

Dlaczego komponenty muszą pozostać czyste?

Ponieważ proces renderowania może zostać wykonyany więcej razy i może zostać przerwany lub odrzucony. Skutki uboczne podczas renderowania mogą „wyciec” z pracy, która nigdy nie trafia na ekran.

Jak ewoluuje model mentalny

Większość programistów zaczyna od wyobrażenia, że zmiana stanu bezpośrednio prowadzi do ponownego renderowania:

setState()
   ↓
React renders
   ↓
Screen updates

Omawianie kanałów i planowania dodaje kilka kolejnych etapów:

setState()
   ↓
Update
   ↓
Lane
   ↓
Scheduler
   ↓
Render
   ↓
Commit

Ostatnią warstwą jest sam czas wykonywania, który znajduje się u podstaw wszystkich działań Reacta:

JavaScript Runtime
       ↓
Event Loop
       ↓
React Scheduler
       ↓
Fiber
       ↓
Render
       ↓
Commit

React inteligentnie planuje swoje zadania, ale zawsze wewnątrz środowiska JavaScript. Patrząc w ten sposób, przejścia, możliwość przerwania renderowania oraz zablokowany wątek JS tworzą spójny obraz.

Dokąd zmierza ten proces

W tym artykule prześledzono ścieżkę od aktualizacji stanu aż do pętli zdarzeń:

State Update
    ↓
Update Queue
    ↓
Fiber
    ↓
Lanes
    ↓
Scheduler
    ↓
JavaScript Event Loop

W React Native ta ścieżka kontynuuje się dalej, od komitetu w React do rzeczywistych widoków platformy. Drzewo tak proste jak to:

<View>
  <Text>Hello React Native</Text>
</View>

nadal przechodzi przez bundler, silnik Hermes, JSI oraz renderer Fabric, zanim stanie się natywnym interfejsem użytkownika w Androidzie lub iOS. Te warstwy wprowadzają własne problemy związane z wątkami i planowaniem, które warto rozumieć osobno po zrozumieniu strony JavaScript.

Główne wnioski

  • Pętla zdarzeń decyduje, kiedy uruchamia się JavaScript; planer React decyduje, jakie zadania React należy wykonać w tym czasie.
  • Planowanie w React jest współpracujące: ustępuje miejsca innym zadaniom Fiber, aby można było przetwarzać dane wejściowe i rysować interfejs.
  • Konkurencja w React oznacza przepływanie zadań na jednym wątku, a nie ich równoległe wykonywanie.
  • startTransition oznacza aktualizacje jako nieważne; useDeferredValue umożliwia komponentowi renderowanie na podstawie opóźnionej wartości.
  • Żaden planer nie może przerwać własnego długiego kodu synchronicznego, więc intensywne obliczenia nadal wymagają optymalizacji lub przeniesienia.
  • Commity aplikują tylko kompletne wyniki, a renderowanie musi być czyste, ponieważ może zostać ponownie wypróbowane lub odrzucone.
  • Aktualizacja o wysokim priorytecie nie może zostać wyrenderowana, dopóki pięciosekundowe obliczenie synchroniczne zajmuje wątek; czeka, aż ten kod zakończy się.