12k
All articles

Code-Splitting-Strategien für Ihre Frontend-App

Vergleichen Sie Strategien zur Code-Aufteilung im Frontend: Trennen Sie Routen und schwere Komponenten, laden Sie Code bei Interaktion vorab und messen Sie die Leistung im Netzwerk-Wasserfall.

OpenReplay Team
OpenReplay Team
Code-Splitting-Strategien für Ihre Frontend-App

Code Splitting zerlegt ein JavaScript-Bundle in separate Chunks, die der Browser erst dann herunterlädt, wenn der darin enthaltene Code tatsächlich benötigt wird. So zahlt der erste Seitenaufruf nur für die erste Seite statt für die gesamte App.

Lighthouse meldet einen ganzen Berg ungenutztes JavaScript, und Sie haben einer Komponente bereits React.lazy hinzugefügt – trotzdem liefert die Produktionsumgebung weiterhin einen einzigen großen Entry-Chunk aus. Die Lösung sind nicht noch mehr lazy()-Aufrufe an allen Ecken und Enden. Sie müssen wissen, welche Splits sich lohnen, was jeder einzelne kostet und woran Sie erkennen, ob er funktioniert hat.

Dieser Artikel stellt fünf Strategien in der Reihenfolge vor, in der sie sich üblicherweise auszahlen, zeigt, wie Sie sie messen, und erklärt die drei Fälle, in denen Code Splitting eine App langsamer macht.

Das Wichtigste in Kürze

  • Routenbasiertes Splitting ist in nahezu jeder Single-Page-App der erste sinnvolle Split, denn jede Route wird zu einem eigenen Chunk, und Besucher laden nur die Route herunter, auf der sie landen.
  • webpack 5 und Rolldown (der Bundler in Vite 8) verschieben Code, den mehrere lazy geladene Chunks gemeinsam nutzen, bereits automatisch in gemeinsame Chunks. Ein manuell erstelltes vendor.js ist daher heute vor allem für die Cache-Stabilität nützlich, nicht für die Größe.
  • Wenn Sie import() bereits bei Hover oder Fokus statt erst beim Klick starten, wird der Chunk heruntergeladen, während der Nutzer noch überlegt, ob er klicken soll.
  • Ein kleinerer Entry-Chunk beweist für sich genommen nichts. Der Beweis ist ein gedrosselter Netzwerk-Wasserfall, in dem der Code für den ersten Bildschirm früher fertig geladen ist als zuvor.
  • Code zu splitten, den der erste Bildschirm benötigt, beseitigt die Wartezeit nicht. Es verschiebt sie nur nach hinten.

Warum verlangsamt ein einzelnes Bundle die erste Seite?

Ohne Splitting liefert eine Single-Page-App ein einziges Entry-Bundle aus, das jede Route, jedes Modal und jede Bibliothek enthält, die die App jemals verwenden könnte. Wer auf Ihrer Marketingseite landet, lädt den Einstellungsbildschirm, das Admin-Dashboard und die Charting-Bibliothek herunter, parst und führt sie aus – selbst wenn er keine davon je öffnet. Dieser ungenutzte Code konkurriert um Bandbreite und Main-Thread-Zeit mit dem Code, der die Seite tatsächlich rendert.

Die fünf folgenden Code-Splitting-Strategien unterscheiden sich jeweils im Auslöser, in den Kosten und in der Art, wie Sie ihren Erfolg überprüfen:

StrategieWann sie greiftWas sie kostetWie Sie den Erfolg prüfen
Route-SplittingJede SPA mit mehreren RoutenEin Fallback bei jeder NavigationDer Entry-Chunk enthält keine anderen Routen mehr
Komponenten-SplittingSchwergewichtige UI hinter einer Interaktion oder unterhalb des sichtbaren BereichsEin Ladezustand und ein zusätzlicher RequestDer Chunk wird erst beim Auslösen geladen
Manuelle Vendor-ChunksStabile Abhängigkeiten, die langfristig gecacht werden sollenKonfigurationspflege und mögliche Änderungen der AusführungsreihenfolgeDer Hash bleibt über Deployments hinweg gleich
Interaktions-PrefetchKlicks mit hoher WahrscheinlichkeitGelegentlich wird Code geladen, den niemand nutztDer Request startet vor dem Klick
Nicht splittenCode, den der erste Bildschirm benötigtKeineKeine neuen Request-Ketten

Routenbasiertes Code Splitting zuerst

Routenbasiertes Splitting ist in nahezu jeder Single-Page-App der erste lohnende Split. Jede Route wird zu einem eigenen Chunk, und ein Besucher lädt nur die Route herunter, auf der er landet. Der Aufwand ist gering, und Routenwechsel sind ohnehin natürliche Stellen für einen kurzen Ladezustand. Dynamische Imports in React verstehen erläutert das Muster mit React.lazy und Suspense. Meta-Frameworks wie Next.js und SvelteKit splitten automatisch nach Routen.

Schwergewichtige Komponenten hinter einer Interaktion splitten

Splitting auf Komponentenebene eignet sich für UI, die schwergewichtig und beim ersten Rendern nicht sichtbar ist: ein Modal, ein Rich-Text-Editor, ein Chart-Widget oder ein Kommentarbereich unterhalb des sichtbaren Bereichs (Below the Fold). Der Preis dafür ist ein Ladezustand sowie ein Netzwerk-Request genau in dem Moment, in dem der Nutzer die Funktion verwenden möchte.

Jedes große Framework bietet Ihnen dafür dasselbe Grundbaustein-Konstrukt auf Basis des dynamischen import():

// 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'));

Eine React-Lazy-Komponente wird innerhalb einer Suspense-Boundary gerendert, die einen Fallback anzeigt, während der Chunk lädt. In Next.js fasst next/dynamic React.lazy() und Suspense in einem einzigen Aufruf zusammen, und Vue bietet defineAsyncComponent. Svelte verwendet import() direkt in Kombination mit bedingtem Rendering.

Bei Inhalten unterhalb des sichtbaren Bereichs sollten Sie das Laden auslösen, sobald sich das Element dem Viewport nähert. Erstellen Sie einen Intersection Observer, trennen Sie ihn nach der ersten Überschneidung mit disconnect() und rendern Sie dann die Lazy-Komponente. Lazy Loading in JavaScript behandelt die entsprechenden Observer-Muster.

Was Ihr Bundler bereits für Sie splittet

Die Standardeinstellungen von splitChunks in webpack 5 sowie Rollup (bzw. Rolldown in Vite 8) verschieben Code, den mehrere lazy geladene Chunks gemeinsam nutzen, automatisch in einen gemeinsamen Chunk. Standardmäßig tut webpack das nur für asynchrone Chunks (chunks: 'async') und nur dann, wenn der neue Chunk eine Mindestgröße überschreitet. Die eingebaute Cache-Gruppe defaultVendors erfasst bereits node_modules. Der aktuelle Stand der JavaScript-Bundler gibt einen Überblick darüber, wo die einzelnen Bundler heute stehen.

Da der Bundler gemeinsam genutzten Code bereits extrahiert, dupliziert ein einzelnes, manuell erstelltes vendor.js größtenteils das, was webpack oder Rolldown ohnehin erledigen. Der wichtigste verbleibende Grund, Vendor-Chunks von Hand zu definieren, ist die Cache-Stabilität: Wenn selten geänderte Abhängigkeiten über Deployments hinweg denselben Content-Hash behalten, müssen wiederkehrende Besucher sie nicht erneut herunterladen.

// webpack 5
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        framework: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'framework',
          chunks: 'all',
        },
      },
    },
  },
};

chunks: 'all' ist hier zwingend erforderlich. Ohne diese Einstellung bleibt Framework-Code, der vom Entry importiert wird, im Entry-Chunk, und dessen Hash ändert sich bei jedem Deployment.

In Vite 7 und älter wurde dasselbe Konzept über build.rollupOptions.output.manualChunks umgesetzt. Vite 8 baut mit Rolldown. Es unterstützt die Objektform von manualChunks nicht mehr und markiert die Funktionsform als veraltet – zugunsten von codeSplitting-Gruppen:

// Vite 8 (Rolldown)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [{ name: 'vendor', test: /node_modules[\\/](react|react-dom)[\\/]/ }],
        },
      },
    },
  },
});

Halten Sie die Gruppen eng gefasst. Die Rolldown-Dokumentation warnt davor, dass manuell definierte Gruppen die Ausführungsreihenfolge von Modulen verändern können – was bei Modulen mit Seiteneffekten relevant ist.

Laden bei Hover statt beim Klick starten

Beim Interaktions-Prefetching wird import() bereits gestartet, wenn der Mauszeiger einen Button erreicht oder dieser den Fokus erhält, und nicht erst beim Klick. So wird der Chunk heruntergeladen, während der Nutzer noch überlegt. Der Download überbrückt die Lücke zwischen Absicht und Aktion, sodass der Fallback nur kurz oder gar nicht erscheint.

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 und die Event-Handler teilen sich eine einzige Loader-Funktion. Das Modulsystem cacht das Ergebnis des ersten import()-Aufrufs, sodass das Rendern nach dem Klick den bereits laufenden Request wiederverwendet. Der Preis ist etwas verschwendete Bandbreite, wenn ein Nutzer zwar mit der Maus darüberfährt, aber nicht klickt.

Hinweise wie webpackPrefetch sind Magic Comments von webpack. Turbopack in Next.js liest zwar einige webpack-kompatible Magic Comments, die Dokumentation nennt jedoch nur Kommentare zur Import-Steuerung wie webpackIgnore, nicht aber webpackPrefetch, und andere Bundler werten sie in der Regel gar nicht aus. Prüfen Sie die Dokumentation Ihres Bundlers, bevor Sie sich darauf verlassen. Das Auslösen von import() aus einem Event-Handler funktioniert dagegen mit jedem Bundler.

Den Wasserfall messen, nicht die Bundle-Größe

Ein kleinerer Entry-Chunk beweist für sich genommen nichts. Der Nachweis, dass ein Split geholfen hat, ist ein gedrosselter Netzwerk-Wasserfall, in dem der Code für den ersten Bildschirm früher fertig geladen ist als zuvor.

  1. Führen Sie einen Bundle-Visualizer für Ihren Bundler aus, um zu prüfen, was jeder Chunk enthält. Für webpack zeigt die Anleitung zum Webpack Bundle Analyzer, wie Sie schwergewichtige Module und von der Seite nicht genutzten Code aufspüren.
  2. Öffnen Sie das Network-Panel der Chrome DevTools, filtern Sie nach JS und wählen Sie ein gedrosseltes mobiles Netzwerkprofil.
  3. Vergleichen Sie Vorher und Nachher. Prüfen Sie, wann der letzte für das erste Rendern benötigte Chunk fertig geladen ist und ob neue sequenzielle Request-Ketten entstanden sind.

Eine solche Kette sieht aus wie eine Treppe: Jeder Chunk-Request beginnt erst, nachdem der vorherige abgeschlossen ist. Verschachtelte Lazy-Imports erzeugen genau dieses Muster.

Wann macht Code Splitting die App langsamer?

Code Splitting kann auf drei vorhersehbare Arten nach hinten losgehen.

Zu viele kleine Chunks. Jeder Chunk ist ein separater Request. Wer kleine Komponenten splittet, erzeugt zusätzliche Requests und Roundtrips, die mehr kosten, als die eingesparten Bytes bringen.

Splitting des kritischen Pfads. Code zu splitten, den der erste Bildschirm benötigt, beseitigt die Wartezeit nicht – es verschiebt sie nur. Der Browser muss nun zuerst den Entry-Chunk herunterladen und ausführen, das dynamische import() erreichen und erst dann den Chunk anfordern, der den Inhalt rendert.

Ein Spinner, wo früher Inhalt war. Ein Fallback, der Inhalte ersetzt, die Nutzer bisher sofort gesehen haben, kann die App langsamer wirken lassen – selbst wenn die Gesamtladezeit gesunken ist.

Kombinieren Sie jede Lazy-Boundary mit einer Error Boundary. Ein Chunk-Request kann fehlschlagen, und ein Tab, der vor einem Deployment geöffnet wurde, fordert möglicherweise gehashte Dateien an, die nicht mehr existieren. Session Replays von Apps mit Code Splitting zeigen Dinge, die ein Bundle-Report nicht erfassen kann: Nutzer, die auf Suspense-Fallbacks warten oder nach einem Deployment mitten in der Sitzung auf einen ChunkLoadError stoßen.

Welche Splits Sie behalten sollten

Beginnen Sie mit Routen und splitten Sie anschließend schwergewichtige Komponenten hinter Interaktionen. Nutzen Sie Prefetching bei Hover oder Fokus und überlassen Sie gemeinsam genutzten Code dem Bundler, es sei denn, Sie benötigen gezielt stabile Cache-Hashes. Vergleichen Sie bei jedem Split den Wasserfall unter Drosselung vorher und nachher. Wenn der erste Bildschirm nicht schneller rendert oder eine Interaktion nicht schneller reagiert, machen Sie den Split rückgängig.

FAQs

Funktioniert React.lazy mit Named Exports?

Nicht direkt. React.lazy erwartet, dass das Promise des Loaders zu einem Modul aufgelöst wird, das die Komponente als Default-Export enthält. Ein Named Export muss daher umgemappt werden: lazy(() => import('./Charts').then((m) => ({ default: m.BarChart }))). Der Chunk wird dabei genauso gesplittet. Alternativ können Sie die Komponente in einer kleinen Wrapper-Datei als Default erneut exportieren – das funktioniert ebenfalls und hält den lazy-Aufruf kurz.

Wie gehe ich damit um, wenn ein Lazy-Chunk nach einem Deployment nicht geladen werden kann?

Laden Sie die Seite neu, damit der Browser den neuen Entry-Chunk und die aktuellen Chunk-URLs abruft. React behält das erste Promise eines Lazy-Loaders bei und ruft den Loader nie ein zweites Mal auf, ein erneutes Rendern allein löst also keinen neuen Versuch aus. Schlägt ein dynamischer Import fehl, feuert Vite ein 'vite:preloadError'-Event auf window; der Aufruf von event.preventDefault() im Listener unterdrückt den Fehler. Begrenzen Sie automatische Reloads, um eine Reload-Schleife zu vermeiden.

Was ist der Unterschied zwischen webpackPrefetch und webpackPreload?

webpackPrefetch lädt einen Chunk in der Leerlaufzeit des Browsers herunter, nachdem der übergeordnete Chunk fertig geladen ist – für Code, der wahrscheinlich später benötigt wird, etwa für den nächsten Bildschirm. webpackPreload ruft den Chunk gleichzeitig mit dem übergeordneten Chunk mit mittlerer Priorität ab – für Code, den der übergeordnete Chunk unmittelbar anfordern wird. Beide sind webpack-Magic-Comments, die Link-Hints erzeugen. Preload ist die seltenere Wahl, da ein übermäßiger Einsatz der Performance schaden kann.

Verhindert Vite Chunk-Wasserfälle automatisch?

Teilweise. Wenn ein asynchroner Chunk von einem gemeinsam genutzten Common Chunk abhängt, schreibt Vite den dynamischen Import mit einem Preload-Schritt um, sodass beide parallel statt nacheinander abgerufen werden. Außerdem erzeugt Vite modulepreload-Links für Entry-Chunks und deren direkte Imports. Das deckt allerdings nur statische Abhängigkeiten ab. Eine Lazy-Komponente, die beim Rendern erneut import() aufruft, erzeugt weiterhin einen sequenziellen Request, der im Wasserfall sichtbar wird.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.