Stratégies de code splitting pour votre application frontend
Comparez les stratégies de découpage du code frontend : séparez routes et composants lourds, préchargez au survol, configurez les chunks vendor et mesurez les performances.
Le code splitting (découpage du code) divise un bundle JavaScript en chunks distincts que le navigateur ne télécharge qu’au moment où leur code devient nécessaire. Ainsi, le premier chargement ne coûte que la première page, et non l’application entière.
Lighthouse signale une montagne de JavaScript inutilisé. Vous avez déjà ajouté React.lazy à un composant, et pourtant la production livre toujours un unique chunk d’entrée volumineux. La solution n’est pas de multiplier les appels à lazy() partout. Il faut savoir quels découpages valent la peine, ce que chacun coûte et comment vérifier qu’il a fonctionné.
Cet article présente cinq stratégies, dans l’ordre où elles sont généralement rentables, la manière de les mesurer et les trois façons dont le code splitting ralentit une application.
Points clés
- Le découpage par route est le premier à mettre en place dans presque toutes les single-page apps : chaque route devient son propre chunk, et les visiteurs ne téléchargent que la route sur laquelle ils arrivent.
- webpack 5 et Rolldown (le bundler de Vite 8) déplacent déjà le code partagé par les chunks chargés en lazy loading vers des chunks communs. Un
vendor.jsconstruit à la main sert donc aujourd’hui surtout à stabiliser le cache, et non à réduire la taille. - Si vous déclenchez
import()au survol ou à la prise de focus plutôt qu’au clic, le chunk se télécharge pendant que l’utilisateur hésite encore à cliquer. - Un chunk d’entrée plus petit ne prouve rien à lui seul. La preuve, c’est une cascade réseau (waterfall) en débit limité où le code du premier écran termine son chargement plus tôt qu’avant.
- Découper le code dont le premier écran a besoin ne supprime pas l’attente. Cela ne fait que la repousser.
Pourquoi un bundle unique ralentit-il la première page ?
Sans découpage, une single-page app livre un seul bundle d’entrée contenant toutes les routes, toutes les modales et toutes les bibliothèques que l’application pourrait utiliser. Un visiteur qui arrive sur votre page marketing télécharge, parse et exécute l’écran des paramètres, le tableau de bord d’administration et la bibliothèque de graphiques, même s’il n’ouvre jamais aucun d’entre eux. Ce code inutilisé entre en concurrence, pour la bande passante et le temps du thread principal, avec le code qui affiche réellement la page.
Les cinq stratégies de code splitting ci-dessous ont chacune un déclencheur, un coût et une méthode de vérification différents :
| Stratégie | Quand l’appliquer | Ce qu’elle coûte | Comment vérifier qu’elle fonctionne |
|---|---|---|---|
| Découpage par route | Toute SPA à plusieurs routes | Un fallback à chaque navigation | Le chunk d’entrée ne contient plus les autres routes |
| Découpage par composant | UI lourde derrière une interaction ou sous la ligne de flottaison | Un état de chargement et une requête supplémentaire | Le chunk ne se charge qu’au déclenchement |
| Chunks vendor manuels | Dépendances stables à mettre en cache sur le long terme | Maintenance de la configuration et risque de modifier l’ordre d’exécution | Le hash reste identique d’un déploiement à l’autre |
| Préchargement à l’interaction | Clics très probables | Récupérer parfois du code que personne n’utilise | La requête démarre avant le clic |
| Ne pas découper | Code nécessaire au premier écran | Aucun | Aucune nouvelle chaîne de requêtes |
Le découpage par route d’abord
Le découpage par route est le premier qui vaille la peine dans presque toutes les single-page apps. Chaque route devient son propre chunk, et un visiteur ne télécharge que la route sur laquelle il arrive. Il demande peu de code, et les changements de route sont des moments naturels pour afficher brièvement un état de chargement. Understanding React dynamic imports détaille ce pattern avec React.lazy et Suspense. Les méta-frameworks comme Next.js et SvelteKit découpent automatiquement par route.
Découper les composants lourds derrière une interaction
Le découpage par composant convient aux éléments d’UI lourds et invisibles au premier rendu : une modale, un éditeur de texte riche, un widget de graphiques, une section de commentaires sous la ligne de flottaison. Le coût : un état de chargement, plus une requête réseau au moment précis où l’utilisateur veut accéder à la fonctionnalité.
Tous les grands frameworks proposent la même primitive, construite sur l’import() dynamique :
// React
import { lazy } from 'react';
const Chart = lazy(() => import('./Chart'));
// Next.js
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'));
// Vue
import { defineAsyncComponent } from 'vue';
const Chart = defineAsyncComponent(() => import('./Chart.vue'));
Un composant React lazy s’affiche à l’intérieur d’une frontière Suspense, qui présente un fallback pendant le chargement du chunk. Dans Next.js, next/dynamic regroupe React.lazy() et Suspense en un seul appel, et Vue propose defineAsyncComponent. Svelte utilise directement import() avec un rendu conditionnel.
Pour le contenu situé sous la ligne de flottaison, déclenchez le chargement lorsque l’élément approche du viewport. Créez un seul Intersection Observer, déconnectez-le après la première intersection, puis affichez le composant lazy. Lazy loading in JavaScript présente les patterns d’utilisation de l’observer.
Ce que votre bundler découpe déjà pour vous
Les paramètres par défaut de splitChunks dans webpack 5, ainsi que Rollup (Rolldown dans Vite 8), déplacent automatiquement le code partagé par plusieurs chunks chargés en lazy loading vers un chunk commun. Par défaut, webpack ne le fait que pour les chunks asynchrones (chunks: 'async') et seulement si le nouveau chunk dépasse une taille minimale. Son cache group intégré defaultVendors cible déjà node_modules. The current state of JavaScript bundlers dresse un état des lieux de chaque bundler aujourd’hui.
Puisque le bundler extrait déjà le code partagé, un vendor.js unique construit à la main fait en grande partie doublon avec le travail de webpack ou de Rolldown. La principale raison qui subsiste pour définir manuellement des chunks vendor est la stabilité du cache. Si les dépendances qui changent rarement conservent le même hash de contenu d’un déploiement à l’autre, les visiteurs réguliers n’ont pas à les retélécharger.
// webpack 5
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'framework',
chunks: 'all',
},
},
},
},
};
chunks: 'all' est indispensable ici. Sans cette option, le code du framework importé par l’entrée reste dans le chunk d’entrée, et le hash de celui-ci change à chaque déploiement.
Dans Vite 7 et les versions antérieures, la même idée s’écrivait avec build.rollupOptions.output.manualChunks. Vite 8 utilise Rolldown pour le build. Il ne prend plus en charge la forme objet de manualChunks et déprécie la forme fonction au profit des groupes codeSplitting :
// Vite 8 (Rolldown)
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [{ name: 'vendor', test: /node_modules[\\/](react|react-dom)[\\/]/ }],
},
},
},
},
});
Gardez des groupes restreints. La documentation de Rolldown avertit que les groupes définis manuellement peuvent modifier l’ordre d’exécution des modules, ce qui pose problème pour les modules à effets de bord.
Charger au survol, pas au clic
Le préchargement à l’interaction déclenche import() lorsque le pointeur entre sur un bouton ou que celui-ci reçoit le focus, plutôt qu’au clic. Le chunk se télécharge donc pendant que l’utilisateur hésite encore. Le téléchargement chevauche l’intervalle entre l’intention et l’action, si bien que le fallback ne s’affiche que brièvement, voire pas du tout.
import { lazy, Suspense, useState } from 'react';
const loadEditor = () => import('./RichTextEditor');
const RichTextEditor = lazy(loadEditor);
function ReplyBox() {
const [open, setOpen] = useState(false);
return (
<>
<button onMouseEnter={loadEditor} onFocus={loadEditor} onClick={() => setOpen(true)}>
Reply
</button>
{open && <Suspense fallback={<p>Loading editor…</p>}><RichTextEditor /></Suspense>}
</>
);
}
lazy et les gestionnaires d’événements partagent une même fonction de chargement. Le système de modules met en cache le résultat du premier import() : le rendu qui suit le clic réutilise donc la requête déjà en cours. Le coût : un peu de bande passante gaspillée lorsqu’un utilisateur survole le bouton sans cliquer.
Les indications comme webpackPrefetch sont des commentaires magiques webpack. Turbopack dans Next.js lit quelques commentaires magiques compatibles avec webpack, mais sa documentation ne mentionne que des commentaires de contrôle d’import comme webpackIgnore, pas webpackPrefetch, et les autres bundlers ne les lisent généralement pas. Consultez la documentation de votre bundler avant de vous appuyer sur l’un d’eux. Déclencher import() depuis un gestionnaire d’événements fonctionne avec tous les bundlers.
Mesurer la cascade réseau, pas la taille du bundle
Un chunk d’entrée plus petit ne prouve rien à lui seul. La preuve qu’un découpage a été utile, c’est une cascade réseau en débit limité où le code du premier écran termine son chargement plus tôt qu’auparavant.
- Lancez un outil de visualisation de bundle adapté à votre bundler pour vérifier le contenu de chaque chunk. Pour webpack, le tutoriel sur Webpack Bundle Analyzer montre comment repérer les modules lourds et le code inutilisé par la page.
- Ouvrez le panneau Network des Chrome DevTools, filtrez sur JS et appliquez un profil réseau mobile limité.
- Comparez l’avant et l’après. Vérifiez à quel moment se termine le dernier chunk nécessaire au premier rendu, et si de nouvelles chaînes de requêtes séquentielles sont apparues.
Une chaîne ressemble à un escalier : chaque requête de chunk ne démarre qu’une fois la précédente terminée. Les imports lazy imbriqués produisent ce schéma.
Quand le code splitting ralentit-il l’application ?
Le code splitting se retourne contre vous de trois manières prévisibles.
Trop de petits chunks. Chaque chunk correspond à une requête distincte. Découper de petits composants ajoute des requêtes et des allers-retours qui coûtent plus cher que les octets économisés.
Découper le chemin critique. Découper le code dont le premier écran a besoin ne supprime pas l’attente : cela la déplace. Le navigateur doit désormais télécharger et exécuter le chunk d’entrée, atteindre l’import() dynamique, et seulement ensuite demander le chunk qui affiche le contenu.
Un spinner à la place du contenu. Un fallback qui remplace un contenu que les utilisateurs voyaient auparavant instantanément peut donner l’impression que l’application est plus lente, même si le temps de chargement total a diminué.
Associez chaque frontière lazy à une error boundary. Une requête de chunk peut échouer, et un onglet ouvert avant un déploiement peut demander des fichiers hashés qui n’existent plus. Les replays de session d’applications découpées révèlent ce qu’aucun rapport de bundle ne montre : des utilisateurs bloqués sur des fallbacks Suspense, ou confrontés à une ChunkLoadError après un déploiement en cours de session.
Choisir les découpages à conserver
Commencez par les routes, puis découpez les composants lourds derrière des interactions. Préchargez au survol ou au focus, et laissez le bundler gérer le code partagé, sauf si vous recherchez spécifiquement des hashs de cache stables. Pour chaque découpage, comparez la cascade réseau en débit limité avant et après. Si le premier écran ne s’affiche pas plus tôt, ou si une interaction ne répond pas plus vite, annulez le découpage.
FAQ
React.lazy fonctionne-t-il avec les exports nommés ?
Pas directement. React.lazy exige que la promesse de son loader se résolve sur un module dont le composant est l'export par défaut ; un export nommé doit donc être remappé : lazy(() => import('./Charts').then((m) => ({ default: m.BarChart }))). Le chunk est découpé de la même façon. Réexporter le composant comme export par défaut depuis un petit fichier wrapper fonctionne aussi et garde l'appel à lazy concis.
Comment récupérer lorsqu'un chunk lazy ne se charge pas après un déploiement ?
Rechargez la page pour que le navigateur récupère le nouveau chunk d'entrée et les URL de chunks actuelles. React conserve la première promesse d'un loader lazy et n'appelle jamais le loader une seconde fois : un simple nouveau rendu ne relancera donc pas la tentative. Lorsqu'un import dynamique échoue, Vite déclenche un événement 'vite:preloadError' sur window, et appeler event.preventDefault() dans l'écouteur supprime l'erreur. Limitez le nombre de rechargements automatiques pour éviter une boucle de rechargement.
Quelle est la différence entre webpackPrefetch et webpackPreload ?
webpackPrefetch télécharge un chunk pendant les temps d'inactivité du navigateur, une fois le chunk parent chargé, pour du code probablement nécessaire plus tard, comme l'écran suivant. webpackPreload récupère le chunk en même temps que son parent, avec une priorité moyenne, pour du code que le parent demandera immédiatement. Ce sont deux commentaires magiques webpack qui émettent des link hints. Le preload est le choix le plus rare, car en abuser peut nuire aux performances.
Vite empêche-t-il automatiquement les cascades de chunks ?
En partie. Lorsqu'un chunk asynchrone dépend d'un chunk commun partagé, Vite réécrit l'import dynamique avec une étape de préchargement afin que les deux soient récupérés en parallèle plutôt que l'un après l'autre. Il émet également des liens modulepreload pour les chunks d'entrée et leurs imports directs. Cela ne couvre que les dépendances statiques. Un composant lazy qui appelle à nouveau import() lors de son rendu crée toujours une requête séquentielle visible dans la cascade réseau.