Startseite / Artikel

Warum React.lazy() eine Standardexportfunktion benötigt und wie man benannte Exporte lädt

Verstehen, was React.lazy() von einer dynamischen Importierung erhält, warum benannte Exporte dies stören, wie man sie auf die Standardexporte umleitet und wie Suspense sowie Code Splitting dazu passen.

Verlag: REACTAPP.TOP9 Min. Lesezeit2056 Wörter

Quellen: React.lazy() Looks Simple Until the Export Is Wrong

React.lazy() gehört zu den kürzesten APIs in React: Man umhüllt eine dynamische Importierung, rendernt das Ergebnis innerhalb von Suspense, und der Bundler teilt diese Komponente in ihren eigenen Chunk auf. Das Problem ist ein Vertrag, der durch die Syntax verdeckt wird: Die importierte Modul muss die Komponente als Standardexport bereitstellen. Wechselt man auf einen benannten Export, versagt die Anwendung mit einem Fehler, der tief im Inneren von React ausgelöst wird und nicht auf die eigentliche Ursache hinweist. Dieser Artikel erklärt, welcher Wert tatsächlich über lazy() fließt, warum benannte Exporte dies stören, zwei Möglichkeiten, das Problem zu beheben, sowie eine Debugging-Praxis, die weit über dieses eine API hinaus Anwendung findet.

Der übliche Aufruf sieht so aus:

const Dashboard = lazy(() => import("./Dashboard"));

Es scheint nichts Riskantes daran zu sein – genau deshalb ist das Versagen verwirrend, wenn es eintritt.

Der erfolgreiche Weg: ein Standardexport

Beginnen Sie mit einer Dashboard.jsx-Datei, die eine Komponente definiert und sie als Standard exportiert.

const Dashboard = () => {
  return <div>Dashboard</div>;
};
export default Dashboard;

In der verwendenden Datei importieren Sie lazy aus React und weisen es auf die Moduldatei hin.

import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));

Meistens denkt niemand darüber nach, was zwischen diesen beiden Zeilen geschieht; React lädt die Komponente einfach beim ersten Renderen. Interessant ist der Ausdruck innerhalb der Pfeilfunktion.

import("./Dashboard")

Das unterscheidet sich grundlegend von einer statischen Importanweisung:

import Dashboard from "./Dashboard";

Die erste Form ist ein dynamischer Import, und ein dynamischer Import ergibt einen Promise. Das Abwarten dieses Promises macht das Ergebnis leichter vorstellbar:

const module = await import("./Dashboard");

Was zurückgegeben wird, ist nicht das Dashboard-Komponente. Es handelt sich um ein Objekt des Modulnamensraums, ein Objekt, dessen Eigenschaften die Exporte des Moduls sind. Weil die Datei mit dieser Zeile endet:

export default Dashboard;

hat das gelöste Modul in etwa diese Struktur:

{
  default: Dashboard
}

Genau diese Struktur ist es, die React.lazy() zum Verarbeiten vorgesehen ist.

Eine kleine Änderung, die es kaputt macht

Nehmen wir nun an, das Team bevorzugt benannte Exporte. Die Datei sah zuvor so aus:

const Dashboard = () => {
  return <div>Dashboard</div>;
};
export default Dashboard;

Sie wird umgeschrieben, um die Komponente nach Name zu exportieren:

export const Dashboard = () => {
  return <div>Dashboard</div>;
};

Der lazy-Aufruf bleibt genau gleich:

const Dashboard = lazy(() => import("./Dashboard"));

Alles scheint in Ordnung zu sein. Die Komponente existiert, die Datei existiert und der Pfad ist korrekt. Eine normale statische Importierung derselben Datei würde ohne Probleme funktionieren:

import { Dashboard } from "./Dashboard";

Was sich geändert hat, ist die Form des Module-Objekts.

Vergleich der beiden Module-Formen

Eine Standardexport-Funktion, so geschrieben:

export default Dashboard;

ergibt ein Module, das ungefähr so aussieht:

{
  default: Dashboard
}

Ein benannter Export, so geschrieben:

export const Dashboard = ...

ergibt stattdessen ein Module mit dieser Form:

{
  Dashboard: Dashboard
}

React.lazy() liest die Komponente von einem bestimmten Ort:

module.default

Es schaut niemals auf die benannte Eigenschaft:

module.Dashboard

Benannte Exports sind an sich nicht falsch. Das Problem besteht lediglich darin, dass das Module nicht mehr der Form entspricht, von der lazy() abhängt. Im Entwicklungsmodus meldet React in der Regel, dass das lazy-Element auf undefined zurückgeführt wurde oder dass die Elementart nicht gültig ist – das beschreibt eher das Symptom als den fehlenden default-Schlüssel.

Warum lazy() nach dem Standardwert sucht

Das Design wird klarer, sobald man sich ansieht, was an lazy() übergeben wird.

lazy(() => import("./Dashboard"))

React erhält hier keine Komponente. Es erhält eine Ladefunktion, die einen Promise zurückgibt, der schließlich auf ein Modul auflöst. React nimmt anschließend die default-Eigenschaft dieses Moduls und behandelt sie als Komponente. Die Kette sieht so aus:

lazy()
  ↓
loader function
  ↓
import("./Dashboard")
  ↓
Promise
  ↓
module
  ↓
module.default
  ↓
React component

Wenn das Modul einen default-Eintrag enthält, hat React, was es benötigt:

{
  default: Dashboard
}

Wenn das Modul nur einen benannten Eintrag enthält, gibt es keinen default, den React lesen kann:

{
  Dashboard: Dashboard
}

Dort tritt die Unstimmigkeit auf. Das Lesen des default-Werts ist eine vernünftige Konvention: Es handelt sich um den Exportnamen, auf den sich alle Module einigen können, ohne dass React etwas über Ihre Namensgebung wissen muss.

Lösung eins: Auf eine Standardexportierung umstellen

Falls die Komponente das Hauptprodukt einer Datei ist, besteht die einfachste Lösung darin, sie als Standard zu exportieren.

const Dashboard = () => {
  return <div>Dashboard</div>;
};
export default Dashboard;

Sobald das umgesetzt ist, funktioniert der ursprüngliche Aufruf wie vorgesehen:

const Dashboard = lazy(() => import("./Dashboard"));

Für Dateien, die eine einzige primäre Komponente darstellen, handelt es sich dabei um eine saubere und übliche Wahl.

Lösung zwei: Die benannte Exportierung beibehalten und ummappen

Mannchmal ist ein Wechsel der Exportierung nicht möglich oder wünschenswert, zum Beispiel wenn eine Codebasis durch eine Lint-Regel benannte Exportierungen vorschreibt oder wenn die Datei mehrere Komponenten exportiert. Die benannte Version kann unverändert bleiben:

export const Dashboard = () => {
  return <div>Dashboard</div>;
};

Man kann weiterhin lazy() verwenden; man muss lediglich angeben, welche Export-Funktion als Standard behandelt werden soll. Der Loader fügt eine .then()-Funktion zum dynamischen Import hinzu und gibt ein neues Objekt zurück, das module.Dashboard unter dem Schlüssel default platziert.

const Dashboard = lazy(() =>
  import("./Dashboard").then((module) => ({
    default: module.Dashboard,
  }))
);

Zum ersten Mal wirkt das seltsam, aber die Logik ist einfach:

Load the module
      ↓
Get the Dashboard export
      ↓
Put it under "default"
      ↓
Give it back to React.lazy()

Anders ausgedrückt: Dieses Objekt:

{
  Dashboard: Dashboard
}

wird in dieses umgewandelt, bevor React es sieht:

{
  default: Dashboard
}

Nun erhält React.lazy() genau die Struktur, die er erwartet. Wenn ein Projekt viele benannte Exporte verspätet lädt, lohnt es sich, dieses Muster in einem kleinen gemeinsamen Hilfsfunktion zu verpacken, damit die Umzuordnung an einem Ort gespeichert wird. Halten Sie außerdem jede lazy()-Aufrufung im Modulbereich und nicht innerhalb des Komponentenkörpers; eine Deklaration während des Renderns erzeugt jedes Mal eine neue lazy-Komponente und setzt ihren Zustand zurück.

Warum statische Importe dieses Problem nie haben

Mit einem statischen benannten Import geben Sie explizit an, welchen Export Sie möchten:

import { Dashboard } from "./Dashboard";

JavaScript löst die Dashboard-Verknüpfung für Sie auf. Bei einem Standardimport hingegen fordern Sie den standardmäßigen Export an:

import Dashboard from "./Dashboard";

Ein lazy Loader hingegen wählt nichts aus:

lazy(() => import("./Dashboard"))

Es wird dem React das gesamte Modulobjekt übergeben, und React muss daraus etwas auswählen. Deshalb ist die Standardexport-Funktion nur im lazy-Modus relevant.

Wie Suspense in den Ablauf passt

React.lazy() kommt selten ohne Suspense vor:

<Suspense fallback={<div>Loading...</div>}>
  <Dashboard />
</Suspense>

Die Kombination macht Sinn, sobald man bedenkt, dass das Komponente nicht sofort verfügbar ist. Der Browser muss möglicherweise einen separaten JavaScript-Chunk herunterladen, bevor React Dashboard rendern kann, und in dieser Zeit benötigt React etwas anderes zur Anzeige. Die fallback-Eigenschaft stellt das bereit. Die vollständige Abfolge sieht so aus:

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

Als Sequenz betrachtet ist lazy() weitaus weniger rätselhaft: Suspense überbrückt die Wartezeit, und der default-Export wird am Ende gerendert. Ein damit verbundener Punkt ist, dass ein fehlgeschlagener Chunk-Download die Promise ablehnt, und Suspense kümmert sich nicht um diesen Fall; ein Error Boundary um den lazy-Komponenten fängt solche Fehler ab.

Code Splitting ist der eigentliche Vorteil

Der Hauptgrund für die Verwendung dynamischer Importe ist das Code Splitting. Ohne einen solchen Aufruf:

lazy(() => import("./Dashboard"))

würde der Code des Dashboards vermutlich in die JavaScript-Datei aufgenommen, die zur Startung der Anwendung benötigt wird. Mit dem dynamischen Import kann der Bundler ihn in einen eigenen Chunk verschieben. Konzeptionell teilt sich die Ausgabe in einen initialen Bundle auf:

Initial bundle
├── React
├── App
└── Home

und in einen Chunk, der später heruntergeladen wird:

Dashboard chunk
└── Dashboard

Der Code des Dashboards wird dann nur heruntergeladen, wenn ein Benutzer ihn tatsächlich benötigt. Geeignete Beispiele sind:

  • Admin-Dashboards
  • Analyseseiten
  • Einstellungsseiten
  • Umfangreiche Editor-Tools
  • Routen, die Benutzer selten besuchen
  • Schwerwiegende Komponenten mit vielen Abhängigkeiten

Das bedeutet jedoch nicht, dass jede Komponente träge sein muss. Jede Aufteilung führt zu einer zusätzlichen Netzwerkanfrage und einem Ladezustand, wodurch ein kleiner Button nichts von seiner eigenen Aufteilung hat. Das Ziel ist es nicht, alles träge zu machen, sondern den initialen Bundle frei von Code zu halten, den der Benutzer noch nicht benötigt.

Fehler aus Reacts internen Strukturen sind nicht immer React-Bugs

Wenn eine Ausnahme aus den internen Strukturen von React auftritt, ist es verlockend, React selbst dafür verantwortlich zu machen. Der Stack-Trace kann Frames wie diese enthalten:

lazyInitializer
resolveLazy
beginWork

Die logische Schlussfolgerung ist, dass etwas innerhalb von React kaputtgegangen ist. Bevor man diesen Weg einschlägt, sollte man den Vertrag der API überprüfen, die aufgerufen wird. Die entscheidende Frage ist hier, was React.lazy() von seinem Loader erwartet. In vereinfachter Form lautet die Antwort:

Promise<{
  default: Component
}>

Vergleichen Sie das mit dem, was die Datei tatsächlich liefert. Wenn die Datei Folgendes enthält:

export const Dashboard = ...

dann ist der tatsächlich bereitgestellte Wert im Grunde genommen dieser:

{
  Dashboard: Dashboard
}

Wenn man beides nebeneinander stellt, wird die Ursache offensichtlich. React ist nicht kaputt; die Exporte der Datei und der Vertrag des Loaders passen einfach nicht zueinander.

Ein mentales Modell zum Verstehen von lazy()-Aufrufen

Anstatt diese Zeile als „Dashboard verspätet laden“ zu lesen:

const Dashboard = lazy(() => import("./Dashboard"));

hilft es, sie als einen kleinen Pipeline-Ablauf zu betrachten:

When Dashboard is needed
        ↓
run the dynamic import
        ↓
get the module
        ↓
take its default export
        ↓
use that as the component

Dieses Modell ermöglicht eine schnelle Diagnose dieser Probleme. Es erklärt außerdem, warum die erste der folgenden Dateien sofort funktioniert:

// Dashboard.jsx
export default function Dashboard() {
  return <div>Dashboard</div>;
}

während die zweite zusätzliche Bearbeitung erfordert:

// Dashboard.jsx
export const Dashboard = () => {
  return <div>Dashboard</div>;
};

wenn sie mit diesem Aufruf geladen wird:

lazy(() => import("./Dashboard"));

Die umfassendere Lektion zur Fehlersuche

Dieser kleine React-Aspekt birgt eine wichtigere Lektion. Wenn sich eine API unerwartet verhält, sollte man über die eingegebene Syntax hinaussehen und den Wert untersuchen, der tatsächlich über die API übertragen wird. Ein Aufruf wie dieser erscheint zunächst trivial:

lazy(() => import("./Dashboard"))

Im Grunde handelt es sich dabei um eine Kette von Transformationen:

dynamic import
      ↓
Promise
      ↓
module object
      ↓
default export
      ↓
React component

Sobald diese Kette sichtbar ist, wird der Fehler leichter verständlich, und dieser Ansatz funktioniert in JavaScript genauso gut. Statt zu fragen, warum eine Bibliothek Fehler auslöst, sollte man prüfen, was genau an die Funktion übergeben wird und was sie zurückbekommen soll. Manchmal liegt der ganze Unterschied in einem einzigen Eigennamen:

default

Wichtige Erkenntnisse

  • import() liefert ein Modulobjekt, nicht eine Komponente, und React.lazy() liest die Komponente aus ihrer default-Eigenschaft.
  • Namhafte Exporte sind in Ordnung; sie müssen lediglich bei verzögerter Ladung mit .then((module) => ({ default: module.Name })) neu mappiert werden.
  • Ladet man Komponenten verzögert, sollte man sie im Modulscope deklarieren, in Suspense einbetten, um den Ladezustand anzuzeigen, und einen Fehlerbereich hinzufügen, falls die Downloadvorgänge fehlschlagen.
  • Trennen Sie schwere, selten verwendete Screens – nicht kleine Komponenten.
  • Wenn ein Fehler in einer Bibliothek auftritt, vergleichen Sie den von Ihnen übergebenen Wert mit dem Wert, den der API-Vertrag erwartet, bevor Sie von einem Fehler in der Bibliothek ausgehen.