Was macht eigentlich ein Bundler?
In der Frontend-Entwicklung kommt man selten ohne JavaScript aus. Gestandene Backend-Profis sehen sich dann mit einer komplett neuen Toolchain konfrontiert, die vor unbekannten Fachbegriffen nur so strotzt. Doch hinter der Komplexität steckt System.
Was ist die Aufgabe einer Webanwendung?
Eine Server-Komponente liefert einen Strauß aus HTML, JavaScript, CSS und weiteren Dateien (z. B. Bilder und Schriften) via HTTP an einen Browser. Der Browser setzt die Teile zusammen und rendert eine komplette Webseite. Unabhängig davon, welcher Technologie-Stack konkret eingesetzt wird, soll dieser Vorgang vordergründig möglichst schnell sein, möglichst auf allen Browsern "gut" aussehen und natürlich eine gute Oberfläche für die Nutzer:innen bieten.
Als Beispiel für diesen Artikel soll eine Anwendung dienen, die als clientseitiges JavaScript-Framework React einsetzt. Denn insbesondere damit ist man auf eine ganze Reihe von Tools angewiesen, die aus dem JavaScript-Quelltext für Browser leicht verdauliche Häppchen bastelt. Wir können uns also mit diesem Beispiel einen guten Überblick über die gängigen Technologien verschaffen. Grundsätzlich sind die hier erklärten Konzepte aber ebenso anwendbar auf klassische, auf dem Server gerenderte Anwendungen.
Die Leitung
Das Hauptproblem, was sämtliche Webanwendungen (und auch einfache Webseiten) plagt, ist die Netzwerkverbindung. Diese kann generell jederzeit wegbrechen, langsam und unzuverlässig sein. Bei Weitem nicht jeder User ist per Glasfaser angebunden.
Doch selbst bei perfekter Verbindung gibt es natürliche Limits: Browser müssen entscheiden, ob sie die für eine Webseite benötigten Dateien parallel oder sequenziell anfordern sollen. Parallele Verbindungen haben den Vorteil, dass Downloads schneller ablaufen, aber sie haben auch Overhead pro Verbindungsaufbau.
Moderne Browser nutzen ausgefuchste Heuristiken oder neuere Protokolle wie HTTP/2 oder HTTP/3 (alias "QUIC"), um das letzte bisschen Leistung aus der Verbindung herauszuquetschen. Darauf hat man als Entwickler:in aber selten Einfluss.
Der weitaus größere Hebel besteht darin, die Menge der nötigen Requests von vornherein zu vermindern. Dazu gibt es zwei miteinander kombinierbare Techniken:
- Aggressives Caching und/oder
- Zusammenfassen von mehreren Ressourcen.
Frontend-Tools können dies für Entwickler:innen übernehmen, daher auch die Bezeichnung "Bundler"; ein Build-Schritt, der vergleichbar mit statischem Linking ist. Für heutige Tools greift dies allerdings zu kurz, da sie neben der Kombination von JavaScript-Dateien (s. folgender Abschnitt) auch noch andere Arten von Ressourcen, wie z. B. Bilder und Styles, verarbeiten. Man findet daher auch manchmal die Begriffe "Frontend-Build-Tool" und "Asset-Pipeline". Doch dazu später mehr.
Die Kombination
Was kann man sich nun unter "Bundling" vorstellen? Bestimmte Ressourcen, die in mehreren Einzeldateien vorliegen, können problemlos kombiniert werden. Als Beispiel: Mehrere CSS-Dateien, die per <link> im HTML-Code eingebunden werden, können zu einer einzigen Datei zusammengefasst werden, in dem sie einfach konkateniert werden. Aus mehreren nötigen GET-Anfragen an den Server wird dadurch nur noch eine.
Auch bei JavaScript ist das möglich, ja, sogar geboten. Denn Bibliotheken wie React bestehen intern aus zahlreichen Einzeldateien; genau wie man es beispielsweise bei den typischen Backend-Programmiersprachen wie Java gewohnt ist. Zwar können moderne Browser dank dem JavaScript-Modul-Standard (dazu später mehr) auch importierte Referenzen auflösen, doch dies führt zum Problem der "kaskadierenden Requests":
- Datei frontend.js wird per <script> eingebunden.
- frontend.js importiert lib.js.
- lib.js importiert lib/dom.js.
- lib/dom.js importiert lib/util.js.
... und so weiter. Jede Zeile erzeugt dabei einen eigenen HTTP-Request. Der Browser kann erst anfangen, den JavaScript-Code auszuführen, wenn sämtliche (transitiven) Abhängigkeiten geladen werden.
Bundling setzt an dieser Stelle an und untersucht sämtliche Importe zur Buildzeit. Statt mehrere JavaScript-Dateien auszuliefern, werden diese konkateniert als Kombination von Framework und Anwendungscode aufbereitet.
Ein weiterer Fallstrick bei der Einbindung von JavaScript-Dateien ist, dass diese blockierend vom Browser ausgeführt werden, und zwar an genau der Stelle, wo das <script>-Tag steht. Wenn diese also im <head> der HTML-Datei auftauchen, pausiert der Browser das Rendering und lädt zunächst den weiteren Code herunter. Währenddessen bleibt der Inhalt des Browsers leer. Der Vollständigkeit halber sei noch gesagt, dass man per defer-Attribut die Verarbeitung der Script-Tags kurz vor das Ende der Datei verschieben kann. Dateien, die per type="module" geladen werden, werden automatisch verzögert geladen; bei diesen ist das defer-Attribut redundant.
Je nach Architekturstil gibt es hier mehrere Optionen. Bei Single-Page-Anwendungen ist der JavaScript-Code überhaupt erst dafür zuständig, Inhalt zu rendern. Man muss daher zwangsläufig darauf warten, bis dieser – einschließlich Framework – im Browser landet. Entscheidet man sich stattdessen für Server Side Rendering, kann man z. B. das <script>-Tag an das Ende des HTML-Codes (d. h. vor </body>) verschieben.
Zusätzlich übernehmen Bundler durch die Analyse von Import-Pfaden auch eine Übersetzung der Node-Welt in die Browser-Welt. Denn per Konvention landen per npm installierte Pakete im Ordner node_modules. Importiert man dann React via import React from "react", wird unter der Haube – vereinfacht gesagt – die Datei node_modules/react/index.js geladen. Dieser Mechanismus lässt sich in jedem npm-Paket individuell per package.json konfigurieren. Dem Browser ist das aber fremd; er würde versuchen, einen GET-Request auf die Ressource react zu machen, welcher dann mit 404 quittiert würde.
Ein anderer Lösungsansatz hierfür sind sogenannte "Import Maps", mit denen ein URL-Remapping für importierte Module definiert werden kann. Deren Browser-Unterstützung ist aber aktuell noch dürftig; außerdem lösen diese nicht alle Anwendungsfälle von Bundlern.
Der Cache
Oft ruft ein User eine Website nicht nur einmal auf. Insbesondere beim Server Side Rendering liefert der Webserver mehrere HTML-Seiten aus, wobei es eine starke Überlappung der Ressourcen gibt: Meistens nutzen alle Unterseiten das gleiche Stylesheet.
Um also Ladezeiten von wiederholten Aufrufen zu reduzieren, muss man sich über Caching Gedanken machen. Allein dazu könnte man einen ganzen Artikel schreiben, deswegen kann ich hier nur eine kurze Einführung zum Browsercache geben.
Ein Webserver kann in der Antwort auf eine GET-Anfrage das gewünschte Caching-Verhalten festlegen. Die allermeisten Webserver machen das automatisch. Wenn der Browser eine Ressource anfragt, antwortet der Server nicht nur mit dessen Inhalt, sondern liefert auch bestimmte Metadaten mit, wie etwa Änderungsdatum und Hash:
Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Der Inhalt mitsamt Metadaten landet dann im Browsercache. Fordert der User die Ressource erneut an, z. B. beim Folgen eines Links, sendet der Browser spezielle Header an den Server:
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Damit zeigt der Browser dem Server an, dass er den Content mit diesem Hash bereits hat. Hat sich die Ressource auf dem Server nicht geändert, antwortet dieser mit dem Statuscode 304 Not Modified. Dies wiederum signalisiert dem Browser, dass er den Inhalt aus dem Cache laden kann. Der Server muss also den Inhalt der Ressource nicht mitsenden und beendet die Antwort mit einem leeren Body.
Korrektes Caching hilft also, das Datenvolumen kleinzuhalten. Doch teilweise torpediert Bundling dieses Verhalten. Bleiben wir bei React: Zum Frameworkcode (ca. 132 Kilobyte in Version 17.0.2) kommt dann noch der Anwendungscode. Beides landet in der gleichen Datei, die vom Server demzufolge auch als eine einzige Ressource behandelt wird.
Dummerweise sorgen sowohl Codeänderungen als auch Framework-Updates dafür, dass sich das Bundle ändert. Im schlimmsten Fall muss also der Browser bei jedem Bugfix 132 Kilobyte unveränderten React-Code neu herunterladen, denn der Webserver weiß schließlich nicht, was genau sich geändert hat, sondern nur, dass sich irgendwo etwas geändert hat. Was zunächst nach wenig klingt, summiert sich schnell auf, wenn neben React noch andere Bibliotheken ins Spiel kommen.
Die Trennung
Das Gegenmittel dazu lautet "Code Splitting". Statt stumpf alle beteiligten Source-Dateien inklusive Bibliotheken in einer einzigen Datei zu verheiraten, geht man intelligenter vor: Üblicherweise landen externe Abhängigkeiten in einer Datei und anwendungsspezifischer Code in einer anderen Datei. Ein Bundler kümmert sich darum, dass die Import-Pfade korrekt umgesetzt werden, sodass man den Entwicklungsworkflow nicht ändern braucht. Lediglich Konfigurationsdateien müssen angepasst werden.
Code Splitting kann zu gewissem Grad auch manuell durchgeführt werden, da die Konfiguration stellenweise kompliziert ist. Dazu stellen viele Bibliotheken bereits "vorgebundelte" und komprimierte Dateien zum Download zur Verfügung, bei React beispielsweise react.production.min.js. Statt nun im eigenen Code import React from "react" zu schreiben, genügt es, auf die globale Variable React zuzugreifen. Denn das bereitgestellte Bundle exportiert sämtliche Funktionalität in dieser globalen Variable.
In der Praxis könnte der Header eine HTML-Datei dann wie folgt aussehen:
<script src="react.production.min.js"></script>
<script src="react-dom.production.min.js"></script>
<script src="app.js"></script>
Ich rate jedoch von diesem Ansatz ab, wenn man noch weitere Bibliotheken einbindet, weil es dann leicht unübersichtlich wird.
Manche Frontend-Frameworks, so auch React, erlauben zusätzlich, den Anwendungscode weiter zu unterteilen. Dazu kennen neuere JavaScript-Versionen sogenannte "dynamische Imports". Mittels dieser Technik, die in React-Komponenten integrierbar ist, kann ein allgemeines Gerüst von mehreren Webseiten gemeinsam benutzt werden, wobei einzelne Seiten dann ihren spezifischen Code nachladen können. Das passiert automatisch im Hintergrund: Der Browser beginnt bereits damit, das Gerüst zu rendern, bevor die "Innereien" zur Verfügung stehen kann. Ein Beispiel hierfür ist in der React-Dokumentation zu finden:
import React, { Suspense } from 'react';
const InnerComponent =
React.lazy(() => import('./OtherComponent'));
const MyComponent = () => (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
Bundler können diese dynamischen Imports erkennen und trennen den Anwendungscode passend auf. Dafür müssen Browser aber auch den "ESM"-Standard unterstützen (Stand Oktober 2021: über 90 %). Alternativ kann man sich auch Dateien in einem anderen Modulstandard generieren lassen, wovon ich aber abraten würde, denn dadurch handelt man sich weitere Komplexität ein.
Logischerweise sorgen zu feingliedrige Unterteilungen wiederum dafür, dass pro Seitenaufruf zu viele HTTP-Requests abgesetzt werden, denn trotz Caching muss der Browser pro Ressource eine Anfrage an den Webserver senden.
Die Unveränderlichkeit
Idealerweise sollte der Browser diesen Request gar nicht erst senden, wenn sich auf dem Server die Ressource nicht geändert hat. Aber woher soll der Browser das wissen? Das Schlüsselwort heißt "Fingerprinting".
Statt ein Skript unter der URL /app.js auszuliefern, kann man kurzerhand den passenden Hash einfügen: Aus /app.js wird /app.48de1f9.js. Im HTML-Code müssen nun die Dateinamen mit dem Hash – dem Fingerprint – referenziert werden.
Nun muss man noch den Server konfigurieren, bei diesen Dateien den folgenden Header zu setzen:
Cache-Control: public,max-age=31536000,immutable
Damit teilt der Server dem Browser mit, dass sich die Datei unter diesem Pfad niemals ändert. Sie kann also bedingungslos aus dem Cache geladen werden. Dadurch beschleunigt sich der wiederholte Seitenaufbau erheblich, denn der schnellste Request ist der, der gar nicht stattfindet.
Der oben angegebene HTML-Header muss dafür wie folgt abgeändert werden:
<script src="react.production.min.e4de6fa.js"></script>
<script src="react-dom.production.min.cd3664e.js"></script>
<script src="app.48de1f9.js"></script>
Ändert sich später der Inhalt einer Datei, ändert sich auch der Hash, damit auch die Referenz in HTML, damit erkennt der Browser, dass eine neue Ressource angefragt werden muss. Ein Bundler kann die Hashes automatisch anpassen (s. folgender Abschnitt).
Typischerweise wendet man diese Technik nur auf die sogenannten "Assets", d. h. Styles, JavaScript, Bilder, Schriften, o. ä. an. Für HTML-Seiten würde man auf die Fingerprints verzichten, denn sonst würden sich bei jedem Update auch die URLs ändern, was natürlich dem Gedanken der stabilen Links widerspräche.
Zusammengefasst kann man also feststellen: ETag-basiertes Caching für HTML, Fingerprint+immutable für alles andere. Ein Bundler kümmert sich darum, dass nach dem Bundling die Namen der erzeugten Dateien den richtigen Fingerprint enthalten.
Dass bei manchen Asset-Klassen wie Bildern und Schriften mehrere Source-Dateien nur sehr schwierig kombiniert werden können, ist für dieses Fingerprinting eine wichtige Performance-Technik. Gepaart mit Code Splitting handelt es sich aber um ein mächtiges Werkzeug.
Übrigens ist es nicht notwendig, das Skript als app.48de1f9.js im Webserver-Verzeichnis abzulegen. Man kann auch den Webserver entsprechend konfigurieren, dass die URL umgeschrieben wird. Aber Vorsicht: Ressourcen mit veraltetem Fingerprint sollten auch mit 404 quittiert werden. Komplette Frameworks wie Rails bieten dies aus einer Hand an, ohne dass viel Konfigurationsaufwand anfällt.
Die Referenzen
Unglücklicherweise hat Fingerprinting auch Einfluss auf den eigentlichen Inhalt der Ressourcen. Im vorherigen Abschnitt kann man erkennen, dass zumindest die <script>-Tags angepasst werden müssen. Doch auch andere Assets sind betroffen. Nutzt man etwa SVG-Dateien für Icons, sind deren Pfade in CSS referenziert:
.icon {
background-image: url("/cool-icon.svg");
}
Aber dank Fingerprinting lautet die URL nun plötzlich anders. Die CSS-Deklaration muss also vom Bundler entsprechend angepasst werden:
.icon {
background-image: url("/cool-icon.2008714.svg");
}
Es ist daher wichtig, dass der Bundler so konfiguriert ist, dass alle Klassen von Assets durch diesen verarbeitet werden. Intern nutzen sie einen Abhängigkeitsbaum, der nicht nur JavaScript-Dateien, sondern auch Styles usw. umfasst. Bei React-Anwendungen geht man sogar oft noch einen Schritt weiter und "importiert" Styles direkt im JavaScript-Code:
import styles from "./styles.css";
Dabei handelt es sich aber um ein Feature, welches in JavaScript eigentlich nicht existiert. Gewissermaßen erweitert der Bundler damit den Sprachumfang, was zwar einerseits praktisch, andererseits aber nicht portabel ist. Da sich die verschiedenen Bundler teilweise erheblich darin unterscheiden, wie ein solcher Import zu handhaben ist, gehen wir hier nicht weiter darauf ein.
Die Übersetzung
Ganz ohne Spracherweiterungen kommt man aber – zumindest bei React – nicht aus. Denn React nutzt den JavaScript-Dialekt "JSX", der es erlaubt, HTML-Tags in JavaScript zu verwenden.
Beispiel:
const tree = (
<ul>
{ items.map(item => <li>{item}</li>) }
</ul>
)
Dieser Code erzeugt zur Laufzeit eine HTML-Liste (<ul>) mit mehreren Einträgen (<li>), die sich aus dem Array items ergeben. Browser verstehen diese Syntax jedoch nicht. Über Sinn und Unsinn von JSX möchte ich hier nicht philosophieren, sondern erklären, wie daraus tatsächlicher JavaScript-Code entsteht. Auftritt der "Transpiler"...
Ein Transpiler, oder auch "Trans-Compiler", ist ein Werkzeug, mit dem ein JavaScript-Dialekt in einen anderen übersetzt wird. Der populärste dieser Art ist "Babel", dessen Name bereits mit einem Augenzwinkern auf die biblische Sprachverwirrung hindeutet. Babel kann nun den obigen JSX-Code zerkauen und folgenden JS-Code ausspucken:
const tree = React.createElement(
"ul",
null,
{
children: items.map(item =>
React.createElement("li", null, item)
)
}
)
Nun ist der Code von idiosynkratischen Features befreit und alle Browser können – vorausgesetzt, React ist als Abhängigkeit geladen – damit umgehen. Oder?
Leider muss Babel oft noch mehr tun, denn moderner JavaScript-Code nutzt oft weitere Features, die zwar standardisiert, aber noch längst nicht in allen Browsern verfügbar sind. Oft steht hier Internet Explorer 11 auf der Bremse, von dem manche Anwendungen (und Anwender:innen) noch abhängig sind. Dieser hängt ungefähr ein Jahrzehnt zurück, unterstützt also nicht einmal das class-Schlüsselwort. Babel kann solche Features ebenfalls entfernen und erzeugt auf Wunsch auch solcherlei prähistorischen JS-Code.
Die technologie-agnostische Datenbank "Browserslist" führt Buch über Browser-Versionen und deren Unterstützung von Sprach-Versionen. Möchte man derart transpilieren, legt man eine Konfigurationsdatei an, die z. B. so aussieht:
Last 2 versions
IE 11
> 5% in DE
Babel wertet diese Datei automatisch aus und konfiguriert sich selbst so, dass von jedem Browser die zwei jüngsten Versionen unterstützt werden, sowie zusätzlich IE 11 und die mit mehr als 5 Prozent deutschlandweitem Marktanteil. Alle Features, die nicht von den angegebenen Browsern unterstützt werden, werden von Babel transpiliert. Obacht: Nicht immer ähnelt das Kompilat dem Original; stellenweise muss Babel allerlei Helferlein injizieren, um neue Features zu emulieren.
Eine gängige Fehlerquelle bei der Benutzung von Babel ist dessen oftmals schlecht verstandene Konfiguration. In vielen Projekten werden frei heraus bereits experimentelle, noch nicht standardisierte JS-Features eingesetzt (sogenannte "Proposals" in Stage 1, 2, oder 3). Deren Spezifikation und Implementation kann sich noch ändern, weshalb ein Update der Babel-Version dazu führen kann, dass der Code nicht mehr funktioniert.
Nebenbei bemerkt können Transpiler auch mit alternativen Programmiersprachen, wie z. B. TypeScript, umgehen. Aus ihrer Sicht handelt es sich dabei lediglich um "yet another" JavaScript-Dialekt. Auf die dadurch entstehenden weiteren Herausforderungen soll aber hier nicht weiter eingegangen werden.
Der Kitt
Wer denkt, dass damit die Unterstützung von alten Browsern abgedeckt ist, hat leider die Rechnung ohne die Web-APIs gemacht. Denn nicht nur entwickelt sich JavaScript als Sprache weiter, sondern auch die Browser stellen mit atemberaubender Geschwindigkeit neue APIs bereit. Beispielsweise kann man mit den "Custom Elements" – wie der Name schon sagt – eigene HTML-Tags definieren, die ihre Inhalte mit weiteren Funktionen anreichern können.
Für die armen 5 Prozent der User, deren Browser damit nichts anfangen kann (danke, IE!), kann Babel auch nichts tun, denn als reiner Transpiler weiß er nichts von Programmierschnittstellen. Ähnlich verhält es sich z. B. mit fetch, dem wesentlich einfacher zu bedienenden Nachfolger von XMLHttpRequest. Was also tun?
Die Lösung lautet "Polyfills", ein Bündel an sehr verschiedenen Technologien, die nach einer Spachtelmasse-Marke benannt sind. Für viele moderne APIs gibt es ein Polyfill, welches normalerweise ein Schnipsel JavaScript-Code ist, der so weit wie möglich die API mit Basismitteln des Browsers nachbildet. Das funktioniert nicht immer vollständig, insbesondere wenn das neue Feature tief im Browser verankert ist. Im Falle von fetch implementiert der passende Polyfill die Funktionalität eben auf Basis von XMLHttpRequest und verpasst diesem damit einen neuen Anstrich.
Zu beachten ist jedoch, dass jeder Polyfill die Größe der Webseite aufbläht. Bei der Architektur muss man also entscheiden, ob nicht doch lieber den "Progressive Enhancement"-Ansatz fährt: Man gestaltet die Webseite so, dass sie auch ganz ohne JavaScript auskommt. Moderne Browser bekommen dann noch weitere Annehmlichkeiten als Dreingabe. Custom Elements eignen sich dafür ganz vorzüglich. Ganz im Vorbeigehen baut man auch gleich noch Barrieren ab, sodass die Webanwendung für eingeschränkte User (z. B. alte Smartphones oder Nutzer:innen mit Screenreader) bedienbar bleibt.
Allerdings verträgt sich Progressive Enhancement nur bedingt mit Single Page Applications. Bei diesen ist man oftmals gezwungen, großflächig Spachtelmasse auszubringen.
Beiden Ansätzen ist aber gemeinsam, dass sie – um sie korrekt anwenden zu können – einiges an Vorab-Arbeit erfordern, um die konkreten Anforderungen im Projekt auszuloten und die Weichen für die Entwicklung entsprechend zu stellen. Entscheidet man sich für Polyfills, können Bundler diese automatisch in die erzeugten Bundles injizieren. Stellenweise können sie sogar die benötigten Polyfills anhand der Quelltexte und der Browserslist-Konfigurationen inferieren.
Zusätzlich sollte man beachten, dass das Transpilieren von Sprachfeatures, wie es Babel praktiziert, üblicherweise die Semantik erhält, aber Polyfills ihrer Natur nach Kompromisse eingehen müssen. Sorgfältiges Lesen der Dokumentation ist also Pflicht.
Die Bilder
Während Babel vortrefflich mit JavaScript umgehen kann, betrifft kränkelnder Browser-Support oder Preprocessing auch noch andere Arten von Assets. Beispielsweise können moderne Browser mit den Bildformaten WEBP und AVIF umgehen, die deutlich kleinere Dateien als JPG erzeugen. HTML 5 bietet die Möglichkeit an, mehrere verschiedene Bildformate zu deklarieren und die Wahl dem Browser zu überlassen:
<picture>
<source type="image/avif" srcset="pic.avif">
<source type="image/webp" srcset="pic.webp">
<img alt="A dog with a hat" src="pic.jpg">
</picture>
Ein Browser, der damit nichts anfangen kann, lädt wie gehabt das JPG-Format. Kann er allerdings AVIF rendern, so lädt er kurzerhand diese Version. Stand Oktober 2021 können weltweit nur ca. zwei Drittel der Web-User AVIF-Dateien anzeigen. Diese profitieren dann aber von deutlich schnellen Ladezeiten.
Dieses Problem lässt sich noch beliebig verkomplizieren, denn um der Webseite auf Touren zu bringen, kann man auch für verschiedene Displaygrößen verschieden große Bilder ausliefern. Da spätestens hier viele Bundler aussteigen, gibt es einige spezialisierte Drittanbieter, die je nach Request Bilder "on the fly" skalieren, konvertieren und optimieren können – selbstredend mit Caching-Support. Die eingesetzte Technologie wird als "Content Negotiation" bezeichnet, wobei der Anbieter die Request-Header des Browsers auswertet, um festzustellen, womit dieser umgehen kann.
Technisch gesehen hat das Transcoding und Skalieren von Bildern so gar nichts mit der Verarbeitung von JavaScript zu tun, gehört also eigentlich nicht zu der Kernaufgabe von Bundlern. Dennoch können viele Asset-Pipelines auch hiermit umgehen, denn sie unterstützen es beispielsweise, besonders kleine Ressourcen zu inlinen. So kann z. B. ein Bild wie folgt optimiert werden: aus
<img alt="Loading" src="spinner.gif">
mach
<img alt="Loading" src="data:image/gif;base64,
iVBORw0KGgoAAAANSUhEUgAAA...">
Diese Ersetzung kann sowohl in HTML, CSS als auch JavaScript-Ressourcen stattfinden. Ähnliche Überlegungen zur Abwägung zwischen Cachebarkeit und kaskadierenden Requests wie beim Code Splitting sind auch dafür anzustellen.
Das Komprimieren
Bleibt noch eine letzte wichtige Aufgabe von Bundlern: Das sogenannte "Tree Shaking"; anderswo auch bekannt als "Dead Code Elimination".
Auf Server-Seite ist es meist egal, wie groß das lauffähige Artefakt ist. Sämtliche transitiven Abhängigkeiten – auch wenn sie am Ende gar nicht gebraucht werden – sind im Image inkludiert. Wie hier bereits lang und breit erläutert, ist clientseitig die Bandbreite kostbar.
Tree Shaking beschreibt den Prozess, mit dem Bundler ungenutzte Source-Dateien aus dem erzeugten Bundle "herausschütteln". Dazu müssen sie sich eigentlich nicht besonders anstrengen, denn einen Abhängigkeitsbaum benötigen sie zum Fingerprinting ohnehin. Eigentlich....
Denn viele Bundler unterstützen das Tree Shaking unterhalb der Dateiebene, namentlich auf Ebene individueller Definitionen. Dazu müssen sie z. B. bei Konstanten eruieren, ob diese "rein" sind. Beispielsweise darf folgende Definition nicht gelöscht werden, selbst wenn nicht auf x zugegriffen wird:
function f() {
console.log("hello world!");
return 3;
}
const x = f();
Eine Definition const x = 3 hat hingegen eine weiße Weste und darf nach Belieben entsorgt werden.
Zur Kompression gibt es noch weitere Techniken, z. B. das Umbenennen von langen Klassen-, Funktions- und Variablennamen. Natürlich bekommt man damit in einer derart dynamischen Sprache wie JavaScript, wo Feldzugriffe auch per String möglich sind, noch einmal eine Extraportion Fallstricke. Entsprechend sollten Tests auch, wenn möglich, die fertig verarbeiteten Artefakte prüfen.
Die Entwicklung
Hat man die Webanwendung mithilfe eines Bundlers gründlich performancemäßig tiefergelegt, hat das Resultat, welches vom Browser ausgeführt und angezeigt wird, oftmals nur noch eine begrenzte Ähnlichkeit mit dem Original. Insbesondere der oben beschriebene letzte Schritt macht der Lesbarkeit den Garaus.
Doch auch dagegen gibt es ein Rezept. Im Debug-Modus kann man Bundler derart konfigurieren, dass sie eine "Sourcemap" ausgeben. Dabei wird der – mehr oder weniger unveränderte – Quelltext als Kommentar im Header des Kompilats abgelegt. Natürlich führt das sämtliche Größenersparnisse ad absurdum, deswegen sollte man dies nicht in Produktion anschalten. Aber während der Entwicklungsphase können Browser anhand der Sourcemap z. B. akkurate Stacktraces rekonstruieren, was die Fehlersuche deutlich vereinfacht.
Um konkrete Benchmarks kommt man aber nicht umhin. Zum einen kann man dafür den bei allen modernen Browsern mitgelieferten Web Inspector benutzen, der die Netzwerkrequests mit und ohne Cache anzeigen kann. Damit lassen sich schnell Flaschenhälse identifizieren. Ein weiterer Baustein sind automatische Tools, die auch noch andere Probleme – wie beispielsweise fehlerhafte Darstellung in mobilen Endgeräten – aufdecken können.
Schließlich rate ich immer gerne dazu, die neu entwickelte Webseite per mobilem Internet im Zug auf dem Smartphone aufzurufen. Denn: Wenn es dann glattläuft, läuft es überall.
Fazit
Ein moderner Bundler muss ganz schön viel leisten. Wir haben in diesem Artikel die wichtigsten Operationen kennengelernt:
- Verkleinern und Optimieren von Assets,
- Fingerprinting von Assets für besseres Caching,
- Erzeugen client-spezifischer Assets,
- Unterstützen neuer JS-Features,
- Einbinden von Polyfills
... und das möglichst alles effizient, sodass man als Entwickler:in bloß Ctrl+S zu drücken braucht und dann alles im Hintergrund passiert. Weitere Aspekte, auf die wir hier nicht mehr eingehen konnten, sind z. B. die verschiedenen Modulformate in JavaScript, Integration verschiedener Toolchains, universelles JavaScript in Node und im Browser, Testing, und so weiter. Hierfür sollte man dann die Dokumentation des Bundlers, welcher im Projekt eingesetzt wird, konsultieren.