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 JavaScript-Menge nach der Dekomprimierung durch den Browser deutlich höher ist. 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 komprimiert etwa 300 KB groß sind.
JavaScript ist eine ressourcenintensive Sprache. 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.
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.
Zu diesem Zweck gibt es Techniken zur Verbesserung der JavaScript-Leistung. 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 Baumzittern?
Das Erschüttern des Baumes ist eine Form der Eliminierung toter Codes. 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 in der Baumstruktur stellt eine Abhängigkeit dar, die unterschiedliche Funktionen für Ihre App bietet. In modernen Anwendungen werden diese Abhängigkeiten über statische import
-Anweisungen wie folgt eingebracht:
// 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 sogar alle – die Abhängigkeiten verwendet, die Sie hinzufügen. 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 löst dieses Problem, indem es nutzt, wie bestimmte Teile von ES6-Modulen mithilfe statischer 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 ist eine einseitige Beispiel-App verfügbar, die zeigt, wie das Beben von Bäumen 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.
Das Verhalten, das diese Anwendung steuert, ist in Anbieter (d.h. Preact und Emotion) und appspezifische Code-Bundles (oder „Chunks“, wie sie in Webpack genannt werden):
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 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.
In jeder Anwendung ist es erforderlich, nach statischen import
-Anweisungen zu suchen, um Möglichkeiten für Baumwippen 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 Namensraum namens utils
verschieben“. Die entscheidende Frage ist hier: „Wie viel Zeug ist in diesem Modul?“
Im Quellcode des Moduls utils
finden Sie 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.
Der Namespace utils
wird in unserer Anwendung nur an drei Stellen verwendet. 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 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 Gelegenheit für das Tree Shaking erkannt haben, wie wird es dann tatsächlich durchgeführt?
Babel daran hindern, ES6-Module in CommonJS-Module zu transpilieren
Babel ist ein unverzichtbares Werkzeug, aber es kann die Auswirkungen von Baumschüttungen etwas erschweren. 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 berücksichtigen
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 einen Nebeneffekt ist, wenn eine Funktion etwas außerhalb ihres eigenen Bereichs ändert. Dies ist ein Nebeneffekt 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 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 kannst du dem Webpack mitteilen, welche Dateien nicht frei von Nebeneffekten 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 die benötigten Elemente werden importiert
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. In diesem Beispiel ist nur die Funktion simpleSort
erforderlich:
import { simpleSort } from "../../utils/utils";
Da nur simpleSort
anstelle 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 kaum merklich steigern. 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.