JavaScript-Nutzlasten mit Tree Shaking reduzieren

Heutzutage können Webanwendungen ziemlich groß werden, insbesondere der JavaScript-Teil. Laut HTTP Archive betrug die mediane Übertragungsgröße von JavaScript auf Mobilgeräten Mitte 2018 etwa 350 KB. Und das ist nur die Übertragungsgröße. JavaScript wird beim Senden über das Netzwerk häufig komprimiert. 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 Ressourcenverarbeitung irrelevant ist. 900 KB dekomprimiertes JavaScript sind für den Parser und Compiler immer noch 900 KB, auch wenn es komprimiert etwa 300 KB groß ist.

Ein Diagramm, das den Ablauf beim Herunterladen, Entpacken, Parsen, Kompilieren und Ausführen von JavaScript veranschaulicht.
Das Herunterladen und Ausführen von JavaScript. Auch wenn die Übertragungsgröße des Scripts komprimiert 300 KB beträgt, entspricht es immer noch 900 KB JavaScript, das geparst, kompiliert und ausgeführt werden muss.

JavaScript ist eine ressourcenintensive Sprache. Im Gegensatz zu Bildern, die nach dem Herunterladen nur eine relativ kurze Dekodierungszeit erfordern, muss JavaScript geparst, kompiliert und schließlich ausgeführt werden. Byte für Byte ist JavaScript daher teurer als andere Arten von Ressourcen.

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

Es werden zwar kontinuierlich Verbesserungen vorgenommen, um die Effizienz von JavaScript-Engines zu verbessern, die Verbesserung der JavaScript-Leistung liegt aber wie immer in der Verantwortung der Entwickler.

Dazu gibt es verschiedene Methoden, die JavaScript-Leistung zu verbessern. Code-Splitting ist eine solche Methode, die die Leistung verbessert, indem der JavaScript-Code der Anwendung in Chunks partitioniert und diese Chunks nur den Routen einer Anwendung bereitgestellt werden, die sie benötigen.

Diese Methode funktioniert zwar, löst aber nicht das häufige Problem von JavaScript-lastigen Anwendungen, nämlich das Einfügen von Code, der nie verwendet wird. Mit dem Tree Shaking wird versucht, dieses Problem zu lösen.

Was ist Tree-Shaking?

Tree Shaking ist eine Form der Beseitigung von Totcode. Der Begriff wurde durch Rollup populär, aber das Konzept der Beseitigung von Dead Code gibt es schon seit einiger Zeit. Das Konzept wird auch in webpack verwendet, was in diesem Artikel anhand einer Beispiel-App veranschaulicht wird.

Der Begriff „Tree Shaking“ leitet sich aus dem mentalen Modell Ihrer Anwendung und ihrer Abhängigkeiten als baumartige Struktur ab. Jeder Knoten im Baum stellt eine Abhängigkeit dar, die Ihrer App bestimmte Funktionen bietet. In modernen Apps werden diese Abhängigkeiten über statische import-Anweisungen eingebunden, z. B. so:

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

Wenn eine App noch jung ist – ein Sprössling, wenn Sie so wollen –, hat sie möglicherweise nur wenige Abhängigkeiten. Außerdem werden die meisten, wenn nicht alle, von Ihnen hinzugefügten Abhängigkeiten verwendet. Im Laufe der Zeit können jedoch weitere Abhängigkeiten hinzukommen. Erschwerend kommt hinzu, 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 nutzt die Tatsache, dass bestimmte Teile von ES6-Modulen über statische import-Anweisungen eingefügt werden:

// 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. Bei Dev-Builds ändert sich dadurch nichts, da das gesamte Modul unabhängig davon importiert wird. Bei Produktionsbuilds kann webpack so konfiguriert werden, dass Exporte aus ES6-Modulen, die nicht explizit importiert wurden, entfernt werden, wodurch diese Produktionsbuilds kleiner werden. In diesem Leitfaden erfährst du, wie das geht.

Chancen finden, um einen Baum zu schütteln

Zur Veranschaulichung gibt es eine Beispiel-App mit einer Seite, die zeigt, wie Tree Shaking funktioniert. Sie können das Repository klonen und der Anleitung folgen, wenn Sie möchten. Wir gehen in diesem Leitfaden jedoch jeden Schritt gemeinsam durch, sodass das Klonen nicht erforderlich ist, es sei denn, Sie lernen lieber praktisch.

Die Beispielanwendung ist eine suchbare Datenbank mit Gitarreneffektpedalen. Sie geben eine Suchanfrage ein und es wird eine Liste mit Effektpedalen angezeigt.

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

Das Verhalten, das diese App auslöst, wird in Anbieter (d.h. Preact und Emotion) und appspezifische Code-Bundles (oder „Chunks“, wie sie in Webpack genannt werden):

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

Die in der Abbildung oben gezeigten JavaScript-Bundles sind Produktionsbuilds, d. h., sie wurden durch Uglify-Optimierung optimiert. 21,1 KB für ein app-spezifisches Bundle sind nicht schlecht, aber es sollte angemerkt werden, dass keine Tree Shaking-Vorgänge stattfinden. Sehen wir uns den App-Code an und überlegen, wie wir das Problem beheben können.

Bei jeder Anwendung müssen Sie nach statischen import-Anweisungen suchen, um Möglichkeiten für das Entfernen von Code 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 diese Methode sollte Ihre Aufmerksamkeit erregen. Diese Zeile besagt: „import alles aus dem utils-Modul in einen Namensbereich namens utils verschieben“. Die entscheidende Frage ist hier: „Wie viel Zeug ist in diesem Modul?“

Der Quellcode des utils-Moduls umfasst etwa 1.300 Codezeilen.

Brauchst du das alles? Sehen wir uns das noch einmal an. Suchen Sie in der Hauptkomponentendatei, in der das utils-Modul importiert wird, nach der Anzahl der Instanzen dieses Namespace.

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

Der Namespace utils kommt in unserer Anwendung nur an drei Stellen vor. Aber für welche Funktionen? Wenn Sie sich die Hauptkomponentendatei noch einmal ansehen, scheint es nur eine Funktion zu geben, nämlich utils.simpleSort. Mit dieser Funktion wird die Liste der Suchergebnisse nach einer Reihe von Kriterien sortiert, 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);
}

In einer Datei mit 1.300 Zeilen mit einer Reihe von Exporten wird nur einer davon verwendet. Dies führt dazu, dass viel ungenutztes JavaScript gesendet wird.

Diese Beispiel-App ist zwar etwas konstruiert, aber dieses synthetische Szenario ähnelt tatsächlichen Optimierungsmöglichkeiten, die Sie in einer Produktions-Web-App finden können. Nachdem Sie eine Möglichkeit für das Tree Shaking identifiziert haben, wie wird es dann tatsächlich durchgeführt?

Babel daran hindern, ES6-Module in CommonJS-Module zu transpilieren

Babel ist ein unverzichtbares Tool, aber es kann die Auswirkungen des Baumschüttelns etwas schwieriger zu beobachten machen. Wenn Sie @babel/preset-env verwenden, kann Babel ES6-Module in allgemein kompatiblere CommonJS-Module umwandeln, also Module, die Sie mit require statt import importieren.

Da das Tree Shaking bei CommonJS-Modulen schwieriger ist, weiß webpack nicht, was aus den Bundles entfernt werden soll, wenn Sie sich für diese entscheiden. Die Lösung besteht darin, @babel/preset-env so zu konfigurieren, dass ES6-Module explizit in Ruhe gelassen werden. Unabhängig davon, ob du Babel in babel.config.js oder package.json konfigurierst, musst du 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 im Auge behalten

Ein weiterer Aspekt, den Sie beim Entfernen von Abhängigkeiten aus Ihrer App berücksichtigen sollten, ist, ob die Module Ihres Projekts Nebenwirkungen haben. Ein Beispiel für eine Nebenwirkung ist, wenn eine Funktion etwas außerhalb ihres eigenen Gültigkeitsbereichs ändert. Dies ist ein Nebeneffekt der 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 führt addFruit zu einer Nebenwirkung, wenn es das Array fruits modifiziert, das sich außerhalb seines Gültigkeitsbereichs befindet.

Nebenwirkungen gelten auch für ES6-Module, was im Zusammenhang mit Tree Shaking wichtig ist. Module, die vorhersehbare Eingaben annehmen und ebenso vorhersehbare Ausgaben liefern, ohne außerhalb ihres eigenen Umfangs etwas zu ändern, sind Abhängigkeiten, die wir getrost entfernen können, wenn wir sie nicht verwenden. Sie sind eigenständige, modulare Code-Abschnitte. Daher „Module“.

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

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

Alternativ können Sie webpack mitteilen, welche Dateien nicht ohne Nebenwirkungen sind:

{
  "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 das nicht in Ihrer package.json-Datei angeben 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 in Ruhe zu lassen, ist eine kleine Anpassung an der import-Syntax erforderlich, damit nur die erforderlichen Funktionen aus dem utils-Modul importiert werden. In diesem Beispiel ist nur die Funktion simpleSort erforderlich:

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

Da nur simpleSort statt des gesamten utils-Moduls 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 Sie in diesem Beispiel für das Tree Shaking benötigen. Das ist die webpack-Ausgabe vor dem Schütteln des Abhängigkeitsbaums:

                 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 dem erfolgreichen 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 Sets wurden verkleinert, aber das main-Set profitiert am meisten davon. 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 des Scripts verkürzt, sondern auch die Verarbeitungszeit.

Geh raus und schüttele ein paar Bäume!

Welche Vorteile Sie durch das Entfernen von Abhängigkeiten erzielen, 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, welche Vorteile es für Ihre Anwendung hat.

Durch das Entfernen von untergeordneten Elementen kann sich die Leistung erheblich verbessern oder nur unwesentlich steigern. Wenn Sie Ihr Build-System jedoch so konfigurieren, dass diese Optimierung bei Produktionsbuilds genutzt wird, und nur das importieren, was Ihre Anwendung benötigt, können Sie Ihre Anwendungsbundles proaktiv so klein wie möglich halten.

Ein besonderer Dank geht an 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.