Strona główna / Artykuły

Komponenty React muszą być czyste: idea stojąca za StrictMode i kompilatorem

Dlaczego React oczekuje czystych komponentów, jak podwójne renderowanie w StrictMode ujawnia nieczystość, gdzie powinny znajdować się efekty uboczne oraz dlaczego kompilator React polega na czystości.

Wydawca: REACTAPP.TOP9 min czytania2023 słów

Źródła: React Components Must Be Pure — the Secret Behind StrictMode and the Compiler

Do tej pory historia Reacta polegała na tym, że „piszesz w ten sposób, a na ekranie pojawia się treść”. Istnieje jednak jedna zasada, której React w tle oczekuje od każdego komponentu, a która wyjaśnia dwie kwestie mylące większość początkujących: dlaczego StrictMode wywołuje twój komponent dwa razy oraz jak kompilator Reacta może tak skutecznie optymalizować kod. Tą zasadą jest czystość.

Czym jest funkcja czysta

Pamiętajmy matematykę: y = f(x) = 2x zwraca 6 dla wartości wejściowej 3. Robi to wczoraj, dziś i przy setnym wywołaniu, przy czym nigdy nie próbuje zmienić niczego poza samym sobą.

Taka funkcja jest czysta, co oznacza, że spełnia dwie warunki.

  1. To samo wejście zawsze daje to samo wyjście.
  2. Nie ma efektów ubocznych, więc nic poza jej własnym zakresem nie ulega zmianie.

Kod sprawia, że jest to jaśniejsze. double poniżej jest czysty, natomiast addToTotal nie jest.

// ✅ pure function — same input, same output, and it touches nothing outside
function double(x: number): number {
  return x * 2;
}
double(3); // 6
double(3); // 6 — six no matter how many times you call it

// 🔴 impure function — it changes the outer variable `total` (a side effect)
let total = 0;
function addToTotal(x: number): number {
  total += x;     // side effect!
  return total;   // the result changes on every call
}
addToTotal(3); // 3
addToTotal(3); // 6 — same input, different result

Komponenty również powinny być czyste

React zakłada, że każdy komponent to funkcja czysta. Przy tych samych propach i stanie musi za każdym razem zwracać ten sam JSX.

type Props = { name: string };

// ✅ pure — same name, always the same result
function Greeting({ name }: Props) {
  return <h1>Hello, {name}!</h1>;
}

Jeśli przypiszesz temu komponentowi nazwę „Jane”, zawsze otrzymasz <h1>Hello, Jane!</h1>. React polega na tej przewidywalności, aby efektywnie aktualizować ekran.

Łamanie zasady: modyfikacja elementów zewnętrznych podczas renderowania

Problemy pojawiają się, gdy komponent zmienia coś poza swoją strukturą podczas renderowania.

let count = 0; // a variable outside the component

// 🔴 not pure — changes an external variable while rendering
function Counter() {
  count = count + 1; // side effect!
  return <p>{count}</p>;
}

Counter zwiększa wartość zmiennej zewnętrznej count przy każdym renderowaniu, więc jego wynik zależy od częstotliwości wywołania: 1 za pierwszym razem, 2 za drugim. Obie warunki czystości nie są spełnione. React może renderować komponent kilka razy lub w innej kolejności, więc taki komponent powoduje trudne do przewidzenia błędy.

Spróbuj i zobacz, dlaczego otrzymujesz 2

Uruchom ten Counter – pierwszą rzeczą na ekranie będzie 2, a nie 1, i nie wzrasta ono później. Na początku to zagadkowe, ale zrozumienie tego jest doskonałą lekcją o czystości kodu.

Dlaczego 2? W trybie rozwojowym StrictMode wywołuje funkcję komponentu dwa razy podczas montowania, więc jeden proces montowania uruchamia Counter dwa razy.

  • Pierwsze wywołanie: count zmienia się z 0 na 1, a wynikiem jest <p>1</p>.
  • Drugi wywołanie: count zmienia się z 1 na 2, a wynik to <p>2</p>.

Na ekranie pokazany jest drugi wynik. Gdy StrictMode jest wyłączone, funkcja jest wykonywana tylko raz i zobaczylibyśmy wartość 1.

Dlaczego zatrzymuje się na 2, zamiast iść dalej na 3, 4 itd.? Komponent jest wykonywany tylko wtedy, gdy React go ponownie renderuje. Ten komponent nie ma stanu, a jego element nadrzędny też nie jest renderowany, więc jest wykonywany podczas początkowego montowania (dwa razy, dzięki StrictMode), a potem przestaje działać, przez co count pozostaje na tym samym poziomie.

Aby zobaczyć, jak zmienia się ta wartość, należy wymusić ponowny render. W następnym przykładzie robi się to poprzez kliknięcie przycisku. (useState zostanie omówione bardziej szczegółowo później, więc na razie można to traktować jako narzędzie, które ponownie renderuje ekran po naciśnięciu przycisku.)

import { useState } from 'react';

let count = 0;

function Counter() {
  const [, setTick] = useState(0); // a device to trigger re-renders
  count = count + 1; // 🔴 changes an external variable on every render
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setTick((n) => n + 1)}>Re-render</button>
    </div>
  );
}

Każde naciśnięcie przerysowuje Counter poprzez jego własną zmianę stanu, a StrictMode wywołuje to dwa razy za każdym razem, więc liczba wzrasta o dwa: 2, 4, 6 i tak dalej. Bez StrictMode rosnęłaby o jeden na raz.

Jedna uwaga dotycząca tej demonstracji. Dlaczego w ogóle stan znajduje się wewnątrz Counter? Gdyby przenieść go do komponentu nadrzędnego i zostawić Counter jako komponent potomny bez właściwości, naciśnięcie przycisku nie zmieniłoby wyświetlanej liczby 2. Komponent, który nie przyjmuje żadnych danych wejściowych, to taki, którego wynik nigdy nie może się różnić, dlatego kompilator React go cacheuje i pomija przerysowanie. Aby zobaczyć tę różnicę, trzeba wpłynąć na zmienną zewnętrzną znajdującą się wewnątrz komponentu, który faktycznie przerysowuje się. Co ciekawe, to zachowanie samo w sobie jest dobrą ilustracją faktu, że kompilator zakłada czystość komponentów. Sposób, w jaki kompilator cacheuje komponenty, zasługuje na osobną dyskusję.

Oto sedno sprawy. Czysty komponent powinien dawać ten sam wynik bez względu na to, ile razy zostanie wywołany, ale ten konkretny zależy od liczby wywołań. StrictMode celowo wywołuje go dwa razy, aby pokazać ci tę nieczystość.

Rozwiązaniem jest pozostawienie elementów zewnętrznych w spokoju. Podczas renderowania oblicz wynik wyłącznie na podstawie własnych propów i stanu.

// ✅ pure — computed from input (props) alone
function Counter({ count }: { count: number }) {
  return <p>{count}</p>;
}

Możesz zmienić to, co stworzyłeś podczas renderowania

Czystość nie oznacza braku żadnych zmian. Oznacza to nieruszanie elementów, które istniały przed rozpoczęciem renderowania, takich jak zmiennych zewnętrznych, atrybutów i stanu. Zmienne lub tablice, które tworzysz od nowa podczas renderowania, możesz modyfikować według własnego uznania. To po prostu obliczenia, a nie efekt uboczny.

function ProductList({ products }: { products: Product[] }) {
  // ✅ this array was just created in this render — handle it however you like
  const sorted = [...products].sort((a, b) => a.price - b.price);
  return (
    <ul>
      {sorted.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

Ważne jest to, że kopia została utworzona za pomocą [...products]. Sortowanie oryginalnego arraya products, który trafia jako props, zmieniłoby te props i złamałoby zasadę. Kopia została stworzona podczas tego renderowania, więc nie ma problemu.

Pierwsza tajemnica: dlaczego StrictMode uruchamia operacje dwa razy

W fazie rozwoju StrictMode celowo wywołuje funkcje komponentów dwa razy, aby wykryć komponenty, które nie są czyste. To ten sam wrapper <StrictMode> umieszczony wokół aplikacji podczas jej konfiguracji.

// main.tsx — in development, the components inside this render twice
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

Komponent czysty daje identyczne wyniki przy obu uruchomieniach, więc nic się nie dzieje. Komponent taki jak Counter powyżej, który modyfikuje zmienną zewnętrzną, od razu pokazuje oznaki ostrzegawcze: wynik się zmienia lub wartość się podwaja. StrictMode ujawnia te objawy podczas rozwoju, na długo przed tym, zanim staną się trudnymi do rozwiązania zagadkami po uruchomieniu aplikacji.

Zatem podwójne uruchomienie to nie błąd – to kontrola stanu. Jeśli dwukrotne uruchomienie nie powoduje problemów, komponent jest czysty. W wersji produkcyjnej uruchamia się on tylko raz.

Gdzie należą efekty uboczne

Zadasz słuszne pytanie: logowanie, wysyłanie danych na serwer i uruchamianie timera są oczywiście konieczne, więc gdzie one się znajdują? Gdziekolwiek indziej niż w samym procesie renderowania.

  • Reakcja na działania użytkownika, takie jak kliknięcia i wpisywanie tekstu, powinna znajdować się w obsługiwaczach zdarzeń.
  • Synchronizacja z zewnętrznym systemem po narysowaniu ekranu powinna znajdować się w sekcji Efekty.

Załóżmy, że rejestrujesz wyświetlenie strony bezpośrednio w ciele renderowania: będzie to wykonywane przy każdym renderowaniu, a podczas rozwoju w trybie StrictMode dwa razy częściej. Przenieś tę funkcję do obsługi zdarzeń, a będzie ona wykonywana tylko wtedy, gdy ktoś faktycznie kliknie.

// 🔴 side effect during rendering — fires on every render
function ProductPage({ id }: { id: number }) {
  logView(id); // a side effect in the render body — not allowed
  return <h1>Product {id}</h1>;
}

// ✅ in an event handler — runs only at the moment the user clicks
function BuyButton({ id }: { id: number }) {
  return <button onClick={() => logPurchase(id)}>Buy</button>;
}

Zachowuj czystość treści renderowanej i przenoś efekty uboczne w odpowiednie miejsca. Ta zasada pojawia się wielokrotnie.

Druga tajemnica: dlaczego kompilator polega na czystości

Jeśli masz włączony React Compiler, zapewne pamiętasz, że przejmuje on kontrolę nad ręcznie napisanymi funkcjami useMemo i useCallback. To właśnie czystość umożliwia takie działanie.

Kompilator działa przy założeniu, że komponenty są czyste. Ponieważ ten sam wprowadzony danych zawsze daje ten sam wynik, uważa za bezpieczne ponowne użycie poprzedniego rezultatu, gdy dane się nie zmieniły, i automatycznie wprowadza mechanizm memoizacji. Jeśli komponent zmienił coś podczas renderowania, ponowne użycie może doprowadzić do błędnych wyników, dlatego kompilator pomija optymalizację w takich komponentach. Zasada ESLint włączona razem z kompilatorem pokazuje, który komponent jest winowajcą.

// 🔴 not pure → the compiler skips optimization (ESLint points at this component)
let renderCount = 0;
function RenderCounter() {
  renderCount++; // mutating an external variable during render — a violation
  return <p>{renderCount}</p>;
}

// ✅ pure → the compiler memoizes automatically (reuse the previous result when inputs match)
function Label({ text }: { text: string }) {
  return <p>{text}</p>;
}

function App() {
  return (
    <>
      <RenderCounter />
      <Label text="Pure Component" />
    </>
  );
}

export default App;

Komponent napisany w sposób czysty pojawia się w DevTools z etykietą „Memo”, mimo że nie użyto instrukcji useMemo.

Krótko mówiąc, czystość jest warunkiem wstępnym, aby kompilator mógł ci pomóc. Pisz czyste komponenty, a poprawa wydajności nastąpi automatycznie. Jedyne, o co należy dbać, to pisanie czystego kodu.

Przedstawienie interfejsu jako drzewa

Kolejna perspektywa dopełnia ten obraz. Komponenty zawierają inne komponenty: App zawiera ProductList, który z kolei zawiera kilka elementów ProductCard. To ugrupowanie naturalnie tworzy drzewo.

function App() {
  return <ProductList products={products} />;
}

function ProductList({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </ul>
  );
}

Gdy komponenty są układane warstwowo, ten kod tworzy następujące drzewo.

App
└─ ProductList
   ├─ ProductCard
   ├─ ProductCard
   └─ ProductCard

W miarę postępu widok drzewa staje się coraz ważniejszy. Dane w postaci propów przepływają w nim od góry do dołu, a jeśli chodzi o stan, o którym dowiesz się później, decyzja o tym, gdzie go umieścić w drzewie, staje się istotą Twojego projektu. Pamiętaj o zasadzie „ekran to drzewo komponentów” i nawet złożone interfejsy użytkownika można analizować pod względem strukturalnym.

Podsumowanie

  • React traktuje każdy komponent jako funkcję czystą: te same propy i stan, ten sam JSX.
  • Podczas renderowania nie zmieniaj niczego zewnętrznego, w tym zmiennych, propów i stanu. Wartości nowo utworzone podczas bieżącego renderowania można swobodnie zmieniać, na przykład podczas sortowania.
  • Dwukrotne uruchomienie w trybie StrictMode podczas rozwoju służy do sprawdzenia integralności i wykrywania nieczystych komponentów. Czysty komponent przechodzi bez zmian.
  • Efekty uboczne umieszczaj w obsłudze zdarzeń lub w funkcjach Effects, nigdy w ciele renderowania.
  • Czystość kodu umożliwia automatyczną optymalizację przez kompilator React, więc pisanie czystego kodu przynosi lepszą wydajność.
  • Interfejs użytkownika to drzewo komponentów, a dane przepływają wzdłuż niego.

Do tej pory ekran jest jak nieruchomy obraz: można go składać z komponentów, pisać kod z użyciem JSX, przekazywać dane za pomocą atrybutów oraz tworzyć dynamiczne widoki przy użyciu warunków i pętli, ale naciśnięcie przycisku nic nie powoduje. Kolejnym krokiem jest sprawienie, by ekran reagował na użytkownika, a zaczyna się to od obsługi zdarzeń.

Oficjalne strony dokumentacji React dotyczące utrzymywania czystości komponentów oraz rozumienia interfejsu użytkownika jako drzewa omawiają te tematy bardziej szczegółowo.

Literatura pokrewna