Jak przyspieszyć swoją aplikację React aż o 50%?

11/10/2024 Frontend

Mateusz Kędziora

image

Optymalizacja wydajności w React - wszystko, co musisz wiedzieć

Cześć! Dziś zabiorę Cię w fascynującą podróż przez świat optymalizacji aplikacji React. Jako programista z wieloletnim doświadczeniem w tworzeniu aplikacji frontendowych, często spotykam się z pytaniem: “Dlaczego moja aplikacja działa wolno?”. To pytanie, które prawdopodobnie zadaje sobie każdy developer na pewnym etapie swojej przygody z React.

W tym artykule pokażę Ci sprawdzone techniki optymalizacji, które stosujemy w produkcyjnych aplikacjach. Nie będę Cię zanudzać teoretycznymi rozważaniami - skupimy się na praktycznych rozwiązaniach, które możesz wdrożyć już dzisiaj.

Dlaczego optymalizacja jest tak ważna?

Zanim zagłębimy się w techniczne szczegóły, chcę Ci uświadomić skalę problemu. Wyobraź sobie, że tworzysz aplikację e-commerce. Każda dodatkowa sekunda ładowania może kosztować Cię utratę potencjalnego klienta. Badania pokazują, że już 3-sekundowe opóźnienie w ładowaniu strony powoduje, że 53% użytkowników mobilnych rezygnuje z odwiedzin. To przekłada się bezpośrednio na Twoje przychody!

Co więcej, Google uwzględnia szybkość ładowania strony w swoich algorytmach rankingowych. Wolna strona to nie tylko frustracja użytkowników, ale także niższa pozycja w wynikach wyszukiwania.

1. Memoizacja - Twój najlepszy przyjaciel

Memoizacja to technika, która może dramatycznie przyspieszyć Twoją aplikację. Wyobraź sobie, że masz sklep internetowy z setkami produktów. Za każdym razem, gdy użytkownik zmienia kategorię, Twoja aplikacja musi przefiltrować te produkty. Bez memoizacji, React będzie wykonywał te obliczenia przy każdym renderze, nawet jeśli lista produktów i kategoria się nie zmieniły!

Spójrzmy na praktyczny przykład:

// Przed optymalizacją
const ProductList = ({ products, category }) => {
  const filteredProducts = products.filter(
    (product) => product.category === category
  );

  return (
    <div>
      {filteredProducts.map((product) => (
        <ProductItem key={product.id} {...product} />
      ))}
    </div>
  );
};

W powyższym kodzie, za każdym razem gdy komponent się przerenderuje (na przykład gdy zmieni się jakiś stan w komponencie rodzica), funkcja filter będzie wykonywana od nowa. Przy dużej liczbie produktów może to powodować zauważalne spowolnienie.

Teraz zobaczmy, jak możemy to zoptymalizować:

// Po optymalizacji
const ProductList = ({ products, category }) => {
  const filteredProducts = useMemo(() => {
    return products.filter((product) => product.category === category);
  }, [products, category]);

  return (
    <div>
      {filteredProducts.map((product) => (
        <ProductItem key={product.id} {...product} />
      ))}
    </div>
  );
};

Co się zmieniło? Dodaliśmy useMemo, który “zapamięta” wynik filtrowania i zwróci go ponownie tylko wtedy, gdy zmienią się products lub category. To oznacza, że jeśli komponent przerenderuje się z innego powodu (na przykład zmiana stanu w komponencie rodzica), nie będziemy niepotrzebnie wykonywać kosztownej operacji filtrowania.

Jest to szczególnie ważne w dwóch przypadkach:

  1. Gdy mamy do czynienia z dużą ilością danych
  2. Gdy operacje na danych są złożone obliczeniowo

2. Lazy Loading - sztuka lenistwa w dobrym stylu

Lazy loading to technika, która może znacząco przyspieszyć pierwsze ładowanie Twojej aplikacji. Wyobraź sobie, że masz w swojej aplikacji panel administracyjny, do którego większość użytkowników nigdy nie wejdzie. Dlaczego więc ładować jego kod dla wszystkich?

// Przed optymalizacją
import HeavyComponent from "./HeavyComponent";

// Po optymalizacji
const HeavyComponent = React.lazy(() => import("./HeavyComponent"));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <HeavyComponent />
    </Suspense>
  );
}

Ten prosty zabieg może zmniejszyć początkowy bundle JavaScript nawet o kilkadziesiąt procent! Co dokładnie się tutaj dzieje?

  1. Zamiast importować komponent standardowo, używamy React.lazy()
  2. Komponent będzie załadowany dopiero wtedy, gdy będzie potrzebny
  3. W trakcie ładowania pokazujemy komponent zastępczy (LoadingSpinner)
  4. Suspense zarządza stanem ładowania za nas

To szczególnie przydatne w:

  • Rozbudowanych dashboardach
  • Aplikacjach z wieloma routami
  • Komponentach, które nie są widoczne na pierwszym ekranie

3. Virtualizacja list - gdy mniej znaczy więcej

Virtualizacja to technika, która pozwala nam renderować tylko te elementy listy, które są aktualnie widoczne na ekranie. To jak zakładanie okularów na nos - widzisz tylko to, co jest w Twoim polu widzenia, ale wiesz, że reszta świata też istnieje.

import { FixedSizeList } from "react-window";

const ProductList = ({ items }) => {
  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductItem product={items[index]} />
    </div>
  );

  return (
    <FixedSizeList
      height={400}
      width="100%"
      itemCount={items.length}
      itemSize={50}
    >
      {Row}
    </FixedSizeList>
  );
};

Ten kod robi coś magicznego:

  1. Zamiast renderować wszystkie 1000 produktów naraz, renderuje tylko te, które użytkownik widzi (plus kilka powyżej i poniżej dla płynnego przewijania)
  2. FixedSizeList zarządza renderowaniem elementów za nas
  3. itemSize określa wysokość każdego elementu (możesz też użyć VariableSizeList dla elementów o różnej wysokości)
  4. Scrollowanie jest płynne, bo browser nie musi zarządzać tysiącami elementów DOM

Jest to nieocenione w przypadku:

  • List z tysiącami elementów
  • Nieskończonego scrollowania
  • Tabel z dużą ilością danych

4. Optymalizacja re-renderów - walka z niepotrzebnymi aktualizacjami

Re-rendery to jeden z głównych zabójców wydajności w React. Czasami komponenty renderują się ponownie, mimo że ich dane się nie zmieniły. To jak robienie prania, które jest już czyste - kompletnie niepotrzebne!

// Przed optymalizacją
const ProductCard = ({ product, onAddToCart }) => {
  return (
    <div className="product-card">
      <h2>{product.name}</h2>
      <button onClick={() => onAddToCart(product.id)}>Dodaj do koszyka</button>
    </div>
  );
};

W tym przypadku, za każdym razem gdy komponent rodzic się przerenderuje, nasz ProductCard też się przerenderuje, nawet jeśli product się nie zmienił. Co więcej, przy każdym renderze tworzona jest nowa funkcja anonimowa dla onClick.

Oto zoptymalizowana wersja:

// Po optymalizacji
const ProductCard = React.memo(({ product, onAddToCart }) => {
  // Memoizujemy funkcję callback
  const handleAddToCart = useCallback(() => {
    onAddToCart(product.id);
  }, [product.id, onAddToCart]);

  return (
    <div className="product-card">
      <h2>{product.name}</h2>
      <button onClick={handleAddToCart}>Dodaj do koszyka</button>
    </div>
  );
});

Co się zmieniło?

  1. React.memo sprawia, że komponent przerenderuje się tylko wtedy, gdy jego propsy się zmienią
  2. useCallback zapobiega tworzeniu nowej funkcji przy każdym renderze
  3. Wydajność poprawia się szczególnie, gdy mamy wiele instancji tego komponentu

5. Optymalizacja stanu aplikacji - porządek w chaosie

Sposób organizacji stanu może mieć ogromny wpływ na wydajność. Wyobraź sobie, że masz wielki worek z różnymi rzeczami - trudno w nim cokolwiek znaleźć. Tak samo jest ze stanem aplikacji!

// Źle - jeden duży stan
const [state, setState] = useState({
  user: null,
  products: [],
  cart: [],
  filters: {},
  sorting: "price",
});

// Dobrze - podzielony stan
const [user, setUser] = useState(null);
const [products, setProducts] = useState([]);
const [cart, setCart] = useState([]);
const [filters, setFilters] = useState({});
const [sorting, setSorting] = useState("price");

Dlaczego ta zmiana jest tak ważna?

  1. Każda zmiana w dużym obiekcie stanu powoduje przerenderowanie całego komponentu
  2. Przy podzielonym stanie, zmiana jednego elementu nie wpływa na inne
  3. Łatwiej zarządzać aktualizacjami poszczególnych części stanu
  4. Kod staje się bardziej przewidywalny i łatwiejszy w debugowaniu

6. Optymalizacja CSS - piękno i wydajność mogą iść w parze

CSS często jest pomijany w dyskusjach o wydajności, ale może mieć ogromny wpływ na performance Twojej aplikacji. Szczególnie w React, gdzie style często są ściśle powiązane z komponentami.

/* Źle - zbyt specyficzny selektor */
.header .navigation .menu-item .link {
  color: blue;
}

/* Dobrze - płaski selektor */
.menu-link {
  color: blue;
}

/* Wykorzystanie CSS Grid dla lepszej wydajności layoutu */
.products-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
}

Co tu się dzieje?

  1. Unikamy zagnieżdżonych selektorów, które są wolniejsze w interpretacji
  2. Używamy nowoczesnych technik layoutu (Grid), które są zoptymalizowane przez przeglądarki
  3. Tworzymy bardziej przewidywalny i łatwiejszy w utrzymaniu kod
  4. Zmniejszamy ryzyko konfliktów nazw klas

7. Debugowanie wydajności - znajdź i napraw problemy

Nie możesz poprawić tego, czego nie możesz zmierzyć. React DevTools to Twoje oko na wydajność aplikacji.

// Dodaj to w kodzie podczas developmentu
if (process.env.NODE_ENV === "development") {
  const whyDidYouRender = require("@welldone-software/why-did-you-render");
  whyDidYouRender(React);
}

Ten prosty kawałek kodu:

  1. Pokazuje Ci dokładnie, które komponenty się renderują i dlaczego
  2. Pomaga zidentyfikować niepotrzebne re-rendery
  3. Działa tylko w trybie development, więc nie wpływa na produkcję
  4. Jest nieocenionym narzędziem w optymalizacji

8. Optymalizacja obrazów - bo pierwszy kontakt ma znaczenie

Obrazy często stanowią największą część danych przesyłanych do przeglądarki. Proper lazy loading może dramatycznie przyspieszyć pierwsze ładowanie strony.

const Image = ({ src, alt }) => {
  return (
    <img
      loading="lazy"
      src={src}
      alt={alt}
      onLoad={() => console.log("Obraz załadowany")}
    />
  );
};

Co zyskujemy?

  1. Obrazy ładują się dopiero, gdy są blisko viewportu
  2. Pierwsze ładowanie strony jest szybsze
  3. Oszczędzamy transfer danych użytkownika
  4. Mamy kontrolę nad procesem ładowania (możemy dodać placeholdery, szkielety itp.)

Podsumowanie

Optymalizacja to nie jednorazowa akcja, ale ciągły proces. Pamiętaj o kilku kluczowych zasadach:

  1. Mierz przed optymalizacją

    • Używaj React DevTools
    • Monitoruj metryki Web Vitals
    • Testuj na rzeczywistych urządzeniach
  2. Rozpocznij od największych problemów

    • Najpierw zidentyfikuj “wąskie gardła”
    • Skup się na optymalizacjach, które dają największe korzyści
    • Nie optymalizuj tego, co już jest szybkie
  3. Nie optymalizuj przedwcześnie

    • Napisz pierwszy prosty kod
    • Zmierz jego wydajność
    • Optymalizuj tylko wtedy, gdy to konieczne
  4. Testuj na urządzeniach docelowych

    • Nie wszyscy użytkownicy mają najnowsze iPhone’y
    • Testuj na różnych przeglądarkach
    • Uwzględnij wolne połączenia internetowe

Mam nadzieję, że ten artykuł pomoże Ci w tworzeniu szybszych i bardziej wydajnych aplikacji React! Pamiętaj, że optymalizacja to maraton, nie sprint. Wprowadzaj zmiany stopniowo i zawsze mierz ich wpływ.

PS: Najlepsza optymalizacja to często ta, której nie musisz robić - dobrze przemyślana architektura aplikacji od początku może zaoszczędzić Ci wielu problemów w przyszłości. Dlatego tak ważne jest, aby myśleć o wydajności już na etapie planowania aplikacji.

Jeśli masz jakiekolwiek pytania zapraszam do sekcji komentarzy.

Bonus: Checklista optymalizacyjna

Czy używasz React.memo tam, gdzie trzeba?
Czy twoje hooki są zoptymalizowane?
Czy kod jest odpowiednio podzielony?
Czy listy są wydajnie renderowane?
Czy obrazy są zoptymalizowane?

Powodzenia w optymalizacji! 🚀

Polecane artykuły