Tree-Shaking: Ein Referenzhandbuch
Veröffentlicht: 2022-03-10Bevor wir uns auf den Weg machen, um zu lernen, was Tree-Shaking ist und wie wir uns damit erfolgreich aufstellen können, müssen wir verstehen, welche Module im JavaScript-Ökosystem enthalten sind.
Seit ihren Anfängen haben JavaScript-Programme an Komplexität und der Anzahl der Aufgaben, die sie ausführen, zugenommen. Die Notwendigkeit, solche Aufgaben in geschlossene Ausführungsbereiche zu unterteilen, wurde offensichtlich. Diese Bereiche von Aufgaben oder Werten nennen wir Module . Ihr Hauptzweck besteht darin, Wiederholungen zu vermeiden und die Wiederverwendbarkeit zu fördern. Daher wurden Architekturen entwickelt, um solche speziellen Arten von Spielraum zuzulassen, ihre Werte und Aufgaben offenzulegen und externe Werte und Aufgaben zu konsumieren.
Um tiefer in das einzutauchen, was Module sind und wie sie funktionieren, empfehle ich „ES Modules: A Cartoon Deep-Dive“. Aber um die Nuancen von Tree-Shaking und Modulverbrauch zu verstehen, sollte die obige Definition ausreichen.
Was bedeutet Tree-Shaking eigentlich?
Einfach ausgedrückt bedeutet Treeshaking das Entfernen von unerreichbarem Code (auch bekannt als toter Code) aus einem Bundle. Wie in der Dokumentation von Webpack Version 3 angegeben:
„Ihre Anwendung können Sie sich wie einen Baum vorstellen. Der Quellcode und die Bibliotheken, die Sie tatsächlich verwenden, repräsentieren die grünen, lebendigen Blätter des Baums. Der tote Code repräsentiert die braunen, toten Blätter des Baums, die bis zum Herbst verbraucht sind. Um die abgestorbenen Blätter loszuwerden, muss man den Baum schütteln, sodass sie herunterfallen.“
Der Begriff wurde erstmals vom Rollup-Team in der Front-End-Community populär gemacht. Aber Autoren aller dynamischen Sprachen haben schon viel früher mit dem Problem zu kämpfen. Die Idee eines Tree-Shaking-Algorithmus lässt sich mindestens bis in die frühen 1990er Jahre zurückverfolgen.
Im JavaScript-Land ist Treeshaking seit der Spezifikation des ECMAScript-Moduls (ESM) in ES2015, früher bekannt als ES6, möglich. Seitdem ist Treeshaking in den meisten Bundlern standardmäßig aktiviert, da sie die Ausgabegröße reduzieren, ohne das Verhalten des Programms zu ändern.
Der Hauptgrund dafür ist, dass ESMs von Natur aus statisch sind. Lassen Sie uns analysieren, was das bedeutet.
ES-Module vs. CommonJS
CommonJS ist einige Jahre älter als die ESM-Spezifikation. Es entstand, um den Mangel an Unterstützung für wiederverwendbare Module im JavaScript-Ökosystem zu beheben. CommonJS verfügt über eine require()
Funktion, die ein externes Modul basierend auf dem bereitgestellten Pfad abruft und es während der Laufzeit dem Gültigkeitsbereich hinzufügt.
Dass require
eine function
wie jede andere in einem Programm ist, macht es schwer genug, das Aufrufergebnis zur Kompilierzeit auszuwerten. Hinzu kommt, dass das Hinzufügen require
-Aufrufen überall im Code möglich ist – eingeschlossen in einen anderen Funktionsaufruf, innerhalb von if/else-Anweisungen, in switch-Anweisungen usw.
Mit dem Lernen und Kämpfen, die sich aus der breiten Übernahme der CommonJS-Architektur ergeben haben, hat sich die ESM-Spezifikation auf diese neue Architektur festgelegt, in der Module durch die entsprechenden Schlüsselwörter import
und export
importiert und exportiert werden. Daher keine Funktionsaufrufe mehr. ESMs sind auch nur als Top-Level-Deklarationen zulässig – eine Verschachtelung in einer anderen Struktur ist nicht möglich, da sie statisch sind: ESMs hängen nicht von der Ausführung zur Laufzeit ab.
Umfang und Nebenwirkungen
Es gibt jedoch noch eine weitere Hürde, die Tree-Shaking überwinden muss, um Blähungen zu vermeiden: Nebenwirkungen. Eine Funktion wird als nebenwirkungsreich angesehen, wenn sie sich verändert oder auf Faktoren beruht, die außerhalb des Ausführungsbereichs liegen. Eine Funktion mit Nebeneffekten gilt als unrein . Eine reine Funktion liefert immer dasselbe Ergebnis, unabhängig vom Kontext oder der Umgebung, in der sie ausgeführt wurde.
const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c
Bundler erfüllen ihren Zweck, indem sie den bereitgestellten Code so weit wie möglich auswerten, um festzustellen, ob ein Modul rein ist. Aber die Codeauswertung während der Kompilierzeit oder der Bündelungszeit kann nur so weit gehen. Daher wird davon ausgegangen, dass Pakete mit Seiteneffekten nicht ordnungsgemäß eliminiert werden können, selbst wenn sie vollständig unerreichbar sind.
Aus diesem Grund akzeptieren Bundler jetzt einen Schlüssel in der Datei „ package.json
“ des Moduls, mit dem der Entwickler angeben kann, ob ein Modul keine Nebenwirkungen hat. Auf diese Weise kann der Entwickler die Codeauswertung deaktivieren und den Bundler darauf hinweisen; der Code innerhalb eines bestimmten Pakets kann eliminiert werden, wenn es keinen erreichbaren import oder require
-Befehl gibt, der darauf verweist. Dies sorgt nicht nur für ein schlankeres Bundle, sondern kann auch die Kompilierungszeiten verkürzen.
{ "name": "my-package", "sideEffects": false }
Wenn Sie also ein Paketentwickler sind, verwenden Sie sideEffects
gewissenhaft vor der Veröffentlichung und überarbeiten Sie es natürlich bei jeder Veröffentlichung, um unerwartete Breaking Changes zu vermeiden.
Zusätzlich zum sideEffects
-Stammschlüssel ist es auch möglich, die Reinheit Datei für Datei zu bestimmen, indem Sie einen Inline-Kommentar /*@__PURE__*/
zu Ihrem Methodenaufruf hinzufügen.
const x = */@__PURE__*/eliminated_if_not_called()
Ich betrachte diese Inline-Anmerkung als Notausstieg für den Consumer-Entwickler, der durchgeführt werden muss, falls ein Paket sideEffects: false
nicht deklariert hat oder falls die Bibliothek tatsächlich einen Nebeneffekt für eine bestimmte Methode aufweist.
Optimierung von Webpack
Ab Version 4 erfordert Webpack immer weniger Konfiguration, um Best Practices zum Laufen zu bringen. Die Funktionalität für einige Plugins wurde in den Kern integriert. Und weil das Entwicklungsteam die Bündelgröße sehr ernst nimmt, haben sie das Baumschütteln einfach gemacht.
Wenn Sie kein großer Tüftler sind oder Ihre Anwendung keine Sonderfälle hat, dann ist das Baumschütteln Ihrer Abhängigkeiten eine Sache von nur einer Zeile.
Die Datei webpack.config.js
hat eine Stammeigenschaft namens mode
. Wenn der Wert dieser Eigenschaft die production
ist, wird sie Ihre Module erschüttern und vollständig optimieren. Neben der Eliminierung von totem Code mit dem TerserPlugin
ermöglicht mode: 'production'
deterministische verstümmelte Namen für Module und Chunks und aktiviert die folgenden Plugins:
- Verwendung der Flag-Abhängigkeit,
- Flag enthaltene Chunks,
- Modulverkettung,
- keine Emission bei Fehlern.
Der Triggerwert ist nicht zufällig die production
. Sie möchten nicht, dass Ihre Abhängigkeiten in einer Entwicklungsumgebung vollständig optimiert werden, da dies das Debuggen von Problemen erheblich erschwert. Daher würde ich vorschlagen, mit einem von zwei Ansätzen vorzugehen.
Einerseits könnten Sie ein mode
-Flag an die Webpack-Befehlszeilenschnittstelle übergeben:
# This will override the setting in your webpack.config.js webpack --mode=production
Alternativ können Sie die Variable process.env.NODE_ENV
in webpack.config.js
:
mode: process.env.NODE_ENV === 'production' ? 'production' : development
In diesem Fall müssen Sie daran denken, --NODE_ENV=production
in Ihrer Bereitstellungspipeline zu übergeben.
Beide Ansätze sind eine Abstraktion auf dem bekannten definePlugin
von Webpack Version 3 und darunter. Für welche Option Sie sich entscheiden, spielt absolut keine Rolle.
Webpack-Version 3 und niedriger
Es ist erwähnenswert, dass die Szenarien und Beispiele in diesem Abschnitt möglicherweise nicht für neuere Versionen von Webpack und anderen Bundlern gelten. In diesem Abschnitt wird die Verwendung von UglifyJS Version 2 anstelle von Terser betrachtet. UglifyJS ist das Paket, aus dem Terser geforkt wurde, daher kann sich die Codeauswertung zwischen ihnen unterscheiden.
Da die Webpack-Version 3 und darunter die Eigenschaft sideEffects
in package.json
nicht unterstützen, müssen alle Pakete vollständig ausgewertet werden, bevor der Code eliminiert wird. Dies allein macht den Ansatz weniger effektiv, aber es müssen auch einige Vorbehalte berücksichtigt werden.
Wie oben erwähnt, hat der Compiler keine Möglichkeit, selbst herauszufinden, wenn ein Paket den globalen Gültigkeitsbereich manipuliert. Aber das ist nicht die einzige Situation, in der es das Baumschütteln überspringt. Es gibt unschärfere Szenarien.
Nehmen Sie dieses Paketbeispiel aus der Webpack-Dokumentation:
// transform.js import * as mylib from 'mylib'; export const someVar = mylib.transform({ // ... }); export const someOtherVar = mylib.transform({ // ... });
Und hier ist der Einstiegspunkt eines Verbraucherbündels:
// index.js import { someVar } from './transforms.js'; // Use `someVar`...
Es gibt keine Möglichkeit festzustellen, ob mylib.transform
Nebenwirkungen hervorruft. Daher wird kein Code eliminiert.
Hier sind andere Situationen mit einem ähnlichen Ergebnis:
- Aufrufen einer Funktion aus einem Modul eines Drittanbieters, das der Compiler nicht überprüfen kann,
- Wiederexportieren von Funktionen, die aus Modulen von Drittanbietern importiert wurden.
Ein Tool, das dem Compiler helfen könnte, Tree-Shaking zum Laufen zu bringen, ist babel-plugin-transform-imports. Es teilt alle Mitglieds- und benannten Exporte in Standardexporte auf, sodass die Module einzeln ausgewertet werden können.
// before transformation import { Row, Grid as MyGrid } from 'react-bootstrap'; import { merge } from 'lodash'; // after transformation import Row from 'react-bootstrap/lib/Row'; import MyGrid from 'react-bootstrap/lib/Grid'; import merge from 'lodash/merge';
Es hat auch eine Konfigurationseigenschaft, die den Entwickler warnt, lästige import-Anweisungen zu vermeiden. Wenn Sie Webpack Version 3 oder höher verwenden und Ihre Due Diligence mit der Grundkonfiguration durchgeführt und die empfohlenen Plugins hinzugefügt haben, aber Ihr Paket immer noch aufgebläht aussieht, dann empfehle ich Ihnen, dieses Paket auszuprobieren.
Scope Hoisting und Kompilierungszeiten
In der Zeit von CommonJS haben die meisten Bundler einfach jedes Modul in eine andere Funktionsdeklaration eingeschlossen und sie einem Objekt zugeordnet. Das ist nicht anders als jedes Kartenobjekt da draußen:
(function (modulesMap, entry) { // provided CommonJS runtime })({ "index.js": function (require, module, exports) { let { foo } = require('./foo.js') foo.doStuff() }, "foo.js": function(require, module, exports) { module.exports.foo = { doStuff: () => { console.log('I am foo') } } } }, "index.js")
Abgesehen davon, dass dies statisch schwer zu analysieren ist, ist dies grundsätzlich inkompatibel mit ESMs, da wir gesehen haben, dass wir import
und export
nicht umschließen können. Heutzutage heben Bundler also jedes Modul auf die höchste Ebene:
// moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()
Dieser Ansatz ist voll kompatibel mit ESMs; Außerdem ermöglicht es die Codeauswertung, Module, die nicht aufgerufen werden, leicht zu erkennen und sie zu löschen. Der Nachteil dieses Ansatzes besteht darin, dass das Kompilieren erheblich mehr Zeit in Anspruch nimmt, da es jede Anweisung berührt und das Bündel während des Prozesses im Speicher speichert. Das ist ein wichtiger Grund, warum die Bündelung der Leistung für alle zu einem noch größeren Anliegen geworden ist und warum kompilierte Sprachen in Tools für die Webentwicklung genutzt werden. Beispielsweise ist esbuild ein in Go geschriebener Bundler, und SWC ist ein in Rust geschriebener TypeScript-Compiler, der sich in Spark integriert, einem ebenfalls in Rust geschriebenen Bundler.
Um das Heben des Oszilloskops besser zu verstehen, empfehle ich dringend die Dokumentation von Parcel Version 2.
Vermeiden Sie vorzeitiges Umpacken
Es gibt ein spezifisches Problem, das leider ziemlich häufig vorkommt und für Tree-Shaking verheerend sein kann. Kurz gesagt, es passiert, wenn Sie mit speziellen Loadern arbeiten und verschiedene Compiler in Ihren Bundler integrieren. Gängige Kombinationen sind TypeScript, Babel und Webpack – in allen möglichen Permutationen.
Sowohl Babel als auch TypeScript haben ihre eigenen Compiler, und ihre jeweiligen Ladeprogramme ermöglichen dem Entwickler, sie für eine einfache Integration zu verwenden. Und darin liegt die verborgene Bedrohung.
Diese Compiler erreichen Ihren Code vor der Codeoptimierung. Und ob standardmäßig oder aufgrund einer Fehlkonfiguration, diese Compiler geben häufig CommonJS-Module anstelle von ESMs aus. Wie in einem vorherigen Abschnitt erwähnt, sind CommonJS-Module dynamisch und können daher nicht richtig für die Eliminierung von totem Code ausgewertet werden.
Dieses Szenario wird heutzutage mit der Zunahme „isomorpher“ Apps (d. h. Apps, die denselben Code sowohl server- als auch clientseitig ausführen) immer häufiger. Da Node.js noch keine Standardunterstützung für ESMs bietet, geben Compiler, wenn sie auf die node
ausgerichtet sind, CommonJS aus.
Überprüfen Sie also unbedingt den Code, den Ihr Optimierungsalgorithmus erhält .
Tree-Shaking-Checkliste
Nachdem Sie nun wissen, wie Bündelung und Tree-Shaking funktionieren, wollen wir uns eine Checkliste erstellen, die Sie an einer praktischen Stelle ausdrucken können, wenn Sie Ihre aktuelle Implementierung und Codebasis überdenken. Hoffentlich sparen Sie dadurch Zeit und können nicht nur die wahrgenommene Leistung Ihres Codes optimieren, sondern vielleicht sogar die Build-Zeiten Ihrer Pipeline!
- Verwenden Sie ESMs nicht nur in Ihrer eigenen Codebasis, sondern bevorzugen Sie auch Pakete, die ESM als Verbrauchsmaterial ausgeben.
- Stellen Sie sicher, dass Sie genau wissen, welche Ihrer Abhängigkeiten (falls vorhanden) keine
sideEffects
deklariert oder sie auftrue
gesetzt haben. - Verwenden Sie die Inline-Annotation, um Methodenaufrufe zu deklarieren, die rein sind, wenn Sie Pakete mit Nebeneffekten verwenden.
- Wenn Sie CommonJS-Module ausgeben, stellen Sie sicher, dass Sie Ihr Bundle optimieren, bevor Sie die Import- und Exportanweisungen umwandeln.
Paketerstellung
Hoffentlich sind wir uns zu diesem Zeitpunkt alle einig, dass ESMs der Weg nach vorne im JavaScript-Ökosystem sind. Wie immer in der Softwareentwicklung können Übergänge jedoch schwierig sein. Glücklicherweise können Paketautoren bruchsichere Maßnahmen ergreifen, um ihren Benutzern eine schnelle und nahtlose Migration zu ermöglichen.
Mit einigen kleinen Ergänzungen zu package.json
kann Ihr Paket Bundlern mitteilen, welche Umgebungen das Paket unterstützt und wie sie am besten unterstützt werden. Hier ist eine Checkliste von Skypack:
- Schließen Sie einen ESM-Export ein.
- Fügen Sie
"type": "module"
. - Geben Sie einen Einstiegspunkt durch
"module": "./path/entry.js"
(eine Community-Konvention).
Und hier ist ein Beispiel, das sich ergibt, wenn alle Best Practices befolgt werden und Sie sowohl Web- als auch Node.js-Umgebungen unterstützen möchten:
{ // ... "main": "./index-cjs.js", "module": "./index-esm.js", "exports": { "require": "./index-cjs.js", "import": "./index-esm.js" } // ... }
Darüber hinaus hat das Skypack-Team einen Paketqualitätswert als Benchmark eingeführt, um festzustellen, ob ein bestimmtes Paket auf Langlebigkeit und Best Practices ausgelegt ist. Das Tool ist Open-Source auf GitHub und kann Ihrem Paket als devDependency
hinzugefügt werden, um die Überprüfungen vor jeder Veröffentlichung einfach durchzuführen.
Einpacken
Ich hoffe, dass dieser Artikel für Sie nützlich war. Wenn ja, erwägen Sie, es mit Ihrem Netzwerk zu teilen. Ich freue mich darauf, mit Ihnen in den Kommentaren oder auf Twitter zu interagieren.
Nützliche Ressourcen
Artikel und Dokumentation
- „ES Modules: A Cartoon Deep-Dive“, Lin Clark, Mozilla Hacks
- „Baumschütteln“, Webpack
- „Konfiguration“, Webpack
- „Optimierung“, Webpack
- „Scope Hoisting“, Parcel-Version 2-Dokumentation
Projekte und Werkzeuge
- Terser
- babel-plugin-transform-imports
- Himmelspack
- Webpaket
- Paket
- Aufrollen
- bauen
- SWC
- Paketprüfung