JavaScript-Nutzlasten mit Tree Shaking reduzieren

Heutige Webanwendungen können ziemlich groß werden, insbesondere der JavaScript-Teil. Mitte 2018 lag die mittlere Übertragungsgröße von JavaScript auf Mobilgeräten laut HTTP Archive 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 an JavaScript nach der Dekomprimierung durch den Browser deutlich höher ist. Das ist wichtig, weil die Komprimierung für die Verarbeitung von Ressourcen irrelevant ist. 900 KB dekomprimiertes JavaScript sind für den Parser und Compiler immer noch 900 KB, auch wenn die komprimierte Datei nur etwa 300 KB groß ist.

Ein Diagramm, das den Prozess des Herunterladens, Dekomprimierens, Parsens, Kompilierens und Ausführens von JavaScript veranschaulicht.
Der Prozess des Herunterladens und Ausführens von JavaScript. Auch wenn die Übertragungsgröße des Skripts 300 KB (komprimiert) beträgt, müssen 900 KB JavaScript geparst, kompiliert und ausgeführt werden.

Die Verarbeitung von JavaScript ist ressourcenintensiv. Im Gegensatz zu Bildern, die nach dem Herunterladen nur eine relativ geringe Decodierungszeit erfordern, muss JavaScript geparst, kompiliert und dann ausgeführt werden. Byte für Byte ist JavaScript damit teurer als andere Ressourcentypen.

Ein Diagramm, in dem die Verarbeitungszeit von 170 KB JavaScript mit der eines gleich großen JPEG-Bilds verglichen wird. Die JavaScript-Ressource ist Byte für Byte viel ressourcenintensiver als das JPEG.
Die Verarbeitungskosten für das Parsen/Kompilieren von 170 KB JavaScript im Vergleich zur Decodierungszeit eines JPEG mit derselben Größe. (Quelle)

Es werden zwar fortlaufend Verbesserungen vorgenommen, um die Effizienz von JavaScript-Engines zu steigern, aber die Verbesserung der JavaScript-Leistung ist wie immer Aufgabe der Entwickler.

Dazu gibt es Techniken, mit denen sich die JavaScript-Leistung verbessern lässt. Code-Splitting ist eine solche Technik, die die Leistung verbessert, indem Anwendungs-JavaScript in Chunks aufgeteilt und diese Chunks nur an die Routen einer Anwendung gesendet werden, die sie benötigen.

Diese Technik funktioniert zwar, löst aber nicht das häufige Problem von JavaScript-lastigen Anwendungen, nämlich die Einbeziehung von Code, der nie verwendet wird. Das Problem soll durch „Tree Shaking“ gelöst werden.

Was ist „Tree Shaking“?

Tree Shaking ist eine Form der Entfernung von nicht mehr benötigtem Code. Der Begriff wurde durch Rollup bekannt, aber das Konzept der Entfernung von nicht verwendetem Code gibt es schon seit einiger Zeit. Das Konzept hat auch in webpack Einzug gehalten, was in diesem Artikel anhand einer Beispiel-App veranschaulicht wird.

Der Begriff „Tree Shaking“ stammt aus dem mentalen Modell Ihrer Anwendung und ihrer Abhängigkeiten als baumartige Struktur. Jeder Knoten im Baum stellt eine Abhängigkeit dar, die Ihrer App bestimmte Funktionen zur Verfügung stellt. In modernen Apps werden diese Abhängigkeiten über statische import-Anweisungen wie folgt eingebunden:

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

Wenn eine App noch jung ist, hat sie möglicherweise nur wenige Abhängigkeiten. Außerdem werden die meisten, wenn nicht alle Abhängigkeiten verwendet, die Sie hinzufügen. Im Laufe der Zeit können jedoch weitere Abhängigkeiten hinzukommen. Hinzu kommt, dass ältere Abhängigkeiten nicht mehr verwendet werden, aber möglicherweise nicht aus Ihrer Codebasis entfernt werden. Das Endergebnis ist, dass eine App mit viel nicht verwendetem JavaScript ausgeliefert wird. Tree Shaking behebt dieses Problem, indem es die Art und Weise 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-Beispiel und dem vorherigen besteht darin, dass in diesem Beispiel nicht alles aus dem "array-utils"-Modul importiert wird, was viel Code bedeuten könnte, sondern nur bestimmte Teile. In Entwickler-Builds ändert sich dadurch nichts, da das gesamte Modul ohnehin importiert wird. In Produktions-Builds kann webpack so konfiguriert werden, dass Exporte aus ES6-Modulen entfernt werden, die nicht explizit importiert wurden. Dadurch werden die Produktions-Builds kleiner. In diesem Leitfaden erfahren Sie, wie das geht.

Möglichkeiten zum Schütteln eines Baums finden

Zur Veranschaulichung ist eine Beispiel-App mit einer Seite verfügbar, die zeigt, wie Tree Shaking funktioniert. Sie können das Repository klonen und die Schritte nachvollziehen. Wir werden in diesem Leitfaden jedoch jeden Schritt gemeinsam durchgehen, sodass das Klonen nicht erforderlich ist (es sei denn, Sie möchten die Schritte selbst ausführen).

Die Beispiel-App ist eine durchsuchbare Datenbank mit Gitarreneffektpedalen. Sie geben eine Anfrage ein und eine Liste mit Effektpedalen wird angezeigt.

Screenshot einer Beispielanwendung mit einer Seite zum Durchsuchen einer Datenbank mit Gitarreneffektpedalen
Ein Screenshot der Beispiel-App.

Das Verhalten, das diese App steuert, ist in Anbieter (d.h. Preact und Emotion) und app-spezifische Code-Bundles (oder „Chunks“, wie sie von webpack genannt werden):

Ein Screenshot von zwei Anwendungscode-Bundles (oder Chunks), die im Netzwerkbereich der Entwicklertools von Chrome angezeigt werden.
Die beiden JavaScript-Bundles der App. Das sind unkomprimierte Größen.

Die oben gezeigten JavaScript-Bundles sind Produktions-Builds, d. h., sie wurden durch Uglifizierung optimiert. 21,1 KB für ein app-spezifisches Bundle sind nicht schlecht, aber es sollte beachtet werden, dass kein Tree Shaking stattfindet. Sehen wir uns den App-Code an, um zu sehen, was wir tun können, um das Problem zu beheben.

In jeder Anwendung müssen Sie nach statischen import-Anweisungen suchen, um Möglichkeiten für Tree Shaking zu finden. Oben in der Hauptkomponentendatei sehen Sie eine Zeile wie diese:

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

Sie können ES6-Module auf verschiedene Arten importieren, aber solche wie diese sollten Ihre Aufmerksamkeit erregen. In dieser Zeile steht: „import alles aus dem Modul utils und füge es in einen Namespace namens utils ein.“ Die große Frage ist hier: „Wie viel Zeug ist in diesem Modul?“

Wenn Sie sich den Quellcode des utils-Moduls ansehen, werden Sie feststellen,dass er etwa 1.300 Zeilen umfasst.

Brauchst du das alles? Wir können das noch einmal überprüfen, indem wir in der Hauptkomponentendatei, in der das Modul utils importiert wird, nachsehen, wie viele Instanzen dieses Namespace vorhanden sind.

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

Der Namespace utils wird nur an drei Stellen in unserer Anwendung verwendet. Aber für welche Funktionen? Wenn Sie sich die Hauptkomponentendatei noch einmal ansehen, sehen Sie, dass sie nur eine Funktion enthält, nämlich utils.simpleSort. Diese Funktion wird verwendet, um die Liste der Suchergebnisse nach einer Reihe von Kriterien zu sortieren, wenn die Drop-down-Menüs für die Sortierung 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);
}

Von einer Datei mit 1.300 Zeilen mit vielen Exporten wird nur einer verwendet. Das führt dazu, dass viel ungenutztes JavaScript ausgeliefert wird.

Dieses Beispiel ist zwar etwas konstruiert, aber es ändert nichts daran, dass dieses synthetische Szenario tatsächlichen Optimierungsmöglichkeiten ähnelt, die in einer Produktions-Web-App auftreten können. Nachdem Sie nun eine Möglichkeit für Tree Shaking identifiziert haben, stellt sich die Frage, wie das eigentlich funktioniert.

Verhindern, dass Babel ES6-Module in CommonJS-Module transpilieren

Babel ist ein unverzichtbares Tool, kann aber die Auswirkungen von Tree Shaking etwas schwieriger nachvollziehbar machen. Wenn Sie @babel/preset-env verwenden, kann Babel ES6-Module in CommonJS-Module umwandeln, die weiter verbreitet sind. Das sind Module, die Sie require statt import.

Da Tree Shaking für CommonJS-Module schwieriger ist, weiß webpack nicht, was aus den Bundles entfernt werden soll, wenn Sie sich für die Verwendung von CommonJS-Modulen entscheiden. Die Lösung besteht darin, @babel/preset-env so zu konfigurieren, dass ES6-Module explizit nicht geändert werden. Wo auch immer Sie Babel konfigurieren – ob in babel.config.js oder package.json –, müssen Sie etwas 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. Dadurch kann webpack Ihren Abhängigkeitsbaum analysieren und nicht verwendete Abhängigkeiten entfernen.

Nebenwirkungen berücksichtigen

Ein weiterer Aspekt, der beim Entfernen von Abhängigkeiten aus Ihrer App berücksichtigt werden muss, ist, ob die Module Ihres Projekts Nebeneffekte haben. Ein Beispiel für eine Nebenwirkung ist, wenn eine Funktion etwas außerhalb ihres eigenen Bereichs ändert. Dies ist eine Nebenwirkung ihrer Ausführung:

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 hat addFruit eine Nebenwirkung, da das Array fruits geändert wird, das außerhalb des Bereichs liegt.

Nebenwirkungen gelten auch für ES6-Module, was im Zusammenhang mit Tree Shaking wichtig ist. Module, die vorhersehbare Eingaben entgegennehmen und ebenso vorhersehbare Ausgaben erzeugen, ohne etwas außerhalb ihres eigenen Bereichs zu ändern, sind Abhängigkeiten, die gefahrlos entfernt werden können, wenn wir sie nicht verwenden. Sie sind in sich geschlossen und modular. Daher der Name „Module“.

Bei Webpack kann ein Hinweis verwendet werden, um anzugeben, dass ein Paket und seine Abhängigkeiten keine Nebeneffekte haben. Dazu wird "sideEffects": false in der Datei package.json eines Projekts angegeben:

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

Alternativ können Sie webpack mitteilen, welche Dateien keine Nebenwirkungen haben:

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

Im letzteren Beispiel wird davon ausgegangen, dass alle nicht angegebenen Dateien keine Nebenwirkungen haben. Wenn Sie dies nicht in Ihre package.json-Datei aufnehmen möchten, können Sie dieses Flag auch in Ihrer Webpack-Konfiguration über module.rules angeben.

Nur das importieren, was benötigt wird

Nachdem wir Babel angewiesen haben, ES6-Module nicht zu ändern, ist eine kleine Anpassung unserer import-Syntax erforderlich, um nur die benötigten Funktionen aus dem utils-Modul zu importieren. Im Beispiel in diesem Leitfaden ist nur die Funktion simpleSort erforderlich:

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

Da nur simpleSort anstelle des gesamten Moduls utils importiert wird, muss jede Instanz von utils.simpleSort in 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 für Tree Shaking in diesem Beispiel erforderlich ist. So sieht die webpack-Ausgabe vor dem Entfernen nicht benötigter Abhängigkeiten aus:

                 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

Das ist die Ausgabe nach erfolgreichem Tree Shaking:

                 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 Bündel sind kleiner geworden, aber das main-Bündel profitiert am meisten. Durch das Entfernen der nicht verwendeten Teile des utils-Moduls wird das main-Bundle um etwa 60 % verkleinert. Dadurch wird nicht nur die Zeit für den Download, sondern auch die Verarbeitungszeit des Skripts verkürzt.

Schüttel mal ein paar Bäume!

Wie viel Sie durch Tree Shaking erreichen, hängt von Ihrer App, ihren Abhängigkeiten und ihrer Architektur ab. Testen! Wenn Sie sicher sind, dass Sie Ihren Modul-Bundler nicht für diese Optimierung eingerichtet haben, können Sie es einfach ausprobieren und sehen, wie es sich auf Ihre Anwendung auswirkt.

Durch Tree Shaking kann die Leistung erheblich gesteigert werden, aber das ist nicht immer der Fall. Wenn Sie Ihr Build-System jedoch so konfigurieren, dass diese Optimierung in Produktions-Builds genutzt wird, und nur das importieren, was Ihre Anwendung benötigt, halten Sie Ihre Anwendungs-Bundles proaktiv so klein wie möglich.

Besonderer Dank gilt Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone und Philip Walton für ihr wertvolles Feedback, das die Qualität dieses Artikels erheblich verbessert hat.