JavaScript Module Systems Showdown: CommonJS vs AMD vs ES2015

Mit zunehmender Verbreitung der JavaScript-Entwicklung werden Namespaces und Abhängigkeiten immer schwieriger zu handhaben. Verschiedene Lösungen wurden entwickelt, um mit diesem Problem in Form von Modulsystemen umzugehen. In diesem Beitrag werden wir die verschiedenen Lösungen untersuchen, die derzeit von Entwicklern eingesetzt werden, und die Probleme, die sie zu lösen versuchen. Lesen Sie weiter!

Einführung: Warum werden JavaScript-Module benötigt?

Wenn Sie mit anderen Entwicklungsplattformen vertraut sind, haben Sie wahrscheinlich eine Vorstellung von den Konzepten der Kapselung und Abhängigkeit. Verschiedene Teile der Software werden in der Regel isoliert entwickelt, bis eine Anforderung von einem bereits existierenden Teil der Software erfüllt werden muss. In dem Moment, in dem die andere Software in das Projekt eingebracht wird, wird eine Abhängigkeit zwischen ihr und dem neuen Code hergestellt. Da diese Teile der Software zusammenarbeiten müssen, ist es wichtig, dass keine Konflikte zwischen ihnen entstehen. Das mag trivial klingen, aber ohne eine Art von Kapselung ist es nur eine Frage der Zeit, bis zwei Module miteinander in Konflikt geraten. Dies ist einer der Gründe, warum Elemente in C-Bibliotheken in der Regel ein Präfix tragen:

#ifndef MYLIB_INIT_H#define MYLIB_INIT_Henum mylib_init_code { mylib_init_code_success, mylib_init_code_error};enum mylib_init_code mylib_init(void);// (...)#endif //MYLIB_INIT_H

Eine Kapselung ist unerlässlich, um Konflikte zu vermeiden und die Entwicklung zu erleichtern.

Wenn es um Abhängigkeiten geht, sind sie in der traditionellen clientseitigen JavaScript-Entwicklung implizit. Mit anderen Worten, es ist die Aufgabe des Entwicklers sicherzustellen, dass die Abhängigkeiten zum Zeitpunkt der Ausführung eines Codeblocks erfüllt sind. Der Entwickler muss auch sicherstellen, dass die Abhängigkeiten in der richtigen Reihenfolge erfüllt werden (eine Anforderung bestimmter Bibliotheken).

Das folgende Beispiel ist Teil der Backbone.js-Beispiele. Skripte werden manuell in der richtigen Reihenfolge geladen:

<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8"> <title>Backbone.js Todos</title> <link rel="stylesheet" href="todos.css"/> </head> <body> <script src="../../test/vendor/json2.js"></script> <script src="../../test/vendor/jquery.js"></script> <script src="../../test/vendor/underscore.js"></script> <script src="../../backbone.js"></script> <script src="../backbone.localStorage.js"></script> <script src="todos.js"></script> </body> <!-- (...) --></html>

Wenn die JavaScript-Entwicklung immer komplexer wird, kann das Abhängigkeitsmanagement mühsam werden. Auch das Refactoring ist beeinträchtigt: Wo sollen neuere Abhängigkeiten eingefügt werden, um die richtige Reihenfolge der Ladekette beizubehalten?

JavaScript-Modulsysteme versuchen, diese und andere Probleme zu lösen. Sie wurden aus der Notwendigkeit heraus geboren, um der ständig wachsenden JavaScript-Landschaft gerecht zu werden. Schauen wir uns an, was die verschiedenen Lösungen zu bieten haben.

Eine Ad-hoc-Lösung: Das Revealing Module Pattern

Die meisten Modulsysteme sind relativ neu. Bevor es sie gab, wurde ein bestimmtes Programmiermuster in immer mehr JavaScript-Code verwendet: das Revealing Module Pattern.

var myRevealingModule = (function () { var privateVar = "Ben Cherry", publicVar = "Hey there!"; function privateFunction() { console.log( "Name:" + privateVar ); } function publicSetName( strName ) { privateVar = strName; } function publicGetName() { privateFunction(); } // Reveal public pointers to // private functions and properties return { setName: publicSetName, greeting: publicVar, getName: publicGetName };})();myRevealingModule.setName( "Paul Kinlan" );

Dieses Beispiel stammt aus dem Buch JavaScript Design Patterns von Addy Osmani.

JavaScript-Scopes (zumindest bis zum Erscheinen von let in ES2015) funktionieren auf Funktionsebene. Mit anderen Worten: Was auch immer an Bindung innerhalb einer Funktion deklariert wird, kann nicht aus ihrem Geltungsbereich entkommen. Aus diesem Grund stützt sich das revealing module pattern auf Funktionen, um private Inhalte zu kapseln (wie viele andere JavaScript-Muster).

Im obigen Beispiel werden öffentliche Symbole im zurückgegebenen Wörterbuch offengelegt. Alle anderen Deklarationen sind durch den sie einschließenden Funktionsbereich geschützt. Es ist nicht notwendig, var und einen direkten Aufruf der Funktion, die den privaten Bereich umschließt, zu verwenden; eine benannte Funktion kann auch für Module verwendet werden.

Dieses Muster wird schon seit einiger Zeit in JavaScript-Projekten verwendet und geht recht gut mit dem Problem der Kapselung um. Mit dem Problem der Abhängigkeiten hat es nicht viel zu tun. Richtige Modulsysteme versuchen, auch dieses Problem zu lösen. Eine weitere Einschränkung liegt in der Tatsache, dass das Einbinden von anderen Modulen nicht im selben Quelltext möglich ist (es sei denn, man verwendet eval).

Pros

  • Einfach genug, um überall implementiert zu werden (keine Bibliotheken, keine Sprachunterstützung erforderlich).
  • Mehrere Module können in einer einzigen Datei definiert werden.

Nachteile

  • Keine Möglichkeit, Module programmatisch zu importieren (außer durch Verwendung von eval).
  • Abhängigkeiten müssen manuell gehandhabt werden.
  • Asynchrones Laden von Modulen ist nicht möglich.
  • Zirkuläre Abhängigkeiten können lästig sein.
  • Schwer zu analysieren für statische Code-Analysatoren.

CommonJS

CommonJS ist ein Projekt, das darauf abzielt, eine Reihe von Spezifikationen zu definieren, die bei der Entwicklung von serverseitigen JavaScript-Anwendungen helfen. Einer der Bereiche, die das CommonJS-Team anspricht, sind Module. Die Node.js-Entwickler wollten ursprünglich der CommonJS-Spezifikation folgen, haben sich dann aber dagegen entschieden. Wenn es um Module geht, ist die Node.js-Implementierung stark davon beeinflusst:

// In circle.jsconst PI = Math.PI;exports.area = (r) => PI * r * r;exports.circumference = (r) => 2 * PI * r;// In some fileconst circle = require('./circle.js');console.log( `The area of a circle of radius 4 is ${circle.area(4)}`);

Als ich eines Abends bei Joyent erwähnte, dass ich ein wenig frustriert über eine lächerliche Anfrage für eine Funktion war, von der ich wusste, dass sie eine schreckliche Idee war, sagte er zu mir: „Vergiss CommonJS. Es ist tot. Wir sind serverseitiges JavaScript.“ – NPM-Schöpfer Isaac Z. Schlueter zitiert Node.js-Schöpfer Ryan Dahl

Es gibt Abstraktionen über dem Modulsystem von Node.js in Form von Bibliotheken, die die Lücke zwischen den Modulen von Node.js und CommonJS schließen. Für die Zwecke dieses Beitrags werden wir nur die grundlegenden Funktionen zeigen, die größtenteils gleich sind.

Sowohl in den Modulen von Node als auch in denen von CommonJS gibt es im Wesentlichen zwei Elemente, die mit dem Modulsystem interagieren: require und exports. require ist eine Funktion, die verwendet werden kann, um Symbole aus einem anderen Modul in den aktuellen Bereich zu importieren. Der Parameter, der an require übergeben wird, ist die ID des Moduls. In der Node-Implementierung ist dies der Name des Moduls im Verzeichnis node_modules (oder, wenn es sich nicht in diesem Verzeichnis befindet, der Pfad dorthin). exports ist ein spezielles Objekt: alles, was in ihm steht, wird als öffentliches Element exportiert. Die Namen der Felder werden beibehalten. Ein besonderer Unterschied zwischen Node und CommonJS ergibt sich aus der Form des module.exports-Objekts. In Node ist module.exports das eigentliche spezielle Objekt, das exportiert wird, während exports nur eine Variable ist, die standardmäßig an module.exports gebunden wird. CommonJS hingegen hat kein module.exports-Objekt. Die praktische Auswirkung ist, dass es in Node nicht möglich ist, ein vollständig vorkonstruiertes Objekt zu exportieren, ohne module.exports zu durchlaufen:

// This won't work, replacing exports entirely breaks the binding to// modules.exports.exports = (width) => { return { area: () => width * width };}// This works as expected.module.exports = (width) => { return { area: () => width * width };}

CommonJS-Module wurden mit Blick auf die Serverentwicklung entworfen. Natürlich ist die API synchron. Mit anderen Worten, die Module werden in dem Moment und in der Reihenfolge geladen, in der sie in einer Quelldatei benötigt werden.

Pros

  • Einfach: ein Entwickler kann das Konzept verstehen, ohne in die Dokumentation schauen zu müssen.
  • Abhängigkeitsmanagement ist integriert: Module benötigen andere Module und werden in der benötigten Reihenfolge geladen.
  • require kann überall aufgerufen werden: Module können programmatisch geladen werden.
  • Zirkuläre Abhängigkeiten werden unterstützt.

Gegenargumente

  • Synchrone API macht es für bestimmte Anwendungen (clientseitig) ungeeignet.
  • Eine Datei pro Modul.
  • Browser benötigen eine Loader-Bibliothek oder Transpilierung.
  • Keine Konstruktorfunktion für Module (Node unterstützt dies jedoch).
  • Schwer zu analysieren für statische Code-Analysatoren.

Implementierungen

Wir haben bereits über eine Implementierung (in Teilform) gesprochen: Node.js.

Node.js JavaScript-Module

Für den Client gibt es derzeit zwei beliebte Optionen: webpack und browserify. Browserify wurde explizit entwickelt, um Node-ähnliche Moduldefinitionen zu parsen (viele Node-Pakete funktionieren out-of-the-box damit!) und den eigenen Code sowie den Code dieser Module in einer einzigen Datei zu bündeln, die alle Abhängigkeiten enthält. Webpack hingegen wurde entwickelt, um komplexe Pipelines von Quelltransformationen vor der Veröffentlichung zu erstellen. Dazu gehört auch die Bündelung von CommonJS-Modulen.

Asynchronous Module Definition (AMD)

AMD entstand aus einer Gruppe von Entwicklern, die mit der von CommonJS eingeschlagenen Richtung unzufrieden waren. In der Tat wurde AMD schon früh in der Entwicklung von CommonJS abgespalten. Der Hauptunterschied zwischen AMD und CommonJS liegt in der Unterstützung für asynchrones Laden von Modulen.

//Calling define with a dependency array and a factory functiondefine(, function (dep1, dep2) { //Define the module value by returning a value. return function () {};});// Or:define(function (require) { var dep1 = require('dep1'), dep2 = require('dep2'); return function () {};});

Asynchrones Laden wird durch die Verwendung des traditionellen Closure-Idioms von JavaScript ermöglicht: Eine Funktion wird aufgerufen, wenn die angeforderten Module fertig geladen sind. Moduldefinitionen und der Import eines Moduls werden von derselben Funktion ausgeführt: Bei der Definition eines Moduls werden seine Abhängigkeiten explizit gemacht. Daher kann ein AMD-Lader zur Laufzeit ein vollständiges Bild des Abhängigkeitsgraphen für ein bestimmtes Projekt haben. Bibliotheken, die beim Laden nicht voneinander abhängig sind, können somit gleichzeitig geladen werden. Dies ist besonders wichtig für Browser, bei denen die Startzeiten für ein gutes Benutzererlebnis entscheidend sind.

Pros

  • Asynchrones Laden (bessere Startzeiten).
  • Zirkuläre Abhängigkeiten werden unterstützt.
  • Kompatibilität für require und exports.
  • Abhängigkeitsmanagement vollständig integriert.
  • Module können bei Bedarf in mehrere Dateien aufgeteilt werden.
  • Konstruktorfunktionen werden unterstützt.
  • Plugin-Unterstützung (eigene Ladeschritte).

Nachteile

  • Syntaktisch etwas komplexer.
  • Loader-Bibliotheken sind erforderlich, es sei denn, sie werden transpiliert.
  • Schwer zu analysieren für statische Code-Analysatoren.

Implementierungen

Die beliebtesten Implementierungen von AMD sind derzeit require.js und Dojo.

Require.js für JavaScript-Module

Die Verwendung von require.js ist ziemlich einfach: Binden Sie die Bibliothek in Ihre HTML-Datei ein und verwenden Sie das Attribut data-main, um require.js mitzuteilen, welches Modul zuerst geladen werden soll. Dojo hat ein ähnliches Setup.

ES2015 Module

Glücklicherweise hat das ECMA-Team, das hinter der Standardisierung von JavaScript steht, beschlossen, das Problem der Module anzugehen. Das Ergebnis ist in der neuesten Version des JavaScript-Standards zu sehen: ECMAScript 2015 (früher bekannt als ECMAScript 6). Das Ergebnis ist syntaktisch ansprechend und sowohl mit synchronen als auch asynchronen Arbeitsweisen kompatibel.

//------ lib.js ------export const sqrt = Math.sqrt;export function square(x) { return x * x;}export function diag(x, y) { return sqrt(square(x) + square(y));}//------ main.js ------import { square, diag } from 'lib';console.log(square(11)); // 121console.log(diag(4, 3)); // 5

Beispiel aus dem Blog von Axel Rauschmayer

Mit der Direktive import können Module in den Namespace gebracht werden. Diese Direktive ist im Gegensatz zu require und define nicht dynamisch (d.h. sie kann nicht an beliebiger Stelle aufgerufen werden). Die export-Direktive hingegen kann verwendet werden, um Elemente explizit öffentlich zu machen.

Die statische Natur der import– und export-Direktive ermöglicht es statischen Analysatoren, einen vollständigen Baum von Abhängigkeiten aufzubauen, ohne dass der Code ausgeführt wird. ES2015 unterstützt das dynamische Laden von Modulen:

System.import('some_module') .then(some_module => { // Use some_module }) .catch(error => { // ... });

In Wahrheit spezifiziert ES2015 nur die Syntax für die dynamischen und statischen Modullader. In der Praxis müssen ES2015-Implementierungen nach dem Parsen dieser Direktiven nichts tun. Modullader wie System.js sind weiterhin erforderlich, bis die nächste ECMAScript-Spezifikation veröffentlicht wird.

Diese Lösung ist in die Sprache integriert und ermöglicht Laufzeiten die Auswahl der besten Ladestrategie für Module. Mit anderen Worten, wenn asynchrones Laden Vorteile bringt, kann es von der Laufzeit verwendet werden.

Pros

  • Synchrones und asynchrones Laden wird unterstützt.
  • Syntaktisch einfach.
  • Unterstützung für statische Analysewerkzeuge.
  • Integriert in die Sprache (letztendlich überall unterstützt, keine Notwendigkeit für Bibliotheken).
  • Unterstützung von zirkulären Abhängigkeiten.

Kontra

  • Noch immer nicht überall unterstützt.

Implementierungen

Leider unterstützt keine der großen JavaScript-Laufzeiten ES2015-Module in ihren aktuellen stabilen Zweigen. Das bedeutet keine Unterstützung für Firefox, Chrome oder Node.js. Glücklicherweise unterstützen viele Transpiler Module und ein Polyfill ist ebenfalls verfügbar. Derzeit kann die ES2015-Voreinstellung für Babel problemlos mit Modulen umgehen.

Babel for JavaScript Modules

The All-in-One Solution: System.js

Vielleicht möchten Sie sich von altem Code lösen und ein Modulsystem verwenden. Oder Sie möchten sicherstellen, dass die von Ihnen gewählte Lösung immer noch funktioniert. Hier kommt System.js ins Spiel: ein universeller Modullader, der CommonJS-, AMD- und ES2015-Module unterstützt. Er kann mit Transpilern wie Babel oder Traceur zusammenarbeiten und unterstützt Node und IE8+ Umgebungen. Um es zu verwenden, müssen Sie lediglich System.js in Ihren Code laden und dann auf Ihre Basis-URL verweisen:

 <script src="system.js"></script> <script> // set our baseURL reference path System.config({ baseURL: '/app', // or 'traceur' or 'typescript' transpiler: 'babel', // or traceurOptions or typescriptOptions babelOptions: { } }); // loads /app/main.js System.import('main.js'); </script>

Da System.js die gesamte Arbeit on-the-fly erledigt, sollte die Verwendung von ES2015-Modulen im Produktionsmodus im Allgemeinen einem Transpiler während des Build-Schritts überlassen werden. Wenn Sie sich nicht im Produktionsmodus befinden, kann System.js den Transpiler für Sie aufrufen, was einen nahtlosen Übergang zwischen Produktions- und Debugging-Umgebung ermöglicht.

Außerdem: Was wir bei Auth0 verwenden

Bei Auth0 verwenden wir viel JavaScript. Für unseren serverseitigen Code verwenden wir CommonJS-ähnliche Node.js-Module. Für bestimmten Client-seitigen Code bevorzugen wir AMD. Für unsere React-basierte Passwordless Lock-Bibliothek haben wir uns für ES2015-Module entschieden.

Like what you see? Melden Sie sich an und nutzen Sie Auth0 noch heute in Ihren Projekten.

Sind Sie ein Entwickler und mögen Sie unseren Code? Wenn ja, dann bewerben Sie sich jetzt für eine technische Stelle. Wir haben ein großartiges Team!

Abschluss

Die Erstellung von Modulen und der Umgang mit Abhängigkeiten war in der Vergangenheit mühsam. Neuere Lösungen, in Form von Bibliotheken oder ES2015-Modulen, haben die meisten dieser Probleme beseitigt. Wenn Sie ein neues Modul oder Projekt starten wollen, ist ES2015 der richtige Weg. Es wird immer unterstützt werden und die aktuelle Unterstützung durch Transpiler und Polyfills ist hervorragend. Wenn Sie hingegen lieber einfachen ES5-Code verwenden möchten, bleibt die übliche Aufteilung zwischen AMD für den Client und CommonJS/Node für den Server die übliche Wahl. Vergessen Sie nicht, uns Ihre Gedanken im Kommentarbereich unten zu hinterlassen. Hack weiter!

Schreibe einen Kommentar