JavaScript-Nutzlasten mit Tree Shaking reduzieren

Heutige Webanwendungen können ziemlich groß werden, insbesondere der JavaScript-Teil. Seit Mitte 2018 lag die durchschnittliche Übertragungsgröße von JavaScript auf Mobilgeräten laut HTTP-Archiv bei etwa 350 KB. Und das ist nur die Übertragungsgröße! JavaScript wird häufig komprimiert, wenn es über das Netzwerk gesendet wird. Das bedeutet, dass die tatsächliche Menge von JavaScript nach der Dekomprimierung durch den Browser ziemlich viel größer ist. Das ist wichtig, da die Komprimierung für die Ressourcenverarbeitung irrelevant ist. 900 KB dekomprimierten JavaScript sind für den Parser und Compiler immer noch 900 KB, auch wenn es bei der Komprimierung ungefähr 300 KB groß sein kann.

Ein Diagramm, das den Prozess des Herunterladens, Dekomprimierens, Parsen, Kompilierens und Ausführens von JavaScript veranschaulicht.
Das Herunterladen und Ausführen von JavaScript. Hinweis: Obwohl die Übertragungsgröße des komprimierten Skripts 300 KB beträgt, umfasst JavaScript immer noch 900 KB an JavaScript, das geparst, kompiliert und ausgeführt werden muss.

Die Verarbeitung von JavaScript ist eine teure Ressource. Im Gegensatz zu Bildern, bei denen nach dem Download nur eine relativ einfache Decodierungszeit erforderlich ist, muss JavaScript geparst, kompiliert und schließlich ausgeführt werden. Byte für Byte, wodurch JavaScript teurer als andere Ressourcentypen wird.

Diagramm, in dem die Verarbeitungszeit von 170 KB JavaScript mit der Verarbeitungszeit eines JPEG-Bildes der gleichen Größe verglichen wird Die JavaScript-Ressource ist weitaus ressourcenintensiver Byte für Byte als die JPEG-Datei.
Die Verarbeitungskosten für das Parsen/Kompilieren von 170 KB JavaScript im Vergleich zur Decodierungszeit einer JPEG-Datei der gleichen Größe. (Quelle)

Während ständig Verbesserungen vorgenommen werden, um die Effizienz von JavaScript-Engines zu verbessern, ist die Verbesserung der JavaScript-Leistung wie immer eine Aufgabe für Entwickler.

Zu diesem Zweck gibt es Techniken zur Verbesserung der JavaScript-Leistung. Die Codeaufteilung ist eine solche Technik, mit der die Leistung verbessert wird, indem Anwendungs-JavaScript in Blöcke partitioniert wird und diese Blöcke nur an die Routen einer Anwendung bereitgestellt werden, die sie benötigen.

Diese Technik funktioniert zwar, lässt sich jedoch nicht an ein häufiges Problem bei Anwendungen mit hoher JavaScript-Nutzung, nämlich der Einbeziehung von Code, der nie verwendet wird, angehen. Das Baumschütteln versucht, dieses Problem zu lösen.

Was versteht man unter Baumschütteln?

Baumschütteln ist eine Form der Beseitigung von totem Code. Der Begriff wurde durch Rollup bekannt gemacht, aber das Konzept der Eliminierung von totem Code existiert schon seit einiger Zeit. Für das Konzept wurden auch Käufe in Webpack gefunden, was in diesem Artikel anhand einer Beispiel-App demonstriert wird.

Der Begriff „Baumwackeln“ stammt vom mentalen Modell Ihrer Anwendung und ihrer Abhängigkeiten in Form einer baumähnlichen Struktur. Jeder Knoten in der Baumstruktur stellt eine Abhängigkeit dar, die unterschiedliche Funktionen für Ihre Anwendung bietet. In modernen Anwendungen werden diese Abhängigkeiten über statische import-Anweisungen wie folgt eingebunden:

// Import all the array utilities!
import arrayUtils from "array-utils";

Wenn eine App jung ist – wenn es sich um Junge handelt – kann es sein, dass sie nur wenige Abhängigkeiten hat. Dabei werden auch die meisten – wenn nicht alle – die Abhängigkeiten verwendet, die Sie hinzufügen. Mit zunehmender Reife Ihrer App können jedoch weitere Abhängigkeiten hinzugefügt werden. Außerdem werden ältere Abhängigkeiten nicht mehr verwendet, aber möglicherweise nicht aus Ihrer Codebasis entfernt. Das Endergebnis ist, dass eine App mit viel nicht verwendetem JavaScript ausgeliefert wird. Das Baumschütteln bekämpft dieses Problem, indem es nutzt, wie statische import-Anweisungen bestimmte Teile von ES6-Modulen abrufen:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

Der Unterschied zwischen diesem import- und dem vorherigen Beispiel besteht darin, dass in diesem Beispiel nicht alles aus dem "array-utils"-Modul importiert wird – was sehr viel Code sein könnte –, sondern nur bestimmte Teile davon importiert werden. Bei Entwicklungs-Builds ändert sich nichts, da das gesamte Modul trotzdem importiert wird. In Produktions-Builds kann das Webpack so konfiguriert werden, dass Exporte aus ES6-Modulen, die nicht explizit importiert wurden, „abgeschüttelt“ werden. Dadurch werden diese Produktions-Builds kleiner. In diesem Leitfaden erfahren Sie, wie das geht.

Möglichkeiten finden, einen Baum zu schütteln

Zur Veranschaulichung ist eine einseitige Beispielanwendung verfügbar, die die Funktionsweise des Tree Shakings demonstriert. Sie können ihn klonen und der Anleitung folgen, wenn Sie möchten. In diesem Leitfaden werden wir jeden Schritt des Weges gemeinsam behandeln. Klonen ist also nicht erforderlich, es sei denn, praxisorientiertes Lernen ist Ihre Aufgabe.

Die Beispiel-App ist eine durchsuchbare Datenbank mit Gitarreneffektpedalen. Wenn Sie eine Abfrage eingeben, wird eine Liste der Effektpedale angezeigt.

Screenshot einer einseitigen Beispielanwendung für die Suche in einer Datenbank mit Gitarreneffektpedalen
Screenshot der Beispiel-App.

Das Verhalten dieser Anwendung wird nach Anbieter getrennt (d.h. Preact und Emotion) sowie appspezifische Code-Bundles (oder „Chunks“, wie sie von Webpack bezeichnet werden):

Screenshot von zwei Anwendungscode-Bundles (oder -Chunks), die im Bereich „Netzwerk“ der Chrome-Entwicklertools angezeigt werden
Die beiden JavaScript-Bundles der App. Dies sind nicht komprimierte Größen.

Die in der Abbildung oben gezeigten JavaScript-Bundles sind Produktions-Builds, d. h. sie werden durch Uglification optimiert. 21,1 KB für ein app-spezifisches Bundle ist nicht schlecht, aber angemerkt, dass kein Tree Shaking auftritt. Sehen wir uns den App-Code an und überlegen Sie, wie Sie dieses Problem beheben können.

Zur Ermittlung von Möglichkeiten zur Baumschüttelung muss in jeder Anwendung nach statischen import-Anweisungen gesucht werden. Am oberen Rand der Hauptkomponentendatei sehen Sie eine Zeile, die wie folgt aussieht:

import * as utils from "../../utils/utils";

Sie können ES6-Module auf verschiedene Weise importieren, aber solche sollten Sie Aufmerksamkeit erregen. In dieser Zeile steht: „import alles aus dem utils-Modul in einem Namespace namens utils.“ Die wichtige Frage an dieser Stelle lautet: „Wie viel Inhalte sind in diesem Modul?“

Im Quellcode des Moduls utils sehen Sie etwa 1.300 Codezeilen.

Brauchst du all diese Dinge? Prüfen wir das noch einmal. Suchen Sie dazu in der Hauptkomponentendatei, die das utils-Modul importiert, um zu sehen, wie viele Instanzen dieses Namespace angezeigt werden.

Screenshot einer Suche nach „utils.“ in einem Texteditor, bei der nur drei Ergebnisse zurückgegeben werden.
Der Namespace utils, aus dem wir unzählige Module importiert haben, wird in der Hauptkomponentendatei nur dreimal aufgerufen.

Wie sich herausstellt, erscheint der utils-Namespace in der Anwendung nur an drei Stellen – aber für welche Funktionen? Wenn Sie sich die Hauptkomponentendatei noch einmal ansehen, scheint es nur eine Funktion zu sein, nämlich utils.simpleSort. Damit wird die Liste der Suchergebnisse nach einer Reihe von Kriterien sortiert, wenn die Drop-down-Menüs zum Sortieren geändert werden:

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

Aus einer Datei mit 1.300 Zeilen und einer Reihe von Exporten wird nur einer davon verwendet. Dies führt dazu, dass viel nicht verwendetes JavaScript versendet wird.

Diese Beispiel-App ist zwar zugegebenermaßen etwas durchdacht, ändert aber nichts an der Tatsache, dass dieses synthetische Szenario den tatsächlichen Optimierungsmöglichkeiten ähnelt, die Sie in einer Produktions-Webanwendung haben könnten. Sie haben nun eine Möglichkeit identifiziert, die Baumwolken nutzen kann. Wie funktioniert das eigentlich?

Es wird verhindert, dass Babel ES6-Module in CommonJS-Module transpiliert

Babel ist ein unverzichtbares Werkzeug, kann die Auswirkungen von Baumwackeln aber etwas erschweren. Wenn Sie @babel/preset-env verwenden, kann Babel ES6-Module in weiter kompatible CommonJS-Module umwandeln, d. h. Module, die require statt import.

Da Baumwackeln bei CommonJS-Modulen schwieriger auszuführen ist, weiß Webpack nicht, was aus Bundles entfernt werden soll, wenn Sie sie verwenden. Die Lösung besteht darin, @babel/preset-env so zu konfigurieren, dass ES6-Module explizit ignoriert werden. Unabhängig von der Konfiguration von Babel, sei es in babel.config.js oder package.json, müssen Sie etwas zusätzliches hinzufügen:

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

Wenn Sie modules: false in Ihrer @babel/preset-env-Konfiguration angeben, verhält sich Babel wie gewünscht, wodurch Webpack Ihren Abhängigkeitsbaum analysieren und nicht verwendete Abhängigkeiten abschütteln kann.

Nebenwirkungen berücksichtigen

Ein weiterer Aspekt, den Sie beim Schütteln von Abhängigkeiten von Ihrer Anwendung berücksichtigen sollten, ist, ob die Module Ihres Projekts Nebenwirkungen haben. Ein Beispiel für einen Nebeneffekt ist, wenn eine Funktion etwas ändert, das außerhalb ihres eigenen Geltungsbereichs liegt, was ein Nebeneffekt der Ausführung ist:

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

In diesem Beispiel führt addFruit zu einem Nebeneffekt, wenn das fruits-Array geändert wird, das außerhalb des Geltungsbereichs liegt.

Nebenwirkungen gelten auch für ES6-Module, und das ist im Zusammenhang mit Tree Shakings von Bedeutung. Module, die vorhersehbare Eingaben nutzen und gleichermaßen vorhersehbare Ausgaben erzeugen, ohne etwas außerhalb ihres eigenen Bereichs zu ändern, sind Abhängigkeiten, die sicher gelöscht werden können, wenn wir sie nicht verwenden. Es handelt sich um eigenständiges, modulares Code-Snippet. Daher „Module“.

Bei Webpack kann ein Hinweis verwendet werden, um anzugeben, dass ein Paket und seine Abhängigkeiten frei von Nebeneffekten sind. Dazu gibst du "sideEffects": false in der package.json-Datei eines Projekts an:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

Alternativ kannst du Webpack mitteilen, welche Dateien keine Nebeneffekte haben:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

Im letzteren Beispiel wird angenommen, dass jede nicht angegebene Datei frei von Nebeneffekten ist. Wenn du dies nicht deiner package.json-Datei hinzufügen möchtest, kannst du es auch über module.rules in der Webpack-Konfiguration angeben.

Nur benötigte Elemente importieren

Nachdem Sie Babel angewiesen haben, die ES6-Module außer Acht zu lassen, ist eine leichte Anpassung der import-Syntax erforderlich, um nur die Funktionen einzufügen, die aus dem utils-Modul notwendig sind. In diesem Beispiel wird lediglich die Funktion simpleSort benötigt:

import { simpleSort } from "../../utils/utils";

Da nur simpleSort und nicht das gesamte utils-Modul importiert wird, muss jede Instanz von utils.simpleSort zu simpleSort geändert werden:

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

Das sollte alles sein, was erforderlich ist, damit die Baumwacklerfunktion in diesem Beispiel funktioniert. So sieht die Webpack-Ausgabe aus, bevor der Abhängigkeitsbaum geschüttelt wird:

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

So sieht die Ausgabe nach erfolgreichem Baumschütteln aus:

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

Beide Sets haben zwar schrumpfend, aber es ist wirklich das main-Bundle, das am meisten davon profitiert. Durch das Abschütteln der nicht verwendeten Teile des utils-Moduls schrumpft das main-Bundle um etwa 60%. Dadurch verkürzt sich nicht nur die Zeit bis zum Download, sondern auch die Verarbeitungszeit des Skripts.

Schüttle ein paar Bäume!

Was auch immer Sie beim Baumschütteln bewältigt, hängt von Ihrer App und ihren Abhängigkeiten und ihrer Architektur ab. Jetzt testen Wenn Sie Ihren Modul-Bundler nicht für diese Optimierung eingerichtet haben, schadet der Versuch nicht, die Vorteile für Ihre Anwendung zu erkennen.

Das Baumschütteln kann einen deutlichen Leistungszuwachs bedeuten oder Sie können gar nicht so viel erreichen. Wenn Sie Ihr Build-System jedoch so konfigurieren, dass diese Optimierung in Produktions-Builds genutzt wird, und nur die Daten importieren, die Ihre Anwendung benötigt, halten Sie Ihre App Bundles proaktiv so klein wie möglich.

Vielen Dank an Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone und Philip Walton für ihr wertvolles Feedback, durch das die Qualität dieses Artikels deutlich verbessert wurde.