Jaki jest rzeczywisty koszt renderowania w React na głównej wątku?
Render versus commit w React: dlaczego niewidzialna praca renderowania nadal konkuruje z operacjami wejścia na głównej wątku i co naprawdę oszczędza memoizacja.
Sam proces „renderowania” w React nie zmienia sam w sobie tego, co jest widoczne w przeglądarce. Komponent, który uruchamia się raz, oraz ten sam komponent, który uruchamia się setki razy, mogą wyglądać identycznie dla osoby używającej aplikacji. Wyglądają tak samo, ponieważ przeglądarka aktualizuje się tylko wtedy, gdy faza commit zapisuje dane do DOM, a sam proces renderowania nigdy nie gwarantuje takiego zapisu. Dlaczego więc tak wiele wskazówek dotyczących wydajności React skupia się na unikaniu renderowań – useCallback (zachowanie identyczności funkcji pomiędzy renderowaniami), useMemo (zachowanie obliczonej wartości pomiędzy renderowaniami), React.memo (pominięcie renderowania dziecka, gdy propsy się nie zmieniły) oraz ograniczanie aktualizacji stanu?
To napięcie jest rzeczywiste: może się wydawać, że optymalizuje się coś, czego użytkownik nigdy by nie zauważył.
Jeśli renderowanie w ogóle nie wpływa na przeglądarkę, to co właściwie jest wtedy wykorzystywane?
Dwie fazy ukryte w jednym renderowaniu
Ludzie często mówią „renderowanie” na określenie całego cyklu aktualizacji. React dzieli ten cykl na dwie fazy o różnych zadaniach. Jedna faza tworzy opis następnego interfejsu użytkownika. Druga faza decyduje, czy ten opis powinien przekształcić się w rzeczywiste zmiany w DOM.
Takie same cztery wyzwalacze uruchamiają obie fazy: aktualizacja stanu, zmiana właściwości, ponowne renderowanie elementu rodzica lub zmiana wartości kontekstu (wartość z API Context odczytana bez przechodzenia przez właściwości). To, co każda faza robi dalej — oraz jakie ma koszty — jest tym, co je różni.
Co dzieje się w pierwszej fazie, tej, która zawsze jest wykonywana, gdy React planuje aktualizację?
Rozbicie fazy renderowania
Gdy React decyduje, że potrzebna jest aktualizacja, ponownie wywołuje funkcję komponentu od góry. Wykonuje się wtedy każde zdanie zawarte w tym ciele: obliczenia, pętle, tworzenie obiektów napisanych bezpośrednio w kodzie.
Następnie React ocenia JSX po instrukcji return. JSX (składnia <div>...</div>) nie jest HTML i nigdy samodzielnie nie trafia do przeglądarki. Podczas budowania aplikacji Babel lub kompilator TypeScript zamienia go na wywołania funkcji – historycznie React.createElement, a coraz częściej nowoczesny helper jsx(). To właśnie te wywołania tworzą opis struktury komponentu.
Rezultat ten nazywany jest zazwyczaj wirtualnym DOM-em; wewnętrzną nazwą React jest drzewo elementów. Jest to zwykły obiekt JavaScript, który opisuje zamierzoną interfejs użytkownika — nie jest to prawdziwy HTML ani żywy węzeł DOM-u.
Rozważmy mały komponent:
function Greeting({ name }) {
const message = `Hello, ${name}`;
return (
<div>
<h3>{message}</h3>
</div>
);
}
Zmiana w polu name powoduje, że React ponownie wywołuje funkcję Greeting. Ciąg szablonowy dla pola message jest uruchamiany ponownie. Następnie skompilowany JSX tworzy drzewo obiektów podobne do tego:
{
"type": "div",
"props": {
"children": {
"type": "h3",
"props": { "children": "Hello, Akshat" }
}
}
}
Tym obiektem jest zaktualizowany wirtualny DOM funkcji Greeting. Struktura pozostała w pamięci — nie doszło do żadnej mutyacji DOM, rysowania ani układu. Kto następnie wykorzysta to drzewo?
Rozbieranie fazy komitowania
React porównuje nowe drzewo elementów z poprzednim — to proces rekonsiliacji, czyli znajdowania różnic, które faktycznie się zmieniły. Tylko te różnice są zapisywane do rzeczywistego DOM-u. Jeśli nic się nie zmieniło, nic nie jest zapisywane.
Zwróćmy się do Greeting i załóżmy, że name przechodzi z jednej ciągu znaków do drugiego. Poprzedni drzewo zawierało stary tekst powitania w elementie h3; nowe drzewo zawiera zaktualizowany tekst. Struktura pozostaje taka sama — div otaczający h3 — więc proces porównywania rejestruje tylko jedną różnicę: ten węzeł tekstowy. Faza komitowania aktualizuje wyłącznie ten tekst; reszta drzewa pozostaje nietknięta.
To jedyna faza, w której bierze udział rzeczywisty przeglądarka, i to także powód, dla którego jest to jedyna faza, która może zmusić do ponownego obliczenia układu (ponownego obliczania geometrii) lub ponownego narysowania. Jeśli zmienimy wystarczająco dużo treści, przeglądarka musi to ponownie wykonać. Jeśli nic nie zmienimy, tego nie robi.
Oto kluczowy przypadek dla reszty argumentacji. Gdyby name nie uległ zmianie przy ponownym uruchomieniu, nowy drzewo odpowiadałoby starymu, proces synchronizacji wykrywałby zerowe różnice, a operacja zapisu nie miałaby żadnego efektu. Żaden zapis do DOM, żaden zmiany układu, żadne rysowanie — nic, co mogłoby zostać zauważone przez użytkownika. A mimo to faza renderowania — wywołanie funkcji, ponowny obliczony message, świeżo przydzielone drzewo obiektów — została przeprowadzona w pełni chwilę wcześniej.
Jeśli renderowanie może się zakończyć bez pozostawienia widocznego śladu, ile kosztowała ta operacja?
Faza renderowania może się odbyć i nadal nic nie zmienić
Tutaj dyskusja staje się spójna. Render, który daje identyczny wynik, nadal zużywa rzeczywiste zasoby: faktyczną funkcję wywołania, rzeczywiste alokacje pamięci dla każdego węzła w drzewie, rzeczywistą pracę synchronizacyjną polegającą na przeszukaniu drzewa, zanim zostanie stwierdzone, że nic się nie zmieniło. Nic z tego nie jest widoczne na ekranie. Mimo to wszystko się wydarzyło.
To rozdzielenie – rzeczywista praca, która pozostaje niewidoczna – jest powodem, dla którego zarówno twierdzenie „rendery nie mają znaczenia”, jak i „rendery mają ogromne znaczenie” mogą być prawdziwe, ale odnoszące się do różnych poziomów. Rendery rzeczywiście nie decydują o tym, co widzi użytkownik; to komitet kontrolny ma taką władzę. Rendery mają znaczenie dla czegoś innego, co nie ma nic wspólnego z pikselami.
Czym jest to coś innego?
Główny wątek nie przejmuje się tym, czy praca jest widoczna
Tym „czymś” jest główny wątek JavaScripta: wspólna kolejka, która wykonywać ma zadania po jednym. Ten sam wątek uruchamia Twój kod JS, oblicza układ elementów i przekazuje zdarzenia wejściowe — kliknięcia, przewijanie, naciśnięcia klawiszy.
Przeglądarki tworzą nowy ramek co około 16,7 milisekundy, aby uzyskać 60 klatek na sekundę. Wszystko, co dotyczy danej klatki — logika fazy renderowania, synchronizacja, zapisy do DOM-u, obliczanie układu, rysowanie — musi zmieścić się w tym limicie, a faza renderowania nie otrzymuje żadnych uprawnień specjalnych tylko dlatego, że jej wynik może zostać odrzucony. Dzieli ona tę samą kolejkę, którą użytkownik odczuwa podczas interakcji.
Bądź precyzyjny: nie każdy krok przeglądarki związany z ramką odbywa się na tej wątku. Rasterizacja (przekształcanie poleceń rysowania w piksele) oraz kompozycja (łączenie warstw w końcową ramkę) często odbywają się gdzie indziej, dlatego warstwa uprzednio zrasterowana może pozostać dostępna do przewijania, podczas gdy główny wątek jest zajęty. Sama faza renderowania oraz zdarzenie, które ją uruchomiło, nadal odbywają się na głównym wątku. Odrębne wątki kompozytora tłumaczą pewną płynność pod obciążeniem; nie eliminują one jednak kosztów fazy renderowania.
Jedno zmarnowane renderowanie może kosztować ułamek milisekundy. Kiedy to staje się czymś, co odczuwa użytkownik?
Dlaczego faza renderowania nadal ma wpływ
Ponieważ rzadko odbywa się tylko raz. Gdy element nadrzędny ponownie renderuje, React domyślnie uruchamia fazę renderowania dla każdego elementu potomnego, nawet jeśli jego atrybuty się nie zmieniły, chyba że element ten jest otoczony funkcją React.memo.
function Dashboard() {
const [searchTerm, setSearchTerm] = useState('');
const products = useProducts(); // 200 items
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<ProductList products={products} />
</div>
);
}
function ProductList({ products }) {
return (
<div>
{products.map((product) => (
<ProductRow key={product.id} product={product} />
))}
</div>
);
}
Wpisz jeden znak do pola wyszukiwania, a searchTerm się aktualizuje. Oczekuje się ponownego uruchomienia panelu sterowania – jego wyniki uległy zmianie. ProductList oraz wszystkie 200 komponentów ProductRow poniżej również uruchamiają się ponownie, nie dlatego, że ich właściwości uległy zmianie (naciśnięcie klawisza nie ma związku z danymi produktów), lecz dlatego, że potomne elementy są ponownie renderowane, gdy to robią ich rodzice, chyba że coś zatrzyma tę kaskadę.
Każde z tych 200 wywołań to nadal rzeczywista eksploatacja funkcji, rzeczywisty drzewo elementów dla każdego wiersza oraz rzeczywiste porównanie, które pokazuje, że żaden z wierszy nie wymaga zapisu do DOM-u. Jedno naciśnięcie klawisza nie kosztuje wiele. Pole wyszukiwania w czasie rzeczywistym przy normalnej szybkości pisania wywołuje te operacje kilka razy na sekundę, na tym samym wątku, który musi obsłużyć kolejne naciśnięcie klawisza.
To jest scenariusz, do którego są skierowane useMemo, useCallback i React.memo — więc co one właściwie chronią?
Czym faktycznie chronią useMemo, useCallback i React.memo
React.memo otacza komponent i pomija jego fazę renderowania, gdy nowe props są wierzchołkowo równe poprzednim (=== dla każdego propu). Otaczając ProductRow, każdy nacisk klawisza na panelu sterowania nie powoduje już 200 wywołań renderowania; React porównuje props tylko raz na komponent, a zatrzymuje się, gdy referencja product pozostaje niezmieniona.
useMemo przechowuje w pamięci wynik obliczeń między renderowaniami, dzięki czemu kosztowne operacje wewnątrz komponentu są wykonywane ponownie tylko wtedy, gdy zmieniają się wymienione zależności. useCallback robi to samo w kwestii identyfikacji funkcji. Zazwyczaj służy nie tyle do oszczędności zasobów przy tworzeniu funkcji, ile do ochrony komponentu memoizowanego: nowa funkcja przy każdym renderowaniu to nowy referencja, a nowa referencja zakłóca powierzchowną weryfikację przeprowadzaną przez React.memo w odniesieniu do elementu, który otrzymuje tę właściwość.
Żaden z tych trzech narzędzi nie modyfikuje fazy komitowania. Proces komitowania jest już kontrolowany przez mechanizm porównywania, który sprawdza istnienie rzeczywistej różnicy. Jeśli nic i tak nie trafiłoby na ekran, te narzędzia nie wpływają na to, czy dojdzie do zapisu w DOM. One oszczędzają czas poświęcony na obliczenia w fazie renderowania – czas spędzony na głównej wątku, nawet gdy wynik jest identyczny.
React.memo oraz wyszukiwanie w pamięci cache przezuseMemo generują koszty przy każdym przetworzeniu, więc otaczanie taniego, rzadko używanego komponentu może ostatecznie przynieść straty: trzeba płacić za dodatkowe obciążenia, aby chronić funkcjonalności, które i tak nigdy nie były kosztowne. Kiedy to ma jakiekolwiek znaczenie dla osoby korzystającej z produktu?
Koszt renderowania wpływa na działanie wątku, które faktycznie odczuwa użytkownik
Nic w fazie renderowania nie zmienia samodzielnie piksela — ta pierwsza część oryginalnego stwierdzenia zawsze była prawdziwa. Komponent, który uruchamia się raz lub sto razy, może wyglądać tak samo, ponieważ to commit, a nie renderowanie, decyduje o tym, co trafia na ekran.
Renderowanie nadal nie jest bezpłatne, ponieważ jest niewidoczne. To rzeczywała praca wykonywana na tej samej kolejce jednowątkowej co układanie elementów, zapisywanie zmian w DOM oraz każde kliknięcie, przewijanie i naciśnięcie klawisza. Unikanie niepotrzebnych zadań w fazie renderowania na tej kolejce – szczególnie gdy aktualizacja elementu nadrzędnego przenosi się na głęboką listę podporządkowanych elementów lub gdy pisanie i przewijanie powodują szybkie aktualizacje – pozwala odzyskać milisekundy potrzebne do realizacji interakcji.
Rzadko mówi się o tej cichszej części procesu. Mniejsza liczba renderowań nigdy nie była ostatecznym celem. Celem jest zachowanie wystarczającej ilości wspólnego budżetu około 16,7 ms na prace związane z zapisywaniem zmian i obsługą wprowadzanych danych, które użytkownik może zaobserwować.
Literatura pokrewna
- Co Naprawdę Czyni Rozwojowców Frontendu Wartościowymi w Erze Sztucznej Inteligencji — Wyjaśnia, dlaczego zrozumienie sytuacji, zdolność osądu oraz myślenie na poziomie systemu są teraz ważniejsze niż biegłość w konkretnych frameworkach, gdy sztuczna inteligencja przejmuje rutynowe zadania związane z kodowaniem frontendu.
Powiązane