Strona główna / Artykuły / Najpierw zrób mniej pracy: lista kontrolna wydajności w React przed użyciem memoizacji

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.

2118 słów

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:

  1. 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.
  2. Zbyt dużo danych? Użyj stronicowania, filtrowania na serwerze oraz odłóż pobieranie danych do momentu, gdy będą one potrzebne.
  • Wartość zbyt wysoka? Sprawdź, czy jedna aktualizacja powoduje ponowne renderowanie większej części drzewa, niż to konieczne.
  • Niestabilne klucze listy? Używaj stabilnych identyfikatorów tam, gdzie elementy mogą się przesuwać lub zmieniać swoje miejsce w liście.
  • Nadmierna przetwarzanie? Zanim zaczniesz optymalizować, sprawdź, czy zadania można przenieść na serwer lub pominąć.
  • Funkcje uruchamiane zbyt wcześnie? Ładowaj w tle duże lub rzadko używane moduły.
  • Nieuzasadniona memoizacja? Nie dodawaj 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ą.
  • Zanim zadasz pytanie o optymalizację czegoś, sprawdź najpierw, czy w ogóle jest to konieczne.
  • Literatura pokrewna