JavaScript-Nutzlasten mit Tree Shaking reduzieren

Heutzutage können Webanwendungen ziemlich groß werden, vor allem der JavaScript-Teil. Seit Mitte 2018 liegt die durchschnittliche Übertragungsgröße von JavaScript auf Mobilgeräten bei HTTP Archive bei etwa 350 KB. Und das ist nur die Größe der Übertragung! JavaScript wird oft komprimiert, wenn es über das Netzwerk gesendet wird. Das bedeutet, dass die tatsächliche Menge an JavaScript deutlich höher ist, nachdem der Browser es dekomprimiert hat. Das ist wichtig, denn für die Verarbeitung von Ressourcen ist die Komprimierung irrelevant. 900 KB dekomprimiertes JavaScript sind für den Parser und Compiler immer noch 900 KB, auch wenn sie in komprimiertem Zustand etwa 300 KB groß sein können.

<ph type="x-smartling-placeholder">
</ph> Ein Diagramm, das das Herunterladen, Entpacken, Parsen, Kompilieren und Ausführen von JavaScript veranschaulicht. <ph type="x-smartling-placeholder">
</ph> Der Download und die Ausführung von JavaScript. Obwohl die Übertragungsgröße des Skripts 300 KB komprimiert ist, umfasst es immer noch 900 KB JavaScript, das geparst, kompiliert und ausgeführt werden muss.

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

<ph type="x-smartling-placeholder">
</ph> Ein Diagramm, in dem die Verarbeitungszeit von 170 KB JavaScript mit der eines JPEG-Bildes in gleicher Größe verglichen wird. Die JavaScript-Ressource ist für Byte wesentlich ressourcenintensiver als die JPEG-Ressource. <ph type="x-smartling-placeholder">
</ph> Die Verarbeitungskosten für das Parsen/Kompilieren von 170 KB JavaScript im Vergleich zur Decodierungszeit einer entsprechenden JPEG-Größe. (Quelle)

Wir arbeiten kontinuierlich an Verbesserungen, um die Effizienz von JavaScript-Engines zu verbessern. Die Verbesserung der JavaScript-Leistung ist jedoch eine Aufgabe für Entwickler.

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

Diese Technik funktioniert zwar, löst jedoch kein häufiges Problem von Anwendungen mit vielen JavaScript-Anwendungen, nämlich das Einbinden von Code, der nie verwendet wird. Beim Baumschütteln wird versucht, dieses Problem zu lösen.

Was ist Baumzittern?

Das Erschüttern des Baumes ist eine Form der Eliminierung toter Codes. Der Begriff wurde bei Rollup bekannt gemacht, aber das Konzept der Eliminierung von veraltetem Code gibt es schon seit geraumer Zeit. Das Konzept hat auch einen Kauf im Webpack gefunden, das in diesem Artikel anhand einer Beispiel-App veranschaulicht wird.

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

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

Wenn eine App noch jung ist – also ein Frühling –, kann sie von wenigen Abhängigkeiten abhängen. Außerdem werden die meisten, wenn nicht sogar alle, von Ihnen hinzugefügten Abhängigkeiten verwendet. Mit der Weiterentwicklung Ihrer Anwendung können jedoch weitere Abhängigkeiten hinzugefügt werden. Zum Zusammenstellen von Rechtsangelegenheiten werden ältere Abhängigkeiten nicht mehr in Gebrauch, werden aber möglicherweise nicht aus Ihrer Codebasis beschnitten. Dies führt dazu, dass eine App viel nicht verwendetes JavaScript enthält. Baumschüttungen lösen dieses Problem, indem sie mithilfe statischer 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 nur bestimmte Teile des Moduls importiert werden, anstatt alles aus dem "array-utils"-Modul zu importieren – was viel Code sein könnte. In Entwicklungs-Builds ändert sich nichts, da das gesamte Modul trotzdem importiert wird. In Produktions-Builds kann Webpack so konfiguriert werden, dass es „schütteln“ Exporte von ES6-Modulen, die nicht explizit importiert wurden, werden verringert. In diesem Leitfaden erfahren Sie, wie das geht.

Möglichkeiten finden, einen Baum zu erschüttern

Zur Veranschaulichung ist eine einseitige Beispiel-App verfügbar, die zeigt, wie das Beben von Bäumen funktioniert. Sie können es klonen und die Schritte ausführen, wenn Sie möchten. Wir gehen jedoch in diesem Leitfaden auf jeden Schritt des Wegs ein, sodass das Klonen nicht notwendig ist – es sei denn, Sie lernen praktisches Lernen.

Die Beispiel-App ist eine durchsuchbare Datenbank mit Gitarren-Effektpedalen. Wenn du eine Suchanfrage eingibst, wird eine Liste der Effektpedale eingeblendet.

<ph type="x-smartling-placeholder">
</ph> Screenshot einer One-Page-Beispielanwendung, mit der in einer Datenbank mit Gitarren-Effektpedalen gesucht wird <ph type="x-smartling-placeholder">
</ph> Screenshot der Beispiel-App

Das Verhalten, das diese Anwendung steuert, ist in Anbieter (d.h. Preact und Emotion) sowie appspezifische Code-Bundles (oder „Chunks“, wie sie vom Webpack genannt werden):

<ph type="x-smartling-placeholder">
</ph> Screenshot von zwei Anwendungscode-Bundles (oder -Blöcken), die im Bereich „Netzwerk“ der Chrome-Entwicklertools angezeigt werden <ph type="x-smartling-placeholder">
</ph> Die beiden JavaScript-Bundles der App. Dies sind unkomprimierte Größen.

Die in der Abbildung oben gezeigten JavaScript-Bundles sind Produktions-Builds, d. h. sie wurden durch Uglification optimiert. 21,1 KB für ein App-spezifisches Bundle sind zwar nicht schlecht, aber es ist zu beachten, dass es keinerlei Strukturschütteln gibt. Sehen wir uns den App-Code an, um herauszufinden, wie sich dieses Problem beheben lässt.

In jeder Anwendung muss nach statischen import-Anweisungen gesucht werden, um Möglichkeiten für Baumwippen zu finden. Oben in der Datei mit der Hauptkomponente sehen Sie eine Zeile wie die folgende:

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

Sie können ES6-Module auf verschiedene Weise importieren, aber Sie sollten auf die hier beschriebenen achten. In dieser Zeile steht: „import alles aus dem Modul utils. Fügen Sie es in einen Namespace namens utils ein. Die große Frage ist, wie viele Dinge in diesem Modul enthalten sind.

Im Quellcode des Moduls utils finden Sie etwa 1.300 Codezeilen.

Brauchen Sie all das Brauchen? Dazu suchen wir zuerst in der Hauptkomponentendatei, die das Modul utils importiert, um zu sehen, wie viele Instanzen dieses Namespace entstehen.

<ph type="x-smartling-placeholder">
</ph> Screenshot einer Suche nach „utils“ in einem Texteditor, bei der nur drei Ergebnisse zurückgegeben werden. <ph type="x-smartling-placeholder">
</ph> Der Namespace utils, aus dem wir zahlreiche Module importiert haben, wird in der Hauptkomponentendatei nur dreimal aufgerufen.

Wie sich herausstellt, erscheint der utils-Namespace nur an drei Stellen in unserer Anwendung – aber für welche Funktionen? Wenn Sie sich die Hauptkomponentendatei noch einmal ansehen, scheint es nur eine Funktion zu geben: utils.simpleSort, mit der die Suchergebnisliste nach einer Reihe von Kriterien sortiert wird, 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,die eine Reihe von Exporten umfasst, wird nur einer verwendet. Dies führt dazu, dass viel nicht verwendetes JavaScript versendet wird.

Diese Beispiel-App ist zwar zugegebenermaßen ein wenig konstruiert, ändert jedoch nichts an der Tatsache, dass dieses synthetische Szenario tatsächlichen Optimierungsmöglichkeiten ähnelt, die Ihnen in einer Produktions-Web-App begegnen könnten. Nachdem Sie nun eine Möglichkeit identifiziert haben, dass das Beben von Bäumen nützlich ist, wie wird dies tatsächlich umgesetzt?

Verhindern, dass Babel ES6-Module in CommonJS-Module übersetzt

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

Da das Baumwackeln bei CommonJS-Modulen schwieriger ist, weiß Webpack nicht, was aus Bundles entfernt werden soll, falls Sie sie verwenden möchten. Die Lösung besteht darin, @babel/preset-env so zu konfigurieren, dass ES6-Module explizit wegfallen. Unabhängig davon, ob Sie Babel in babel.config.js oder package.json konfigurieren, müssen Sie weitere Elemente hinzufügen:

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

Wenn Sie modules: false in Ihrer @babel/preset-env-Konfiguration angeben, funktioniert Babel wie gewünscht, sodass Webpack Ihre Abhängigkeitsstruktur analysieren und nicht verwendete Abhängigkeiten abschütteln kann.

Nebenwirkungen berücksichtigen

Ein weiterer Aspekt, der beim Schütteln von Abhängigkeiten von Ihrer App berücksichtigt werden sollte, ist, der Module Ihres Projekts Nebenwirkungen haben. Ein Beispiel für einen Nebeneffekt ist, ändert etwas außerhalb des eigenen Bereichs, was ein Nebeneffekt ist. seiner 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 erzeugt addFruit einen Nebeneffekt, wenn das fruits-Array geändert wird, was außerhalb des zulässigen Bereichs liegt.

Nebenwirkungen gelten auch für ES6-Module und das ist im Zusammenhang mit Baumwicken wichtig. Module, die vorhersehbare Eingaben nehmen 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. Sie sind eigenständige, modulare Code-Snippets. also „Module“.

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

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

Alternativ kannst du dem Webpack mitteilen, für welche Dateien es keine Nebenwirkungen gibt:

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

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

Nur die benötigten Elemente importieren

Nachdem Babel angewiesen wurde, die ES6-Module in Ruhe zu lassen, ist eine geringfügige Anpassung an der import-Syntax erforderlich, um nur die Funktionen einzubinden, die aus dem utils-Modul benötigt werden. Für das Beispiel dieser Anleitung ist nur die Funktion simpleSort erforderlich:

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

Da statt des gesamten utils-Moduls nur simpleSort 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 die Baumbewegung in diesem Beispiel erforderlich ist. So sieht die Webpack-Ausgabe vor dem Schütteln der Abhängigkeitsstruktur 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

So sieht die Ausgabe aus, nachdem die Baumstruktur erfolgreich war:

                 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 sind zwar geschrumpft, aber wirklich das main-Paket profitiert am meisten. Durch das Abschütteln der ungenutzten Teile des utils-Moduls wird das main-Bundle um etwa 60 % verkleinert. Dadurch verringert sich nicht nur die Zeit, die das Skript für den Download benötigt, sondern auch die Verarbeitungszeit.

Schüttel ein paar Bäume!

Wie viel Sie dabei haben, hängt von Ihrer Anwendung und ihren Abhängigkeiten und Architektur ab. Testen! Wenn Sie wissen, dass Sie Ihren Modul-Bundler nicht für diese Optimierung eingerichtet haben, schadet es nicht, wenn Sie die Vorteile für Ihre Anwendung testen möchten.

Durch Baumzittern kann die Leistung erheblich oder gar nicht gesteigert werden. Wenn Sie Ihr Build-System jedoch so konfigurieren, dass die Optimierung in Produktions-Builds genutzt wird und nur das importiert wird, was Ihre Anwendung wirklich benötigt, halten Sie Ihre Anwendungs-Bundles proaktiv so klein wie möglich.

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.