Dlaczego React.lazy() wymaga domyślnego eksportu i jak ładować te o nazwie
Zrozum, co otrzymuje React.lazy() od dynamicznego importu, dlaczego eksporty nazwane go psują, jak przekierować je na domyślne, oraz w jaki sposób wpisują się w to Suspense i dzielenie kodu.
React.lazy() to jedna z najkrótszych funkcji API w React: otacza dynamiczny import, renderuje wynik wewnątrz Suspense, a narzędzie do pakowania dzieli ten komponent na osobny plik. Problem polega na umowie, którą ukrywa ta składnia – importowany moduł musi eksponować komponent jako swoją domyślną wartość eksportu. Jeśli zmienimy plik na taki z eksportem o nazwie, aplikacja zawiedzie z błędem pochodzącym z głębi React, który nie wskazuje na rzeczywistą przyczynę. Ten artykuł wyjaśnia, jaka wartość faktycznie przepływa przez lazy(), dlaczego eksporty o nazwie go psują, dwa sposoby na rozwiązanie problemu oraz nawyk debugowania przydatny nie tylko w przypadku tej funkcji API.
Zwykły wywołanie wygląda tak:
const Dashboard = lazy(() => import("./Dashboard"));
Wszystko to nie wydaje się ryzykowne, i właśnie dlatego awaria jest myląca, gdy się zdarza.
Prawidłowy scenariusz: domyślny eksport
Zacznij od pliku Dashboard.jsx, który definiuje komponent i eksportuje go jako domyślny.
const Dashboard = () => {
return <div>Dashboard</div>;
};
export default Dashboard;
W pliku, który korzysta z tego komponentu, imporтуj lazy z React i skieruj go na odpowiedni moduł.
import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
Zazwyczaj nikt nie zastanawia się nad tym, co dzieje się pomiędzy tymi dwoma liniami; React po prostu ładuje komponent podczas pierwszej renderizacji. Interesująca jest wyrażenie znajdujące się wewnątrz funkcji strzałkowej.
import("./Dashboard")
To jest zasadniczo inne od instrukcji import statycznego:
import Dashboard from "./Dashboard";
Pierwsza forma to import dynamiczny, a import dynamiczny zwraca wartość typu Promise. Czekanie na jego realizację ułatwia zrozumienie wyniku:
const module = await import("./Dashboard");
To, co wraca, to nie komponent Dashboard. Jest to obiekt przestrzeni nazw modułu, obiekt, którego właściwościami są eksporty modułu. Ponieważ plik kończy się tą linią:
export default Dashboard;
rozwiązany moduł ma mniej więcej taką strukturę:
{
default: Dashboard
}
Właśnie taką strukturę ma na celu obsłużenie funkcja React.lazy(). Jedna mała zmiana, która to psuje
Załóżmy teraz, że zespół woli eksporty o nazwie. Plik wyglądał wcześniej w ten sposób:
const Dashboard = () => {
return <div>Dashboard</div>;
};
export default Dashboard;
Plik zostaje przepisany tak, aby eksportować komponent pod nazwą:
export const Dashboard = () => {
return <div>Dashboard</div>;
};
Wezwanie funkcji lazy pozostaje zupełnie takie samo:
const Dashboard = lazy(() => import("./Dashboard"));
Wszystko wygląda poprawnie. Komponent istnieje, plik istnieje, a ścieżka jest prawidłowa. Zwykły statyczny import tego samego pliku zadziałałby bez żadnych problemów:
import { Dashboard } from "./Dashboard";
Zmienił się kształt obiektu modułu.
Porównanie dwóch kształtów modułów
Eksport domyślny, napisany w ten sposób:
export default Dashboard;
tworzy moduł, który wygląda mniej więcej tak:
{
default: Dashboard
}
Eksport o nazwie, napisany w ten sposób:
export const Dashboard = ...
tworzy zamiast tego moduł o takim kształcie:
{
Dashboard: Dashboard
}
React.lazy() odczytuje komponent z jednego konkretnej miejsca:
module.default
Nigdy nie sprawdza właściwości o nazwie:
module.Dashboard
Zatem eksporty o nazwie same w sobie nie są błędne. Problem polega wyłącznie na tym, że moduł już nie odpowiada kształtowi, od którego zależy lazy(). W fazie rozwoju React zazwyczaj informuje, że element lazy został rozwiązany jako undefined lub że typ elementu jest nieważny, co opisuje objaw, a nie brak klucza default.
Dlaczego lazy() szuka wartości domyślnej
Konstrukcja staje się jaśniejsza, gdy przyjrzysz się temu, co jest przekazywane do lazy().
lazy(() => import("./Dashboard"))
React nie otrzymuje tutaj komponentu. Otrzymuje funkcję ładowania, która zwraca Promise, który ostatecznie rozwiązuje się do modułu. Następnie React bierze właściwość default tego modułu i traktuje ją jako komponent. Łańcuch wygląda w ten sposób:
lazy()
↓
loader function
↓
import("./Dashboard")
↓
Promise
↓
module
↓
module.default
↓
React component
Gdy moduł zawiera wpis default, React ma wszystko, czego potrzebuje:
{
default: Dashboard
}
Gdy moduł zawiera tylko nazwany wpis, nie ma żadnej wartości default, którą React mógłby odczytać:
{
Dashboard: Dashboard
}
Wtedy właśnie pojawia się niezgodność. Odczytywanie wartości default jest rozsądną konwencją – to jedyna nazwa eksportu, co do której wszystkie moduły mogą się zgodzić, bez konieczności, by React wiedział cokolwiek o Twojej nomenklaturze.
Rozwiązanie pierwsze: przejście na eksport domyślny
Jeśli komponent jest głównym elementem dostarczanym przez plik, najprostszym rozwiązaniem jest jego eksport jako domyślny.
const Dashboard = () => {
return <div>Dashboard</div>;
};
export default Dashboard;
Gdy to zostanie zaimplementowane, pierwotne wywołanie będzie działać zgodnie z zamierzeniem:
const Dashboard = lazy(() => import("./Dashboard"));
Dla plików reprezentujących pojedynczy główny komponent jest to czysty i konwencjonalny wybór.
Rozwiązanie drugie: zachowanie eksportu nazwanego i jego przemapowanie
Czasami zmiana eksportu nie jest możliwa lub niepożądana, na przykład gdy kod bazowy wymaga eksportów nazwanych ze względu na reguły lintingu lub gdy plik eksportuje kilka komponentów. Wersja nazwana może pozostać bez zmian:
export const Dashboard = () => {
return <div>Dashboard</div>;
};
Nadal można używać lazy(); trzeba tylko wskazać, który eksport ma być traktowany jako domyślny. Ładowarka łączy .then() z dynamicznym importem i zwraca nowy obiekt, w którym module.Dashboard znajduje się pod kluczem default.
const Dashboard = lazy(() =>
import("./Dashboard").then((module) => ({
default: module.Dashboard,
}))
);
Pierwszy raz może to wydawać się dziwne, ale logika jest prosta:
Load the module
↓
Get the Dashboard export
↓
Put it under "default"
↓
Give it back to React.lazy()
Innymi słowy, ten obiekt:
{
Dashboard: Dashboard
}
jest przekształcany w ten przed tym, jak React go zobaczy:
{
default: Dashboard
}
Teraz React.lazy() otrzymuje dokładnie taką strukturę, jakiej oczekuje. Jeśli projekt ładuje w tle wiele nazwanych eksportów, warto umieścić ten wzorzec w małym wspólnym helperze, aby przekierowanie znajdowało się w jednym miejscu. Ponadto należy umieszczać każde wywołanie lazy() na poziomie modułu, a nie wewnątrz ciała komponentu; deklarowanie go podczas renderowania tworzy nowy komponent lazy za każdym razem i resetuje jego stan.
Dlaczego statyczne importy nigdy nie napotykają tego problemu
Przy statycznym nazwanym imporcie wyraźnie określasz, jaki eksport chcesz:
import { Dashboard } from "./Dashboard";
JavaScript sam rozwiązuje problem powiązania Dashboard. Przy importie domyślnym natomiast prosisz o eksport domyślny:
import Dashboard from "./Dashboard";
Natomiast ładowarka lazy nie wybiera niczego:
lazy(() => import("./Dashboard"))
Przekazuje się Reactowi cały obiekt modułu, a React musi z niego coś wybrać. Dlatego domyślny eksport ma znaczenie tylko w przypadku użycia mechanizmu lazy loading.
Jak Suspense wpisuje się w ten proces
React.lazy() rzadko występuje bez Suspense:
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
To połączenie ma sens, gdy pamiętamy, że komponent nie jest dostępny od razu. Przeglądarce może być konieczne pobranie oddzielnego fragmentu JavaScriptu, zanim React będzie mógł wyświetlić Dashboard, a w tym czasie React potrzebuje czegoś innego do pokazania. Właśnie to zapewnia atrybut fallback. Pełna sekwencja wygląda w ten sposób:
React needs Dashboard
↓
Dashboard hasn't been loaded
↓
Dynamic import starts
↓
Suspense shows fallback
↓
JavaScript chunk loads
↓
Module resolves
↓
React gets module.default
↓
Dashboard renders
Jako sekwencja lazy() jest o wiele mniej tajemnicza: Suspense zarządza okresem oczekiwania, a to, co zostaje wyświetlone na końcu, to wartość z eksportu default. Istotnym aspektem jest to, że nieudane pobranie fragmentu odrzuca obietnicę (Promise), a Suspense nie radzi sobie z takim przypadkiem; to granica błędów wokół komponentu lazy jest tym, co to przechwytuje.
Rzeczywistą korzyścią jest dzielenie kodu
Głównym powodem używania dynamicznych importów jest właśnie dzielenie kodu. Bez takiego wywołania:
lazy(() => import("./Dashboard"))
kod Dashboard prawdopodobnie zostałby włączony do JavaScriptu potrzebnego do uruchomienia aplikacji. Dzięki dynamicznemu importowi bundler może przenieść go do osobnego fragmentu. Koncepcyjnie plik wyjściowy dzieli się na początkowy bundle:
Initial bundle
├── React
├── App
└── Home
i fragment, który jest pobierany później:
Dashboard chunk
└── Dashboard
Kod panelu sterowania jest pobierany dopiero wtedy, gdy użytkownik faktycznie go potrzebuje. Dobrymi przykładami są:
- Panel sterowania administratora
- Strony analityczne
- Ekrany ustawień
- Zaawansowane edytory
- Trasy, których użytkownicy rzadko odwiedzają
- Części o dużej wadze, które wymagają wielu zależności
To nie oznacza, że każda część powinna być łatwo pobierana. Każde podzielenie dodaje żądanie sieciowe i stan ładowania, więc mały przycisk nie zyskuje nic od swojego osobnego pliku. Celem nie jest uczynienie wszystkiego łatwo pobieralnym; chodzi o to, aby początkowy plik nie zawierał kodu, którego użytkownik jeszcze nie potrzebuje.
Błędy pochodzące z wewnętrznych mechanizmów Reacta nie zawsze są błędami Reacta
Gdy pojawia się wyjątek z wewnętrznych mechanizmów Reacta, łatwo jest podejrzewać sam React. Ślad stosu może zawierać takie elementy jak te:
lazyInitializer
resolveLazy
beginWork
I naturalnym wnioskiem jest to, że coś wewnątrz React się zepsuło. Zanim pójdziesz tą drogą, sprawdź umowę API, które wywołujesz. Kluczowe pytanie brzmi: co React.lazy() oczekuje, że zwróci jego loader? W uproszczonej formie odpowiedź brzmi:
Promise<{
default: Component
}>
Porównaj to z tym, co faktycznie dostarcza moduł. Jeśli plik zawiera to:
export const Dashboard = ...
wtedy wartość, która zostaje rozwiązana, jest faktycznie taka:
{
Dashboard: Dashboard
}
Gdy postawi się to obok siebie, przyczyna staje się oczywista. React nie jest uszkodzony; eksporty modułu i umowa loadera po prostu się nie pokrywają.
Model mentalny do interpretacji wywołań lazy()
Zamiast czytać tę linię jako „ładuj Dashboard w trybie lazy”:
const Dashboard = lazy(() => import("./Dashboard"));
pomaga czytać ją jako małą łańcuchową operację:
When Dashboard is needed
↓
run the dynamic import
↓
get the module
↓
take its default export
↓
use that as the component
Ten model ułatwia szybką diagnozę tych problemów. Wyjaśnia również, dlaczego pierwszy z poniższych plików działa od razu:
// Dashboard.jsx
export default function Dashboard() {
return <div>Dashboard</div>;
}
podczas gdy drugi wymaga dodatkowej pracy:
// Dashboard.jsx
export const Dashboard = () => {
return <div>Dashboard</div>;
};
gdy jest ładowany za pomocą tej funkcji:
lazy(() => import("./Dashboard"));
Szersza lekcja związana z debugowaniem
To małe szczegóły w React niosą ze sobą ważniejszą lekcję. Gdy API zachowuje się nieoczekiwanie, należy spojrzeć poza zapisaną składnię i sprawdzić wartość, która faktycznie przepływa przez API. Taka funkcja wydaje się błahe:
lazy(() => import("./Dashboard"))
W rzeczywistości jest to łańcuch transformacji:
dynamic import
↓
Promise
↓
module object
↓
default export
↓
React component
Gdy ta łańcuchowa struktura staje się widoczna, błąd łatwiej jest zrozumieć, a ten sam podejście sprawdza się we wszystkich implementacjach JavaScript. Zamiast zastanawiać się, dlaczego biblioteka wywołuje błąd, należy sprawdzić, co dokładnie przekazujemy do funkcji i czego oczekuje ona w zamian. Czasami cała różnica sprowadza się do nazwy pojedynczej właściwości:
default
Główne wnioski
import()zwraca obiekt modułu, a nie komponent, natomiastReact.lazy()odczytuje komponent z jego właściwościdefault.- Eksporty o nazwie nie stanowią problemu; wystarczy je ponownie skierować za pomocą
.then((module) => ({ default: module.Name }))podczas ładowania w trybie opóźnionym. - Komponenty typu lazy należy deklarować na poziomie modułu, otaczać je elementem
Suspensew celu obsługi stanów ładowania oraz dodawać granicę błędów w przypadku nieudanych pobierania fragmentów kodu.
Literatura pokrewna
- Ładowanie, czekanie, błąd: jak lazy, Suspense i Error Boundaries się dopasowują — Dowiedz się, jak React.lazy, Suspense i Error Boundaries dzielą kod, pokazują alternatywy i zapobiegają awariom, oraz gdzie umieścić każdą z tych granic dla lepszej obsługi użytkownika.
- Zrób mniej pracy najpierw: lista weryfikacyjna wydajności React przed memoizacją — Zmniejsz koszty aplikacji React, unikając niepotrzebnych operacji: używaj funkcji debounce do zadań wyszukiwania, implementuj paginację, umieszczaj stan w odpowiednich miejscach, stosuj stabilne klucze, przenoś obliczenia na stronę serwera i używaj ładowania opóźnionego, a dopiero potem zastosuj memoizację w razie potrzeby.