Ładowanie, oczekiwanie, błędy: jak pasują do siebie granice lenistwa, napięcia i błędów
Zrozum, w jaki sposób React.lazy, Suspense i Error Boundaries dzielą kod, pokazują alternatywy oraz zapobiegają awariom, a także gdzie umieścić każdą z tych mechanizmów dla lepszej użyteczności.
Źródła: React Suspense, Lazy Loading & Error Boundaries — A Practical Guide
Ekran w React może znajdować się w trzech nieprzyjemnych stanach: kod nie został pobraany, treść nie jest gotowa lub wystąpił błąd renderowania. React.lazy odkłada ładowanie kodu, Suspense pokazuje element tymczasowy, dopóki coś się nie załadowa, a Error Boundary zastępuje uszkodzoną poddrzewo alternatywnym rozwiązaniem. Gdy zrozumiesz, jak te elementy ze sobą współpracują, będziesz mógł rozsądnie dzielić pliki i umieszczać takie granice, które zapewnią użyteczność reszty strony.
Odkładanie ładowania kodu za pomocą React.lazy
Ładowanie opóźnione oznacza, że kod komponentu jest pobierany tylko wtedy, gdy React po raz pierwszy musi go wyświetlić, a nie jako część początkowego pliku. Różnica jest widoczna w instrukcji import: statyczny import Dashboard from „./Dashboard” pobiera moduł natychmiast, podczas gdy umieszczenie dynamicznego importu w funkcji lazy() pozwala narzędziu do pakowania wygenerować oddzielny plik. (W tym fragmencie drugi import jest pokazany wyłącznie w celach porównawczych.)
import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
Instead of:
import Dashboard from "./Dashboard";
Co daje ładowanie opóźnione
Załóżmy aplikację składającą się z kilku odrębnych obszarów:
Application
├── Home
├── Products
├── Dashboard
├── Analytics
├── Admin
└── Payment
Osoba trafiająca na stronę główną jeszcze nie potrzebuje tych elementów:
Analytics
Admin
Payment
Ich wysyłanie mimo to powiększa początkową wielkość pobieranych danych. Dzięki ładowaniu opóźnionemu pozostałe pliki są ładowane na żądanie:
Initial Load
↓
Home + common code
↓
User opens Dashboard
↓
Download Dashboard chunk
↓
Render Dashboard
Jak działa ładowanie opóźnione w praktyce
lazy() przyjmuje funkcję, która zwraca obietnicę, niemal zawsze dynamiczne import(). Moduł docelowy musi mieć domyślną eksportację w postaci komponentu. Sekwencja wygląda tak (diagram jest koncepcyjny, a nie kod):
const Dashboard = lazy(() => import("./Dashboard"));
Conceptually:
lazy()
↓
dynamic import()
↓
JavaScript chunk requested
↓
Promise pending
↓
Component becomes available
↓
React renders component
Obowiązują dwa praktyczne zasady. Deklaruj komponenty typu lazy na najwyższym poziomie modułu, nigdy wewnątrz innego komponentu, ponieważ w przeciwnym razie przy każdym renderowaniu tworzony jest nowy typ komponentu i resetowany jest jego stan. A ponieważ dane przychodzą asynchronicznie, coś musi zdecydować, co użytkownik zobaczy w międzyczasie. To właśnie jest zadaniem Suspense.
Wyświetlanie elementu tymczasowego za pomocą Suspense
Suspense renderuje element zastępczy, dopóki jakiś potomek jest w stanie zawieszenia, co oznacza, że nie może jeszcze zostać renderowany. Otocz komponent typu lazy i przekaż element zastępczy:
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<p>Loading dashboard...</p>}>
<Dashboard />
</Suspense>
);
}
Gdy plik jest pobierany, React przechodzi do najbliższej granicy i wyświetla swoją alternatywę:
Dashboard
↓
Not ready
↓
Suspense
↓
"Loading dashboard..."
Gdy obietnica zostanie spełniona, React ponownie próbuje wyświetlić treść, a prawdziwy content zastępuje alternatywę:
Dashboard loaded
↓
React retries rendering
↓
Dashboard displayed
Suspense nie odpowiada za proces ładowania; to lazy() (lub biblioteka danych z obsługą Suspense) uruchamia te działania. Suspense jedynie wskazuje, gdzie może pojawić się stan oczekiwania i jak powinien wyglądać.
Użycie lazy i Suspense razem
W większości rzeczywistych kodów te elementy są łączone, zazwyczaj z bardziej rozbudowaną alternatywą niż zwykła linia tekstu:
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
);
}
Pełny cykl życiowy pierwszej wizyty, ponownie przedstawiony jako diagram:
User opens Dashboard
↓
React tries to render Dashboard
↓
Dashboard chunk isn't available
↓
Dynamic import starts
↓
Suspense fallback shown
↓
Dashboard chunk loads
↓
React retries rendering
↓
Dashboard displayed
Skeletony zamiast spinnerów
Prosta alternatywa tekstowa funkcjonuje, ale nie informuje użytkownika o tym, co ma nadejść:
<Suspense fallback={<p>Loading...</p>}>
Komponent szkieletowy, który naśladuje ostateczną strukturę, jest zazwyczaj lepszym wyborem:
<Suspense fallback={<DashboardSkeleton />}>
Zarezerwuje on mniej więcej odpowiednią przestrzeń, zmniejszając przesunięcia w układzie i sprawiając, że oczekiwanie wydaje się krótsze:
┌─────────────────────────────┐
│ Dashboard │
├─────────────────────────────┤
│ ░░░░░░░ ░░░░░░░ │
│ ░░░░░░░ ░░░░░░░ │
│ │
│ ░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────┘
Wkładanie się granic Suspense
Jedna granica dla całej aplikacji rzadko jest tym, czego chcemy. Granice mogą być wkładane w siebie, a każdy komponent w stanie oczekiwania jest obsługiwany przez najbliższą nad nim granicę:
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</Suspense>
Zamiast tego, by cała strona zamieniała się w jedną wiadomość o ładowaniu,
Entire page → Loading...
każda sekcja może się ujawnić niezależnie:
Header ✅
Products Loading...
[Product Skeleton]
Recommendations Loading...
[Recommendation Skeleton]
Zarządzanie błędami za pomocą Error Boundaries
Oczekiwanie to jeden z możliwych wyników; błąd to inny. Załóżmy, że komponent rzuci błędem podczas renderowania:
function Dashboard() {
throw new Error("Dashboard crashed!");
}
Błąd renderowania, którego nie udało się złapać, powoduje rozmontowanie całego korzenia Reacta, co skutkuje pustą stroną. Error Boundary to komponent, który przechwytuje błędy rzucane przez jego potomki i pokazuje zamiast tego interfejs awaryjny:
Dashboard
↓
Rendering Error
↓
Error Boundary
↓
Fallback UI
Użycie takiego komponentu przypomina otoczenie dowolnego innego komponentu, a użytkownik widzi komunikat „Coś poszło nie tak” zamiast pustego ekranu:
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
Pisanie Error Boundary
Error Boundaries to nadal komponenty klasowe; w samym React nie ma odpowiednika Hooków. Istotne są dwa metody. Statyczna funkcja getDerivedStateFromError jest wykonywana podczas renderowania i zwraca stan przełączający się na interfejs awaryjny. Funkcja componentDidCatch jest wykonywana później z błędem oraz informacją info.componentStack, która służy do logowania lub wysyłania raportów.
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
};
}
static getDerivedStateFromError() {
return {
hasError: true,
};
}
componentDidCatch(error, info) {
console.error(error);
console.error(info.componentStack);
}
render() {
if (this.state.hasError) {
return <h2>Something went wrong.</h2>;
}
return this.props.children;
}
}
Używa się go dokładnie tak jak w poprzednim przykładzie:
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
W tej minimalnej wersji nie ma funkcji resetowania; przycisk „Spróbuj ponownie” musi ustawić hasError z powrotem na false.
Jak React znajduje granicę
Niech granica otacza komponent profilu,
<ErrorBoundary>
<Profile />
</ErrorBoundary>
gdzie komponent rzuca błąd:
function Profile() {
throw new Error("Profile failed");
}
React renderuje elementy w dół drzewa aż do wystąpienia błędu:
ErrorBoundary
↓
Profile
↓
💥 Error
Następnie szuka w górę najbliższej granicy błędów:
Profile
↑
ErrorBoundary
Ta granica przechodzi w stan błędu,
{
hasError: true
}
i renderuje rozwiązanie awaryjne. Granica nie może złapać własnych błędów, tylko błędów swoich dzieci; błąd w rozwiązaniu awaryjnym propaguje się do następnej granicy powyżej.
Błędy, które może złapać granica
Granice obsługują błędy powstające podczas renderowania w React, w metodach cyklu życia oraz w konstruktorach komponentów potomnych. Klasycznym przykładem jest odczytywanie właściwości typu undefined podczas renderowania:
function Profile() {
const user = undefined;
return <h1>{user.name}</h1>;
}
Zachłaniają one również odrzucone importy typu lazy. Jeśli na przykład plik nie zostanie pobraany po aktualizacji, która usunęła stary plik, błąd pojawia się w najbliższej granicy błędów, a nie w elementie Suspense.
Błędy, których nie dostrzegają granice
Często pytane na rozmowach kwalifikacyjnych: granice nie widzą błędów w obsługownikach zdarzeń, ponieważ te działają poza procesem renderowania.
function Button() {
const handleClick = () => {
throw new Error("Click failed");
};
return (
<button onClick={handleClick}>
Click
</button>
);
}
Obsługuj te sytuacje tam, gdzie one występują, za pomocą zwykłej obsługi błędów w odpowiednich funkcjach:
try {
// operation
} catch (error) {
// handle error
}
To samo dotyczy asynchronicznych funkcji zwrotnych, takich jak timerzy czy łańcuchy obietnic, które rzucają błędy po zakończeniu renderowania:
setTimeout(() => {
throw new Error("Async error");
}, 1000);
Jeśli taka błąd powinna wyświetlić alternatywny rozwiązanie granicy, złap ją i przechowaj w stanie, a następnie rzuć ją podczas kolejnego renderowania.
Łączenie wszystkich trzech elementów
W praktyce produkcyjnej wzorzec polega na umieszczeniu Suspense wewnątrz granicy błędów:
<ErrorBoundary>
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
</ErrorBoundary>
Gdy panel sterowania jest definiowany w sposób opóźniony,
const Dashboard = lazy(() => import("./Dashboard"));
łączny przepływ obejmuje każdy możliwy wynik. Pierwszą ścieżkę można interpretować jako „brakuje kodu?”: jeśli tak, React rozpoczyna import i wyświetla szkielet; później render albo się udaje, albo dochodzi do granicy błędów.
Dashboard
│
▼
Need the code?
│
┌────────┴────────┐
│ │
YES NO
│ │
Render normally dynamic import()
│
Pending
│
▼
Suspense
│
▼
Skeleton UI
│
Loaded
│
▼
Render again
│
┌───────┴───────┐
│ │
Success Error
│ │
▼ ▼
Dashboard Error Boundary
│
▼
Error UI
Dzielenie według trasy
Trasy są najnaturalniejszymi punktami podziału, ponieważ każda strona to samodzielny ekran. Każdą stronę należy deklarować w sposób opóźniony:
const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
następnie należy otoczyć router granicą, aby nawigacja do niezaładowanej strony wyświetlała element ładowania:
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
Wiedza o tym, czego nie dzielić
Ładowanie w tle małego komponentu jest kontraproduktywne:
const Button = lazy(() => import("./Button"));
Każdy komponent ładowany w tle wymaga żądania i stanu ładowania, czego przycisk nigdy nie zrekompensuje. Dobrymi kandydatami są duże panele sterowania, obszary analityczne i administracyjne, edytory tekstu z bogatymi funkcjami, mapy, skomplikowane wykresy lub biblioteki third-party, procesy płatności oraz rzadko odwiedzane trasy. Należy dzielić kod, który jest kosztowny lub rzadko używany, a nie wszystko.
Celowe umieszczanie granic
Miejsce umieszczenia elementu wstrzymującego decyduje o tym, co znika
Granica umieszczona wysoko w drzewie oznacza, że każdy wolny komponent zastępuje dużą obszar treści swoim alternatywnym rozwiązaniem:
<Suspense fallback={<FullPageLoader />}>
<EntireApplication />
</Suspense>
Umieszczenie jej wokół wolnej części zapewnia, że nagłówek i stopka pozostają widoczne i użyteczne podczas ładowania:
<Header />
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
<Footer />
Miejsce umieszczenia granicy błędu decyduje o tym, co przetrwa
Ta sama logika obowiązuje w przypadku błędów. Jeden tylko punkt graniczny na poziomie korzenia zamienia każdy błąd w pełnoekranową stronę z komunikatem o błędzie:
<ErrorBoundary>
<EntireApplication />
</ErrorBoundary>
Zawijanie niezależnych sekcji zapobiega rozprzestrzenianiu się awarii na całość aplikacji:
<App>
<Header />
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
<ErrorBoundary>
<Recommendations />
</ErrorBoundary>
<Footer />
</App>
Jeśli rekomendacje zawiodą, wszystko inne nadal będzie działać:
Header ✅
Dashboard ✅
Recommendations ❌
Footer ✅
Punkt graniczny na poziomie korzenia pozostaje przydatny jako ostateczność wokół bardziej szczegółowych punktów granicznych.
Model mentalny w jednym obrazku
Lazy Loading
│
▼
Code needed?
│
▼
Suspense
│
┌──────┴──────┐
│ │
Loading Ready
│ │
Fallback ▼
Render
│
┌─────┴─────┐
│ │
Success Error
│ │
▼ ▼
UI Error Boundary
│
▼
Error UI
Główne wnioski
React.lazyw połączeniu z dynamicznymimport()dzieli kod na fragmenty ładowane podczas pierwszej renderizacji; moduł musi mieć domyślną wartość eksportowaną.Suspensenie ładuje żadnych treści; określa jedynie miejsce, gdzie pojawi się rozwiązanie awaryjne, gdy potomne elementy są w stanie zawieszenia.- Boundaries błędów łapią błędy związane z renderowaniem, cyklem życia i konstruktorem, w tym nieudane ładowanie elementów typu lazy, ale nie błędy obsługiwaczy zdarzeń ani późniejsze błędy asynchroniczne.
Suspense nie radzi sobie z błędami, dlatego należy go łączyć z Error Boundary.