Najpierw zrób mniej pracy: lista kontrolna wydajności w React przed użyciem memoizacji
Zmniejsz koszty aplikacji React, unikając nadmiernych zadań: używaj opóźnień przy wyszukiwaniu, stronicuj dane, umieszczaj stan w jednym miejscu, stosuj stabilne klucze, przenoś obliczenia na stronę serwera i używaj ładowania opóźnionego, a następnie zastosuj mechanizm memoizacji w razie potrzeby.
Gdy aplikacja React działa wolno, pierwszą reakcją jest użycie funkcji useMemo, useCallback lub React.memo. Narzędzia te przyspieszają wykonywanie istniejących zadań, ale w wielu aplikacjach prawdziwy koszt wynika z operacji, których w ogóle nie należało wykonywać: zbędnych żądań, zbyt dużych obciążeń danych, aktualizacji stanu wpływających na połowę struktury aplikacji oraz kodu, o który użytkownicy jeszcze nie prosili. Ten przewodnik pokazuje siedem sposobów na usunięcie niepotrzebnych operacji przed optymalizacją tego, co pozostało, wraz z listą kontrolną, którą można wykorzystać podczas przeglądania kodu.
Główne pytanie, które powinno kierować nami przez cały proces, jest proste: czy tę operację można w ogóle uniknąć?
Liczniejsze żądania: wykorzystaj funkcję debounce dla pola wyszukiwania
Pola wyszukiwania to klasyczne źródło marnowanych żądań. Prosty mechanizm obsługi wywołuje API przy każdej zmianie:
const handleSearch = (value) => {
fetchUsers(value);
};
Wpisanie słowa „React” do tego pola powoduje jedno żądanie za każdy nacisk klawisza:
R
Re
Rea
Reac
React
Pięć przejść tam i z powrotem przy jednym wyszukiwaniu, z których cztery zwracają wyniki, którymi nikt się nie zajmie. Lepszym podejściem jest czekanie, aż użytkownik na chwilę przerwie działanie, i dopiero wtedy wysłanie zapytania. To właśnie robi mechanizm debouncingu: każde nowe wezwanie resetuje timer, a zagnieżdżona funkcja jest wykonywana tylko wtedy, gdy timer wygaśnie bez przerwy.
const handleSearch = debounce((value) => {
fetchUsers(value);
}, 300);
Przy oknie czasowym 300 ms szybcy klawiści wywołują tylko jedną prośbę na końcu. Dzięki temu oszczędza się ruch w sieci, obciążenie serwera oraz przetwarzanie na stronie klienta odrzuconych odpowiedzi. Należy zauważyć, że nic tutaj nie wpływa na renderowanie; korzyść pochodzi wyłącznie z uniknięcia niepotrzebnych działań.
Jedną z zastrzeżeń przy używaniu takiego pomocnika wewnątrz komponentu jest to, że jeśli debounce(...) zostanie wywołane bezpośrednio w ciele komponentu, przy każdym renderowaniu tworzy się nowa funkcja z efektem opóźnienia (oraz nowy timer), co psuje funkcjonalność tego efektu. Należy ją utworzyć tylko raz, na przykład za pomocą useMemo lub useRef, albo zastosować poniższy wzorzec oparty na efektach.
Komponent wyszukiwania z efektem opóźnienia
Tę samą ideę można zrealizować wyłącznie za pomocą elementów podstawowych React, bez żadnej biblioteki pomocniczej. Zacznij od importów oraz dwóch stanów: aktualnego tekstu wprowadzanego przez użytkownika i pobranych użytkowników.
import { useEffect, useState } from "react";
function UserSearch() {
const [search, setSearch] = useState("");
const [users, setUsers] = useState([]);
Efekt jest wykonywany za każdym razem, gdy zmienia się wartość search. Zamiast natychmiast wykonać operację, planuje się ją za pomocą setTimeout. W funkcji zwrotnej zapytanie puste lub składające się wyłącznie ze spacji usuwa wyniki i zakończa proces bez wysyłania żadnego żądania.
useEffect(() => {
const timer = setTimeout(async () => {
if (!search.trim()) {
setUsers([]);
return;
}
W przypadku rzeczywistej zapytania funkcja callback prosi o dostarczenie dopasowanych użytkowników, kodując termin wyszukiwania tak, aby specjalne znaki nie zakłóciły adresu URL:
const response = await fetch(
`/api/users?search=${encodeURIComponent(search)}`
);
Następnie analizuje plik JSON i przechowuje wynik, wszystko to wciąż w ramach limitu 300 ms:
const data = await response.json();
setUsers(data);
}, 300);
Funkcja czyszcząca jest tym miejscem, gdzie dokonuje się faktycznego odczekiwania przed ponownym wykonaniem operacji. React uruchamia ją przed ponownym wywołaniem efektu, dzięki czemu każde naciśnięcie klawisza anuluje poprzedni, czekający timer:
return () => clearTimeout(timer);
}, [search]);
Na koniec komponent wyświetla kontrolowany polu wprowadzania powiązane z zmienną search:
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
/>
I wyświetla listę użytkowników, uporządkowaną według ich identyfikatorów:
{users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
Każda wpisana litera resetuje zegar, a żądanie jest wysyłane dopiero po 300 ms ciszy. Należy pamiętać, że technika debouncing zmniejsza liczbę wysyłanych żądań, ale nie gwarantuje, że zostaną one zrealizowane w odpowiedniej kolejności; wolna wcześniejsza odpowiedź nadal może przepisać nowszą. Jeśli jest to istotne dla Twojej interfejsu użytkownika, należy połączyć to z możliwością anulowania żądań, jak opisano w rozwiązywaniu problemów warunków wyścigowych, których nie da się rozwiązać za pomocą debouncing w interfejsach wyszukiwania.
Ogólnie rzecz biorąc: zapobieganie wykonywaniu operacji zwykle jest skuteczniejsze niż przyspieszanie tych już istniejących.
Pobieraj tylko te dane, których potrzebuje ekran
Kolejnym częstym problemem jest pobieranie znacznie większej ilości danych, niż jest to pokazane. Załóżmy, że punkt końcowy zwraca 10 000 użytkowników, podczas gdy interfejs pokazuje ich po 20 na raz. Przeglądarka musi nadal pobrać, przetworzyć i przechować je wszystkie w pamięci, a React musi pracować z znacznie większymi tablicami, niż to konieczne.
Gdzie tylko to możliwe, używaj stronicowania, aby każda prośba zawierała tylko jedną stronę:
API
↓
20 users
↓
Browser
↓
Display
Za każdym razem, gdy użytkownik przechodzi dalej, żądaj następnego fragmentu danych. Dzięki temu zmniejsza się zużycie sieci, zużycie pamięci, przetwarzanie po stronie klienta oraz ilość danych przepływających przez komponenty. Zasada ta obowiązuje również w przypadku nieskończonego przewijania i interfejsów opartych na kursorze. Zmiana sposobu pobierania danych często przynosi znacznie większe efekty niż optymalizacja pojedynczego komponentu.
Zachowuj szybko zmieniające się dane blisko ich użytkowników
Miejsce przechowywania stanu określa, jak duża część interfejsu zostanie ponownie wyrenderowana po jego zmianie. Rozważmy panel sterowania, który przechowuje tekst wyszukiwania:
function Dashboard() {
const [search, setSearch] = useState("");
return (
<>
<SearchBox value={search} onChange={setSearch} />
<Analytics />
<UserTable />
</>
);
}
Każde naciśnięcie klawisza aktualizuje Dashboard, w związku z czym Analytics i UserTable również są ponownie wyrenderowywane, mimo że żaden z nich nie używa wartości wyszukiwania. W przypadku dużego panelu sterowania ma to istotne konsekwencje. Jeśli stan jest potrzebny tylko niewielkiej części interfejsu, przeniesienie go do tej części (w tym przypadku bezpośrednio do SearchBox) ogranicza aktualizacje do miejsc, gdzie są one konieczne, i ułatwia zrozumienie struktury komponentów.
To nie jest reguła mówiąca, że stan zawsze należy przenosić na niższy poziom. Gdy kilka komponentów rzeczywiście potrzebuje tej samej wartości, ich najbliższy wspólny rodzic jest odpowiednim właścicielem stanu. Celem jest przechowywanie stanu na najniższym poziomie, który nadal jest wspólny dla wszystkich komponentów, które go faktycznie odczytują.
Daj elementom listy dynamicznej stabilne klucze
Listy to miejsce, gdzie małe szczegóły mają ogromny wpływ. Powszechnym rozwiązaniem jest używanie indeksu tablicy jako klucza:
{users.map((user, index) => (
<UserCard key={index} user={user} />
))}
Klucze oparte na indeksach nie zawsze są błędne. W przypadku listy statycznej, której kolejność nigdy się nie zmienia, działają one skutecznie. W przypadku listy dynamicznej stabilny identyfikator pochodzący z danych zapewnia każdemu elemencie trwałą tożsamość:
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
Różnica polega na tym, co reprezentuje klucz:
index → position
id → identity
Jeśli elementy mogą być dodawane, usuwane, przestawiane lub filtrowane, ich pozycje ulegają zmianie, a React dopasowuje niewłaściwe elementy: może ponownie renderować więcej treści, niż to konieczne, a co gorsza, może przypisać stan wewnętrzny jednego elementu innemu. Problem ten staje się widocznym błędem, gdy elementy listy zawierają pola wprowadzania danych, lokalny stan lub inne elementy interaktywne, takie jak pole tekstowe, które zachowuje wpisany wartość, podczas gdy wiersz poniżej się zmienia. Zawsze preferuj stabilny identyfikator, gdy lista może ulegać zmianom.
Kwestionuj procesowanie, a nie tylko jego szybkość
Intensywne transformacje po stronie klienta to kolejne źródło kosztów, których można uniknąć. W tym przypadku użytkownicy są filtrowani, sortowani i mapowani do nowych obiektów:
const filteredUsers = users
.filter((user) => user.isActive)
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => ({
...user,
displayName: user.name.toUpperCase()
}));
Przy kilku tysiącach rekordów powtarzanie tego procesu przy każdym renderowaniu jest kosztowne. Można to rozwiązać poprzez zapisywanie wyników w pamięci tymczasowej, ale najpierw należy sprawdzić, czy klient w ogóle musi wykonywać te operacje. Często sam endpoint może filtrować aktywnych użytkowników, baza danych może zajmować się sortowaniem dzięki indeksom, które sprawiają, że jest to tanie, a paginacja może zmniejszyć zbiór danych, dzięki czemu pozostała obróbka staje się prosta. Zmniejszenie ilości danych wejściowych zwykle jest skuteczniejsze niż przyspieszenie obliczeń.
Ładowanie funkcji na żądanie
W dużych aplikacjach znajdują się funkcje, których wielu użytkowników w danym sesji nigdy nie otwiera. Strona z raportami, na przykład, może być zupełnie oddzielna od głównego panelu sterowania. Zamiast pakować ją do początkowego pobierania, należy ładować ją w sposób opóźniony:
const Reports = lazy(() => import("./Reports"));
Za pomocą React.lazy moduł jest pobierany po raz pierwszy podczas renderowania komponentu, co musi odbywać się wewnątrz obszaru Suspense, który pokazuje alternatywne rozwiązanie w czasie ładowania. Rozwiązanie to jest szczególnie przydatne w aplikacjach z wieloma trasami, dużymi obszarami funkcjonalnymi, ciężkimi zależnościami takimi jak biblioteki do tworzenia wykresów lub edytorów, czy też ekranami, które odwiedza niewielu użytkowników.
Należy jasno zrozumieć, co to przynosi: mniejszą początkową wielkość pliku JavaScript oraz szybsze pierwsze załadowanie. Nie sprawia to, że kod w komponencie lazy działa szybciej po załadowaniu, a dodatkowo powoduje krótkie opóźnienie podczas pierwszego otwarcia danej funkcji.
Memoizacja z uzasadnieniem
Powszechnym błędem jest domyślne stosowanie API optymalizacyjnych we wszystkich częściach kodu, na przykład otaczanie każdego obsługiwanego zdarzenia:
const handleClick = useCallback(() => {
setSelectedUser(id);
}, [id]);
Lub każdej wartości pochodnej:
const data = useMemo(() => {
return processData(users);
}, [users]);
Te hooki mają uzasadnione zastosowania, ale każdy z nich dodaje kod, tablice zależności, które muszą być poprawne, oraz własny niewielki koszt wykonywania. Zanim je dodasz, sprawdź:
- Czy obliczenia są rzeczywiście kosztowne?
- Czy są wykonywane często?
- Czy wynik jest ponownie używany podczas różnych renderów?
- Czy zmemorizowane dziecko lub efekt zależy od stabilnego referencji?
- Czy zmiana spowoduje zauważalną różnicę?
Jeśli odpowiedzi są głównie „nie”, prostszy kod jest lepszym kodem. Wydajność pochodzi od właściwej optymalizacji w odpowiednim miejscu, a nie od ilości kodu służącego do optymalizacji. React Compiler, który został wdrożony w niektórych projektach, automatyzuje dużą część tego procesu memoizacji, co jest kolejnym powodem, by nie pisać go ręcznie we wszystkich miejscach; zobacz co optymalizuje React Compiler i co pozostawia do załatwienia.
Lista kontrolna przy przeglądaniu
Należy przejrzeć te pytania podczas audytu funkcji w React:
- Niepotrzebne żądania? Sprawdź liczba wywołań na każde naciśnięcie klawisza, powtarzające się żądania oraz próby pobrania danych, które obecnie nie są wyświetlane.
- Zbyt dużo danych? Użyj stronicowania, filtrowania na serwerze oraz odłóż pobieranie danych do momentu, gdy będą one potrzebne.
useMemo, useCallback ani React.memo tylko dlatego, że istnieją.Wydajność to cały proces, a nie tylko renderowanie
Wydajność React dotyczy nie tylko samego React. Koszty gromadzą się na całej ścieżce przetwarzania:
API calls
↓
Amount of data
↓
State updates
↓
Component structure
↓
Data processing
↓
Rendering
↓
Bundle size
Koncentracja wyłącznie na warstwie renderowania może przesłaniać znacznie większy problem na wyższych poziomach łańcucha. Skrócenie czasu renderowania o kilka milisekund niewiele daje, jeśli strona nadal wysyła zbędne żądania, a zapamiętywanie komponentów nie pomaga przy pobieraniu tysięcy rekordów, których nigdy nie wyświetlamy. Zacznij od szczytu łańcucha i pracuj w dół.
Główne wnioski
- Usunięcie zadań (żądań, bajtów, aktualizacji, kodu) zwykle przynosi większe efekty niż tylko przyspieszenie ich wykonywania.
- Zastosuj mechanizm debouncing do żądań generowanych przez dane wejściowe i połącz go z możliwością anulowania, gdy to ma znaczenie.
- Pozwól serwerowi filtrować, sortować i paginować dane; wysyłaj do klienta tylko to, co ma być wyświetlone.
- Zlokalizuj stan na najniższym poziomie, który obsługuje wszystkich odczytujących go użytkowników, oraz indeksuj dynamiczne listy według ich identyfikatorów.
- Zastosuj ładowanie opóźnione, aby uzyskać lżejsze pierwsze załadowanie, a uciekaj się do zapamiętywania tylko wtedy, gdy konkretne koszty tego uzasadniają.
Literatura pokrewna
- Co optymalizuje kompilator React i co pozostawia do załatwienia — Dowiedz się, jakie zadania związane z wydajnością automatyzuje kompilator React, dlaczego powolne API i duże pliki pozostają twoją odpowiedzialnością oraz jak bezpiecznie wdrożyć go w istniejącej bazie kodu React.
- Diagnozowanie problemów z wydajnością w React poza czasem odpowiedzi API — Przeczytaj, dlaczego szybkie API nie gwarantują szybkiego interfejsu użytkownika oraz jak renderowanie, rozmiar plików i organizacja danych w tle wpływają na rzeczywistą wydajność aplikacji React.
- Utrzymanie strony odpowiedniej przy zmianach: Kiedy przenieść prace CPU do web workera — Dowiedz się, dlaczego intensywne pętle zamykają interfejs przeglądarki, w jaki sposób wzorzec komunikacji Web Workera przenosi te obowiązki na inny wątek oraz jak zdecydować, czy dana zadanie w ogóle wymaga użycia workera.
- Budżety wydajności dla JavaScript: Implementacja ograniczeń pakietów w procesie integracji — Dowiedz się, dlaczego JavaScript kosztuje znacznie więcej niż czas jego pobierania, jak zdefiniować realistyczny budżet wydajności oraz jak sprawić, by webpack odrzucał projekty przekraczające te limity.
- Czytelny JavaScript przy przykładzie: Dziesięć refaktoryzacji przed i po — Dziesięć małych refaktoryzacji kodu w JavaScript i React, od nazewnictwa oraz obiektów parametrów po obsługę błędów i formatowanie, które ułatwiają kolejnemu programiście czytanie i modyfikację kodu.
- Dlaczego React.lazy() wymaga domyślnego exportu i jak załadować te oznaczone — Zrozum, co otrzymuje React.lazy() od dynamicznego importu, dlaczego eksporty oznaczone go psują, jak przekierować je na domyślne oraz w jaki sposób wpisują się w to Suspense i dzielenie kodu.
- Projektowanie frontendów typu offline-first jako replik: kolejki, próby ponowne, konflikty — Dowiedz się, dlaczego podejście offline-first przekształca przeglądarkę w replikę danych oraz jak lokalne magazyny, trwałe kolejki mutacji, idempotentne próby ponowne i zasady rozwiązywania konfliktów współpracują ze sobą.