Strona główna / Artykuły

Zrozumienie React Lanes: Jak maski bitowe kodują priorytet aktualizacji

Dowiedz się, w jaki sposób React koduje kilka priorytetów aktualizacji w jeden liczbę całkowitą przy użyciu mask bitowych oraz dlaczego operacje bitowe zastępują proste flagi logiczne do planowania.

9 min czytania2125 słów

W końcu to nie wartości logiczne

W poprzednim rozdziale zobaczyliśmy, jak lanes i childLanes funkcjonują jak proste flagi. Pole w strukturze Counter zmieniało się z pustego na zajęte, to samo działo się w każdej strukturze nadrzędnej, a analiza drzewa opierała się na tych zmianach, aby określić, które gałęzie w ogóle warto sprawdzić. Dla tego wyjaśnienia nic innego nie miało znaczenia: pole było albo puste, albo nie.

Jednak to opisanie pomija to, co faktycznie zawierają te pola.

{
  type: Counter,
  memoizedState: { count: 1 },
  lanes: 16,
}

lanes przechowuje liczbę, a nie true lub false — rzeczywisty liczebnik zapisany w strukturze. Gdyby React musiał tylko wiedzieć „czy coś jest w toku, tak czy nie”, wystarczyłaby zwykła wartość logiczna. Zamiast tego React wybrał coś innego.

Dlaczego zatem liczba jest naturalnym wyborem do oznaczenia „ta wiązka ma zaległe zadania”?

Czym właściwie jest maska bitowa

React dostarcza grupę stałych stworzonych specjalnie w tym celu, a ich przeanalizowanie od razu ujawnia odpowiedź.

SyncLane            = 1   = 00000001
InputContinuousLane = 4   = 00000100
DefaultLane         = 16  = 00010000
TransitionLane      = 64  = 01000000

Napisane w formacie binarnym, każda z tych stałych zawiera dokładnie jedną wartość 1, przy czym ta wartość znajduje się w innej pozycji dla każdej z nich. SyncLane zajmuje pierwszą pozycję, natomiast DefaultLane – piątą. Żadna z nich nie znajduje się w tej samej pozycji. Taka struktura nazywana jest maską bitową – liczbą, której wartość liczbowo nie ma znaczenia, lecz która funkcjonuje jako seria niezależnych przełączników włączone/wyłączone, po jednym na bit, wszystkie przechowywane razem w jednym polu.

Gdy wewnątrz Counter widnieje wartość lanes: 16, nie oznacza to, że „ta aktualizacja jest warta szesnaście jednostek czegoś”. Oznacza to raczej, że bit należący doDefaultLanejest włączony, a wszystkie pozostałe bity pozostają wyłączone. Ta liczba służy jako pojemnik na flagi, a nie jako wielkość do pomiaru.

Co się dzieje, gdy jeden fiber musi jednocześnie śledzić więcej niż jedną z tych flag?

Łączenie dwóch kanałów za pomocą jednej operacji

Załóżmy fiber, który w tym samym momencie obsługuje dwa rodzaje zadań oczekujących na wykonanie – zwykłą aktualizację oraz taką, która została celowo przypisana do niższej priorytetu. React łączy te dwie maski bitowe za pomocą operacji OR bitowego: działania, które porównuje liczby bit po bicie i ustawia bit na 1 w wyniku, jeśli którykolwiek z źródeł ma go ustawionego.

00010000   (16, DefaultLane)
| 01000000   (64, TransitionLane)
-----------
  01010000   (80)

Ponieważ DefaultLane i TransitionLane posiadają po swojej pozycję bitu, do której żaden inny tor nie ma dostępu, połączenie ich operacją OR po prostu umieszcza oba wartości 1 w uzyskanym numerze, bez tego, by jedna z nich wymazywała drugą. Wynik, 80, jest nową wartością zmiennej lanes, a jeśli przekonwertujesz go z powrotem na binarny format, zobaczysz, że oba oryginalne bity są w nim zachowane dokładnie takie, jakie były.

Gdy spojrzymy sam na liczbę 80 – co ona tak naprawdę ujawnia?

Dlaczego sam fakt widzenia liczby 80 nic nam nie mówi

Samodzielnie 80 nic nie ujawnia. Takiego wartości nie czyta się w taki sam sposób jak zwykłej liczby – należy ją analizować, po jednej pozycji bitowej, przy użyciu operacji AND na poziomie bitów, która pozostawia 1 w danej pozycji tylko wtedy, gdy obie porównywane liczby już zawierają tam 1.

01010000   (80)
& 00010000   (16, DefaultLane)
-----------
  00010000   (non-zero → yes, DefaultLane's bit is set)
01010000   (80)
& 00000001   (1, SyncLane)
-----------
  00000000   (zero → no, SyncLane's bit is not set)

Nigdy nie sprawdza się wartości konkretnej ścieżki samodzielnie. Zamiast tego zadaje się wąsko określone pytanie dotyczące jednej konkretnej ścieżki, a odpowiedź przychodzi albo jako zero, albo jako wartość niezerowa. Dokładnie to się działo w tle, w logice przeszukiwania drzewa omówionej wcześniej: za każdym razem, gdy sprawdzano childLanes danej wiązki, aby zdecydować, czy warto zagłębiać się w dany gałąź, wykonywano tę samą operację AND względem ścieżki, którą aktualnie analizowano.

Zatem wracając do podstawowego pytania: dlaczego w ogóle przedstawiać to jako liczbę, zamiast prostego flaga logicznej?

Dlaczego nie użyć po prostu flagi logicznej?

Flaga logiczna może odpowiedzieć tylko na jeden rodzaj pytania: czy jest coś nierozwiązanego, tak lub nie. Nie ma możliwości wyrażenia sytuacji „czeka na siebie zwykła aktualizacja, a dodatkowo, niezależnie od niej, czeka też aktualizacja o niższej priorytecie”, ponieważ flaga logiczna przenosi zawsze tylko jeden bit informacji. React musi w rzeczywistości jednocześnie śledzić wiele różnych kategorii zadań w toku na danej ścieżce przetwarzania i musi być w stanie sprawdzić każdą z nich bez wpływania na stan pozostałych.

Maska bitowa umożliwia jednoczesne obsłużenie obu wymagań. Jedno pole liczbowe może przechowywać kilka flag równocześnie, ponieważ każda flaga zajmuje własną pozycję bitową. Testowanie jednej flagi lub łączenie kilku flag to prosta operacja CPU – AND lub OR – zamiast czegoś, co wymagałoby przeglądania struktury danych lub przechowywania oddzielnej zmiennej logicznej dla każdego możliwego poziomu priorytetu. To właśnie jest główny powód, dla którego lanes przechowywany jest jako liczba całkowita, a nie jako true lub false: nie odnotowuje on, czy istnieje jakieś zadanie, lecz dokładnie, jakie rodzaje zadań istnieją, wszystkie jednocześnie, przy niemal zerowych kosztach.

To, czego jeszcze nie omówiono, to dlaczego aktualizacja musiałaby być traktowana jako mniej ważna od początku.

Dlaczego aktualizacje w ogóle potrzebują priorytetu

Rozważmy pole wyszukiwania zbudowane jako kontrolowany komponent, w którym każde naciśnięcie klawisza ponownie filtruje listę dziesięciu tysięcy wpisów, a sam proces filtrowania odbywa się bezpośrednio w ciele funkcji komponentu.

function SearchableList({ items }) {
  const [query, setQuery] = useState("");
  function handleChange(e) {
    setQuery(e.target.value);
  }
  const filteredItems = items.filter(item => item.includes(query));
  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>
    </div>
  );
}

Wpisz jeden znak. Wykonuje się funkcja handleChange, która wywołuje setQuery, co z kolei planuje jedną ponowną renderizację SearchableList. Gdy React ostatecznie przetwarza tę ponowną renderizację, wywołuje funkcję SearchableList jako jedną nieprzerwaną, synchroniczną transakcję od początku do końca. Gdzieś wewnątrz tej jednej transakcji funkcja .filter() musi przejść przez wszystkie dziesięć tysięcy elementów, zanim funkcja będzie mogła zwrócić swój nowy JSX.

Wywołanie funkcji w trakcie jej wykonywania nie może zostać przerwane w połowie jej kodu. Możliwość przerywania, którą zapewniają wątki, działa na granicy między nimi, a nie podczas linijowego wykonywania funkcji, która jest nadal w trakcie. Dlatego React nie ma możliwości odświeżenia zaktualizowanej wartości wejściowej, zapisania jakichkolwiek zmian ani wyświetlenia pikseli na ekranie, dopóki to konkretne wywołanie funkcji, wraz z filtrowaniem itp., nie zostanie zakończone. Kolejny naciskany klawisz trafia do kolejki po nim, nie dlatego, że dwie oddzielne aktualizacje konkurują ze sobą, ale ponieważ oba te zadania są zawarte w wyniku tego samego wywołania.

Rozwiązaniem jest podzielenie tego procesu na dwie naprawdę niezależne aktualizacje, z których każda może mieć własny poziom priorytetu.

function SearchableList({ items }) {
  const [query, setQuery] = useState("");
  const [deferredQuery, setDeferredQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value);
    startTransition(() => {
      setDeferredQuery(value);
    });
  }

  const filteredItems = items.filter(item => item.includes(deferredQuery));

  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>
    </div>
  );
}

setQuery i setDeferredQuery istnieją teraz jako dwa oddzielne wywołania – każde z nich uruchamia własną aktualizację i jest oznaczone własnym przebiegiem. Render pochodzący z setQuery jest niewymagający pod względem zasobów, ponieważ nie wiąże się z żadnym filtrowaniem, więc odbywa się szybko, a pole wprowadzania pozostaje responsywne podczas pisania. Render pochodzący z setDeferredQuery to ten, który faktycznie wykonywa funkcję .filter(), i zawiera element TransitionLane, ponieważ jest otoczony funkcją startTransition, która umożliwia ten wzorzec opóźnionej aktualizacji. Jeśli wpiszesz kolejną literę, gdy ten wolny proces filtrowania jest jeszcze w trakcie, React sprawdza listę lanes, znajduje aktualizację o wyższej priorytecie czekającą w kolejce i odkłada niedokończoną pracę o niższej priorytecie, aby najpierw obsłużyć wpisany znak. Działa to tylko dlatego, że obie aktualizacje są teraz śledzone niezależnie.

, każdy z nich zawierający własny marker priorytetu, zamiast być łączony w jedną funkcję obsługującą oba zadania jednocześnie.

Rozsądne pytanie w tym momencie brzmi, czy od programistów oczekuje się ręcznego przypisywania tych wartości priorytetu, aktualizacji po aktualizacji.

Priorytet jest przeważnie automatyczny

W większości przypadków nie. React zazwyczaj wybiera ścieżkę na podstawie tego, co pierwotnie spowodowało aktualizację, a nie dlatego, że ktoś celowo ją wybrał. Ta decyzja zapada wewnątrz systemu zdarzeń React, który wie, jaki rodzaj zdarzenia przeglądarki jest obsługiwany w dokładnym momencie wywołania setState.

Ułożone mniej więcej od najbardziej pilnego do najmniej:

Discrete events    (clicks, key presses, form submissions)
Continuous events  (scroll, drag, pointer-move)
Default            (timeouts, resolved promises, no special context)
Transition         (explicitly deprioritized via startTransition)
Idle               (only when nothing else is pending)

Wywołanie setState z wnętrza obsługi kliknięcia automatycznie umieszcza je na górze listy priorytetów. Wywołanie go z obsługi przewijania stawia je na niższym poziomie, ponieważ traktowanie każdej aktualizacji spowodowanej przewijaniem jako najwyższej priorytetu, potencjalnie dziesiątki razy na sekundę, samo w sobie powoduje problemy z wydajnością. Aktualizacja uruchomiona przez spełnioną obietnicę lub timeout, bez dostępnego kontekstu do wykorzystania, ma domyślnie priorytet DefaultLane. Jedyną ręczną kontrolą, do której rzeczywiście ma dostęp programista, jest startTransition, a jej zakres jest wąski: albo aktualizacja przebiega normalnie, a React określa jej priorytet na podstawie kontekstu jak we wszystkich innych przypadkach, albo zostaje otoczona, co zmusza ją do użycia TransitionLane, niezależnie od tego, co ją faktycznie uruchomiło. useDeferredValue jest w istocie lżejszym opakowaniem zbudowanym na tym samym mechanizmie. Nikt nie ustawia

fiber.lanes = 64 bezpośrednio, i nie ma ku temu żadnego powodu.

Jedna uwaga, którą warto wspomnieć przed zakończeniem: rzeczywista baza kodowa React definiuje coś na kształt trzydziestu odrębnych stałych lane, a nie tylko cztery. Wiele z nich istnieje wyłącznie w celach wewnętrznego rozrachunku, a nie reprezentuje faktycznie nowego poziomu priorytetu, przydatnego do niezależnego śledzenia kilku przejść zamiast łączenia ich w jeden wspólny, nierozróżniony zbiór. Pięć poziomów opisanych powyżej to te, które warto zapamiętać jako model mentalny. Wszystko inne stanowi szczegóły implementacji ukryte pod strukturą, którą już rozumiesz.

Cztery elementy, jeden mechanizm

Podsumowując, oto co tak naprawdę dzieje się podczas ponownego renderowania, od początku do końca. Stan jest przechowywany w fibrze, ponieważ to właśnie fibra jest obiektem, który przetrwa więcej niż jedną rundę renderowania, w odróżnieniu od funkcji komponentu, która zapomina o wszystkim między wywołaniami, czy elementu, który jest usuwany i odbudowywany za każdym razem. Fibra występuje w postaci dwóch powiązanych wersji – obecnej i w trakcie przetwarzania – połączonych za pomocą wskaźnika alternate, ponieważ potrzebny jest rzeczywisty zrzut obrazu „przed” i „po” umieszczony obok siebie, aby w ogóle można było dokonać jakiejkolwiek porównania. Wywołanie setState nigdy nie wpływa bezpośrednio na żadną z fibr. Zamiast tego umieszcza wiadomość w kolejce i odkłada rzeczywistą pracę, podczas gdy zaznaczona grupa ścieżek umożliwia późniejszej wędrówce po drzewie określenie, które gałęzie rzeczywiście warto sprawdzić. A ten marker nigdy nie był czymś prostym

Pierwotnie jest to flaga, a dokładniej maska bitowa, która przenosi wystarczająco dużo zakodowanych informacji, aby pojedyncze naciśnięcie klawisza mogło ominąć całą pracę związana z renderowaniem, która wciąż czeka w kolejce.

Cztery odrębne koncepcje, ale łączą się one w jeden mechanizm. Każda z nich staje się w pełni zrozumiała dopiero po zrozumieniu poprzedniej. Rozpatrywana osobno, każdy z tych elementów sprowadza się do faktu, który można zapamiętać, bez rozumowania, które go faktycznie uzasadnia.