So vergrößert CommonJS Ihre Bundles

Informationen dazu, wie sich CommonJS-Module auf das Tree-Shaking Ihrer Anwendung auswirken

In diesem Beitrag erfahren Sie, was CommonJS ist und warum es Ihre JavaScript-Bundles größer als nötig macht.

Zusammenfassung: Damit der Bundler Ihre Anwendung erfolgreich optimieren kann, sollten Sie keine Abhängigkeiten von CommonJS-Modulen haben und in Ihrer gesamten Anwendung die ECMAScript-Modulsyntax verwenden.

Was ist CommonJS?

CommonJS ist ein Standard aus dem Jahr 2009, der Konventionen für JavaScript-Module festlegt. Sie wurde ursprünglich für die Verwendung außerhalb des Webbrowsers entwickelt, hauptsächlich für serverseitige Anwendungen.

Mit CommonJS können Sie Module definieren, Funktionen daraus exportieren und in andere Module importieren. Im folgenden Snippet wird beispielsweise ein Modul definiert, das fünf Funktionen exportiert: add, subtract, multiply, divide und max:

// utils.js
const { maxBy } = require('lodash-es');
const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Später kann ein anderes Modul einige oder alle dieser Funktionen importieren und verwenden:

// index.js
const { add } = require('./utils.js');
console.log(add(1, 2));

Wenn Sie index.js mit node aufrufen, wird die Zahl 3 in der Konsole ausgegeben.

Da es Anfang der 2010er-Jahre kein standardisiertes Modulsystem im Browser gab, wurde CommonJS auch zu einem beliebten Modulformat für clientseitige JavaScript-Bibliotheken.

Wie wirkt sich CommonJS auf die endgültige Größe des Bundles aus?

Die Größe Ihrer serverseitigen JavaScript-Anwendung ist nicht so kritisch wie im Browser. Deshalb wurde CommonJS nicht entwickelt, um die Größe des Produktions-Bundles zu reduzieren. Gleichzeitig zeigt eine Analyse, dass die Größe des JavaScript-Bundles immer noch der Hauptgrund für die Verlangsamung von Browser-Apps ist.

JavaScript-Bundler und -Minifier wie webpack und terser führen verschiedene Optimierungen durch, um die Größe Ihrer App zu reduzieren. Dabei wird Ihre Anwendung zum Zeitpunkt des Builds analysiert und es wird versucht, so viel wie möglich aus dem nicht verwendeten Quellcode zu entfernen.

Im obigen Snippet sollte Ihr endgültiges Bundle beispielsweise nur die Funktion add enthalten, da dies das einzige Symbol aus utils.js ist, das Sie in index.js importieren.

Erstellen wir die App mit der folgenden webpack-Konfiguration:

const path = require('path');
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
};

Hier geben wir an, dass wir Optimierungen im Produktionsmodus verwenden und index.js als Einstiegspunkt verwenden möchten. Wenn wir nach der Ausführung von webpack die Größe der Ausgabe prüfen, sehen wir ungefähr Folgendes:

$ cd dist && ls -lah
625K Apr 13 13:04 out.js

Das Bundle hat eine Größe von 625 KB. In der Ausgabe sehen wir alle Funktionen aus utils.js sowie viele Module aus lodash. Obwohl wir lodash in index.js nicht verwenden, ist es Teil des Outputs, was unseren Produktions-Assets einen großen zusätzlichen Wert verleiht.

Ändern wir jetzt das Modulformat in ECMAScript-Module und versuchen es noch einmal. Dieses Mal würde utils.js so aussehen:

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

import { maxBy } from 'lodash-es';

export const max = arr => maxBy(arr);

Und index.js würde utils.js mit der ECMAScript-Modulsyntax importieren:

import { add } from './utils.js';

console.log(add(1, 2));

Mit derselben webpack-Konfiguration können wir unsere Anwendung erstellen und die Ausgabedatei öffnen. Die Größe beträgt jetzt 40 Byte mit der folgenden Ausgabe:

(()=>{"use strict";console.log(1+2)})();

Das finale Bundle enthält keine der Funktionen aus utils.js, die wir nicht verwenden, und es gibt keine Spur von lodash. Außerdem hat terser (der JavaScript-Minifier, den webpack verwendet) die add-Funktion in console.log eingefügt.

Eine berechtigte Frage wäre: Warum ist das Ausgabebundle bei Verwendung von CommonJS fast 16.000-mal größer? Natürlich ist dies ein Beispiel. In der Realität ist der Größenunterschied möglicherweise nicht so groß. Die Chancen stehen jedoch gut, dass CommonJS Ihrem Produktions-Build erheblich mehr Gewicht verleiht.

CommonJS-Module sind im Allgemeinen schwieriger zu optimieren, da sie viel dynamischer als ES-Module sind. Damit Ihr Bundler und Minifier Ihre Anwendung erfolgreich optimieren können, sollten Sie keine Abhängigkeiten von CommonJS-Modulen haben und in Ihrer gesamten Anwendung die ECMAScript-Modulsyntax verwenden.

Auch wenn Sie in index.js ECMAScript-Module verwenden, hat die Größe des Bundles Ihrer App zu leiden, wenn das verwendete Modul ein CommonJS-Modul ist.

Warum macht CommonJS Ihre App größer?

Um diese Frage zu beantworten, sehen wir uns das Verhalten der ModuleConcatenationPlugin in webpack an und sprechen dann über die statische Analysierbarkeit. Dieses Plug-in fasst den Gültigkeitsbereich aller Ihrer Module in einem einzigen Closure zusammen und ermöglicht eine schnellere Ausführungszeit Ihres Codes im Browser. Beispiel:

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// index.js
import { add } from './utils.js';
const subtract = (a, b) => a - b;

console.log(add(1, 2));

Oben sehen Sie ein ECMAScript-Modul, das wir in index.js importieren. Wir definieren auch eine subtract-Funktion. Wir können das Projekt mit derselben webpack-Konfiguration wie oben erstellen, deaktivieren aber diesmal die Minimierung:

const path = require('path');

module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    minimize: false
  },
  mode: 'production',
};

Sehen wir uns die Ausgabe an:

/******/ (() => { // webpackBootstrap
/******/    "use strict";

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

/******/ })();

In der obigen Ausgabe befinden sich alle Funktionen im selben Namespace. Um Kollisionen zu vermeiden, hat webpack die subtract-Funktion in index.js in index_subtract umbenannt.

Wenn ein Minifier den obigen Quellcode verarbeitet, geschieht Folgendes:

  • Entfernen Sie die nicht verwendeten Funktionen subtract und index_subtract.
  • Entfernen Sie alle Kommentare und redundanten Leerzeichen.
  • Den Funktionsblock der add-Funktion in den console.log-Aufruf einfügen

Entwickler bezeichnen das Entfernen nicht verwendeter Importe oft als Tree Shaking. Das Tree-Shaking war nur möglich, weil webpack statisch (zur Buildzeit) erkennen konnte, welche Symbole wir aus utils.js importieren und welche Symbole es exportiert.

Dieses Verhalten ist für ES-Module standardmäßig aktiviert, da sie im Vergleich zu CommonJS statischer analysierbar sind.

Sehen wir uns dasselbe Beispiel an, aber ändern Sie utils.js dieses Mal so, dass CommonJS anstelle von ES-Modulen verwendet wird:

// utils.js
const { maxBy } = require('lodash-es');

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Diese kleine Änderung wirkt sich erheblich auf die Ausgabe aus. Da es zu lang ist, um es auf dieser Seite einzubetten, habe ich nur einen kleinen Teil davon geteilt:

...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();

Das finale Bundle enthält einige webpack-„Laufzeit“-Elemente: Eingefügter Code, der für den Import/Export von Funktionen aus den gebündelten Modulen verantwortlich ist. Dieses Mal platzieren wir nicht alle Symbole aus utils.js und index.js im selben Namespace, sondern fordern die add-Funktion dynamisch zur Laufzeit mit __webpack_require__ an.

Das ist notwendig, weil wir mit CommonJS den Exportnamen aus einem beliebigen Ausdruck abrufen können. Der folgende Code ist beispielsweise ein absolut gültiges Konstrukt:

module.exports[localStorage.getItem(Math.random())] = () => {  };

Der Wrapper kann zum Zeitpunkt der Build-Phase nicht wissen, wie das exportierte Symbol heißt, da dafür Informationen erforderlich sind, die erst zur Laufzeit im Kontext des Browsers des Nutzers verfügbar sind.

So kann der Minifier nicht nachvollziehen, was genau index.js von seinen Abhängigkeiten verwendet, und kann sie daher nicht durch Tree-Shaking entfernen. Genau das Gleiche gilt für Module von Drittanbietern. Wenn wir ein CommonJS-Modul aus node_modules importieren, kann es von Ihrer Build-Toolchain nicht richtig optimiert werden.

Tree-Shaking mit CommonJS

CommonJS-Module sind per Definition dynamisch und daher viel schwieriger zu analysieren. Beispielsweise ist der Importort in ES-Modulen immer ein Stringliteral, im Vergleich zu CommonJS, wo es sich um einen Ausdruck handelt.

Wenn die von Ihnen verwendete Bibliothek bestimmten Konventionen für die Verwendung von CommonJS folgt, können Sie in einigen Fällen nicht verwendete Exporte zum Zeitpunkt des Builds mit einem webpack plugin von Drittanbietern entfernen. Dieses Plug-in unterstützt zwar Tree-Shaking, deckt aber nicht alle Möglichkeiten ab, wie Ihre Abhängigkeiten CommonJS verwenden könnten. Das bedeutet, dass Sie nicht dieselben Garantien wie bei ES-Modulen erhalten. Außerdem fallen im Rahmen des Build-Prozesses zusätzliche Kosten an, die über das standardmäßige webpack-Verhalten hinausgehen.

Fazit

Damit der Bundler Ihre Anwendung erfolgreich optimieren kann, sollten Sie keine Abhängigkeiten von CommonJS-Modulen haben und in Ihrer gesamten Anwendung die ECMAScript-Modulsyntax verwenden.

Hier sind einige praktische Tipps, mit denen Sie prüfen können, ob Sie auf dem optimalen Weg sind:

  • Verwenden Sie das node-resolve-Plug-in von Rollup.js und legen Sie das Flag modulesOnly fest, um anzugeben, dass Sie nur von ECMAScript-Modulen abhängig sein möchten.
  • Mit dem Paket is-esm können Sie prüfen, ob ein npm-Paket ECMAScript-Module verwendet.
  • Wenn Sie Angular verwenden, erhalten Sie standardmäßig eine Warnung, wenn Sie von Modulen abhängig sind, die nicht durch Tree Shaking entfernt werden können.