Accueil / Articles

Pourquoi React.lazy() a besoin d’un export par défaut et comment charger les exports nommés.

Comprendre ce que React.lazy() reçoit d’une importation dynamique, pourquoi les exports nommés le perturbent, comment les réaffecter au export par défaut, et comment Suspense et le code splitting s’y intégrent.

Éditeur : REACTAPP.TOP9 min de lecture2056 mots

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

React.lazy() fait partie des APIs les plus concises de React : il s’agit d’envelopper une importation dynamique, de rendre le résultat à l’intérieur de Suspense, et le bundler divise alors ce composant en son propre chunk. Le problème réside dans une contrainte que la syntaxe cache : le module importé doit exposer le composant en tant qu’export par défaut. Si l’on passe à un export nommé, l’application échoue en raison d’une erreur générée profondément à l’intérieur de React, sans indiquer la véritable cause. Cet article explique quelle valeur circule réellement via lazy(), pourquoi les exports nommés le perturbent, deux façons de résoudre ce problème, ainsi qu’une habitude de débogage utile bien au-delà de cette API.

L’appel habituel se présente comme suit :

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

Rien dans cette approche ne semble risqué, ce qui explique précisément pourquoi l’échec est déroutant lorsqu’il se produit.

Le scénario idéal : un export par défaut

Commencez par un fichier Dashboard.jsx qui définit un composant et l’exporte en tant que valeur par défaut.

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

Dans le fichier qui utilise ce composant, importez lazy de React et pointez-le vers le module.

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

La plupart du temps, personne ne se demande ce qui se passe entre ces deux lignes ; React charge simplement le composant lors de sa première rendu. La partie intéressante est l’expression à l’intérieur de la fonction flèche.

import("./Dashboard")

Cela diffère fondamentalement d’une instruction d’import statique :

import Dashboard from "./Dashboard";

La première forme correspond à un import dynamique, et un import dynamique renvoie une Promise. En l’attendre, il devient plus facile d’en visualiser le résultat :

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

Ce qui est retourné n’est pas le composant Dashboard. Il s’agit d’un objet de nom d’espace de module, un objet dont les propriétés correspondent aux exports du module. Comme le fichier se termine par cette ligne :

export default Dashboard;

le module résolu a à peu près cette structure :

{
  default: Dashboard
}

C’est précisément ce type de structure que React.lazy() est conçu pour traiter.

Une petite modification qui le brise

Supposons maintenant que l’équipe préfère des exports nommés. Le fichier ressemblait auparavant à ceci :

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

Il est réécrit pour exporter le composant par son nom :

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

La requête lazy reste exactement la même :

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

Rien ne semble anormal. Le composant existe, le fichier existe, et le chemin est correct. Une importation statique classique du même fichier fonctionnerait sans problème :

import { Dashboard } from "./Dashboard";

Ce qui a changé, c’est la forme de l’objet module.

Comparaison des deux formes de modules

Une exportation par défaut, écrite comme ceci :

export default Dashboard;

produit un module qui ressemble à peu près à ceci :

{
  default: Dashboard
}

Une exportation nommée, écrite comme ceci :

export const Dashboard = ...

produit un module ayant plutôt cette forme :

{
  Dashboard: Dashboard
}

React.lazy() lit le composant depuis un endroit spécifique :

module.default

Il ne prend jamais en compte la propriété nommée :

module.Dashboard

Ainsi, les exportations nommées ne sont pas en soi erronées. Le problème réside simplement dans le fait que le module ne correspond plus à la forme sur laquelle lazy() compte. En développement, React indique généralement que l’élément lazy a donné undefined ou que le type de l’élément est invalide, ce qui décrit les symptômes plutôt que l’absence de la clé default.

Pourquoi lazy() cherche une valeur par défaut

La conception devient plus claire une fois que l’on examine ce qui est passé à lazy().

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

React ne reçoit pas ici un composant. Il reçoit une fonction chargeuse, qui retourne une Promise, laquelle se résout finalement en un module. React prend ensuite la propriété default de ce module et la traite comme le composant. La chaîne est la suivante :

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

Lorsque le module contient une entrée default, React dispose de ce dont il a besoin :

{
  default: Dashboard
}

Lorsque le module ne contient qu’une entrée nommée, il n’y a pas de default que React puisse lire :

{
  Dashboard: Dashboard
}

C’est là que survient le désaccord. Lire default est une convention raisonnable : c’est le seul nom d’export sur lequel tous les modules peuvent s’accorder, sans que React ait besoin de connaître quoi que ce soit concernant votre nommage.

Correction un : passer à une export par défaut

Si le composant constitue l’élément principal fourni par un fichier, la solution la plus simple est de l’exporter en tant que valeur par défaut.

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

Avec cela en place, l’appel initial fonctionne comme prévu :

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

Pour les fichiers représentant un seul composant principal, c’est un choix propre et conventionnel.

Correction deux : conserver l’export nommé et le rémapper

Parfois, modifier l’export n’est pas possible ou souhaitable, par exemple lorsque le codebase impose des exports nommés via une règle de linting ou lorsque le fichier exporte plusieurs composants. La version nommée peut rester telle quelle :

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

Vous pouvez toujours utiliser lazy() ; il vous suffit de lui indiquer quelle exportation considérer comme par défaut. Le chargeur ajoute un .then() à l’import dynamique et renvoie un nouvel objet qui place module.Dashboard sous la clé default.

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

Cela peut sembler étrange la première fois, mais la logique est simple :

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

En d’autres termes, cet objet :

{
  Dashboard: Dashboard
}

est transformé en celui-ci avant que React ne le voie :

{
  default: Dashboard
}

Désormais, React.lazy() reçoit exactement la structure qu’il attend. Si un projet charge de manière différée de nombreuses exportations nommées, il est judicieux d’encapsuler ce schéma dans un petit outil partagé afin que le remappage soit centralisé en un seul endroit. De plus, conservez chaque appel à lazy() au niveau du module plutôt que dans le corps d’un composant ; en le déclarant pendant le rendu, on crée un nouveau composant différé à chaque fois et on réinitialise son état.

Pourquoi les imports statiques ne rencontrent jamais ce problème

Avec un import nommé statique, vous indiquez explicitement quelle exportation vous souhaitez :

import { Dashboard } from "./Dashboard";

JavaScript résout automatiquement le lien vers Dashboard. Avec un import par défaut, vous demandez plutôt l’exportation par défaut :

import Dashboard from "./Dashboard";

Cependant, un chargeur différé ne sélectionne rien :

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

Il remet à React l’ensemble de l’objet module, et React doit en sélectionner une partie. C’est pourquoi l’export par défaut n’a d’importance que dans le cas lazy.

Comment Suspense s’intègre au flux

React.lazy() apparaît rarement sans Suspense:

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

Cette combinaison prend sens une fois qu’on se souvient que le composant n’est pas disponible immédiatement. Le navigateur peut devoir télécharger un chunk JavaScript séparé avant que React ne puisse afficher Dashboard, et pendant ce temps, React a besoin de quelque chose d’autre à afficher. La propriété fallback le fournit. La séquence complète est la suivante :

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

Considéré comme une séquence, lazy() est bien moins mystérieux : Suspense gère la période d’attente, et c’est l’export default qui est rendu en fin de processus. Un point connexe concerne le fait qu’un téléchargement de chunk échoué rejette la Promise, et Suspense ne gère pas ce cas ; c’est un bordure d’erreur autour du composant lazy qui permet de le capturer.

Le véritable avantage réside dans le splitage du code

La raison principale d’utiliser des imports dynamiques est justement le splitage du code. Sans une telle appel :

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

le code du Dashboard serait probablement inclus dans le JavaScript nécessaire pour démarrer l’application. Avec l’import dynamique, le bundler peut le placer dans son propre chunk. Conceptuellement, le résultat se divise en un bundle initial :

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

et en un chunk qui est chargé ultérieurement :

Dashboard chunk
└── Dashboard

Le code du tableau de bord n’est alors téléchargé que lorsque l’utilisateur en a réellement besoin. Parmi les exemples pertinents, on trouve :

  • Les tableaux de bord d’administration
  • Les pages d’analyse
  • Les écrans de configuration
  • Les éditeurs volumineux
  • Les routes que les utilisateurs consultent rarement
  • Les composants lourds qui intègrent de nombreuses dépendances

Cela ne signifie pas pour autant que chaque composant doit être chargé de manière différée. Chaque division ajoute une requête réseau et un état de chargement, de sorte qu’un petit bouton ne bénéficie en rien d’un téléchargement séparé. L’objectif n’est pas de rendre tout chargé différément, mais plutôt d’éviter que le paquet initial ne contienne du code dont l’utilisateur n’a pas encore besoin.

Les erreurs provenant des composants internes de React ne sont pas toujours des bugs de React

Lorsqu’une exception apparaît à l’intérieur des composants internes de React, il est tentant de soupçonner React lui-même. L’historique d’erreurs peut inclure des éléments tels que ceux-ci :

lazyInitializer
resolveLazy
beginWork

La conclusion logique est qu’il y a un problème à l’intérieur de React. Avant d’emprunter cette voie, vérifiez le contrat de l’API que vous appelez. La question clé ici est ce que React.lazy() attend que son chargeur retourne. En termes simplifiés, la réponse est :

Promise<{
  default: Component
}>

Comparez cela avec ce que le module fournit réellement. Si le fichier contient ceci :

export const Dashboard = ...

alors la valeur résolue est en fait celle-ci :

{
  Dashboard: Dashboard
}

En les comparant côte à côte, la cause devient évidente. React n’est pas défectueux ; les exports du module et le contrat du chargeur ne correspondent tout simplement pas.

Un modèle mental pour interpréter les appels lazy()

Au lieu de lire cette ligne comme « charger en différé le Dashboard » :

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

il est utile de la considérer comme un petit pipeline :

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

Ce modèle permet de diagnostiquer rapidement ces problèmes. Il explique également pourquoi le premier des fichiers suivants fonctionne immédiatement :

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

alors que le second nécessite des travaux supplémentaires :

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

lorsqu’il est chargé avec cette appel :

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

La leçon plus large sur le débogage

Ce petit détail de React recèle une leçon plus importante. Lorsqu’une API se comporte de manière inattendue, regardez au-delà de la syntaxe que vous avez tapée et examinez la valeur qui circule réellement à travers l’API. Un appel comme celui-ci semble trivial :

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

En réalité, il s’agit d’une chaîne de transformations :

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

Lorsque cette chaîne devient visible, il devient facile de comprendre l’erreur, et la même approche fonctionne dans tout JavaScript. Au lieu de se demander pourquoi une bibliothèque génère une erreur, il faut plutôt se demander ce que l’on passe exactement à la fonction et ce qu’elle attend en retour. Parfois, toute la différence réside dans le nom d’une seule propriété :

default

Points clés

  • import() renvoie un objet module, et non un composant, tandis que React.lazy() lit le composant à partir de sa propriété default.
  • Les exports nommés sont acceptables ; il suffit de les rémapper avec .then((module) => ({ default: module.Name })) lorsqu’ils sont chargés de manière différée.
  • Déclarez les composants différés au niveau du module, enveloppez-les dans Suspense pour gérer les états de chargement, et ajoutez une limite d’erreur en cas d’échec du téléchargement des chunks.
  • Diviser les écrans lourds et peu utilisés, pas de petits composants.
  • Lorsqu’une erreur provient à l’intérieur d’une bibliothèque, comparez la valeur que vous passez avec celle attendue par le contrat API avant d’attribuer l’erreur à une faille de la bibliothèque.