ES Modules in Node.js

ES-Modules in Node.js: Der Status quo
Keine Kommentare

Fast jede Programmiersprache stellt die Möglichkeit bereit, den Code, aus dem ein Programm besteht, auf mehrere Dateien aufzuteilen. In C und C++ dient dazu die #include-Direktive, in Java und Python gibt es das import-Schlüsselwort. JavaScript war bislang eine der wenigen Ausnahmen, doch der neue JavaScript-Standard (ECMAScript 6) ändert dies durch die Einführung sogenannter ECMAScript Modules. Alle bekannten Browser unterstützen diesen neuen Standard bereits — nur Node.js scheint hinterherzuhängen. Woran liegt das?

Die neuen ECMAScript-(ES-)Module sind nicht vollständig kompatibel mit bisherigen Sprachversionen, daher muss die verwendete JavaScript Engine bei jeder Datei wissen, ob es sich um „alten“ JavaScript-Code oder ein „neues“ Modul handelt. Beispielsweise war der von vielen Programmierern bevorzugte Strict Mode, der in ECMAScript 5 eingeführt wurde, erst einmal optional und musste explizit aktiviert werden; in ES-Modulen ist er immer aktiv. So kann der folgende Ausschnitt syntaktisch sowohl als herkömmlicher JavaScript-Code als auch als ES-Modul interpretiert werden:

a = 5;

Als klassisches Node.js-Modul ist dies äquivalent zu global.a = 5, da die Variable a nicht deklariert wurde und der Strict Mode nicht explizit aktiviert wurde, also wird a als globale Variable behandelt. Versucht man, dieselbe Datei als ES-Modul zu laden, erhält man den Fehler „ReferenceError: a is not defined“, da nicht deklarierte Variablen im Strict Mode nicht verwendet werden dürfen.

Browser lösen das Problem der Unterscheidung durch eine Erweiterung des <script>-Tags: Skripte ohne type-Attribut oder mit dem Attribut type=“text/javascript“ werden weiterhin im herkömmlichen Modus ausgeführt, während Skripte mit dem Attribut type=“module“ als Modul verarbeitet werden. Dank dieser einfachen Trennung unterstützen mittlerweile alle verbreiteten Browser die neuen Module. Deutlich schwieriger gestaltet sich die Umsetzung in Node.js: Das 2009 erfundene Framework für JavaScript-Anwendungen verwendete bisher den CommonJS-Standard für Module, der auf der require-Funktion basiert. Diese Funktion kann jederzeit genutzt werden, um ein anderes Modul anhand seines Pfads relativ zum aktuell ausgeführten Modul zu laden. Auch die neuen ES-Module werden anhand ihres Pfads geladen, doch woher sollte Node.js wissen, ob es sich bei dem zu ladenden Modul um ein herkömmliches CommonJS- oder ein ES-Modul handelt? Eine Unterscheidung basierend auf der Syntax genügt nicht, da auch ES-Module, die keine neuen Schlüsselwörter verwenden, nicht kompatibel mit CommonJS-Modulen sind.

ECMAScript 6 sieht außerdem vor, dass Module anhand einer URL geladen werden können, während sich CommonJS auf relative und absolute Dateipfade beschränkt. Diese Neuerung macht das Laden nicht nur komplizierter, sondern auch potenziell langsam, da URLs nicht auf lokale Dateien zeigen müssen. Gerade im Browser werden Skripte und Module üblicherweise über das Netzwerprotokoll HTTP geladen.

CommonJS erlaubt das Laden von Modulen über die require-Funktion, die das geladene Modul zurückgibt. Ein CommonJS-Modul könnte beispielsweise folgendermaßen beginnen:

const { readFile } = require('fs');
const myModule = require('./my-module');

Dies kommt in ECMAScript 6 nicht in Frage, da die Ladezeiten von Modulen über HTTP während des Aufrufs von require() die gesamte Ausführung unverhältnismäßig lang blockieren würde. Stattdessen stellen ES-Module zwei Arten bereit, andere Module zu laden. In den meisten Fällen bietet sich die import-Anweisung an:

import { readFile } from 'fs';
import myModule from './my-module';

Diese Anweisungen verzögern zwar notwendigerweise die Ausführung des Moduls, bis fs und ./my-module geladen wurden, aber sie blockieren die Ausführung anderer Module nicht. Komplizierter ist es, wenn Module dynamisch geladen werden müssen. Was in CommonJS-Modulen trivial erscheint, ist asynchron schwieriger:

if (condition) {
  myOtherModule = require('./my-other-module');
}

ECMAScript möchte dieses Problem durch eine funktionsartige Verwendung des import-Schlüsselworts lösen, die Module asynchron lädt und bei jedem Aufruf ein Promise-Objekt zurückgibt. Ein Nachteil ist, dass der Programmierer jetzt auch für die Fehlerbehandlung verantwortlich ist, da der Fehler nicht wie im synchronen Fall automatisch an den Aufrufer weitergereicht wird.

if (condition) {
  import('./my-other-module.js')
  .then(myOtherModule => {
    // Das Modul wurde erfolgreich geladen und kann hier
    // verwendet werden.
  })
  .catch(err => {
    // Es gab einen Fehler, der hier behandelt werden muss.
    console.error(err);
  });
}

Wenn die Funktion, in der das Modul geladen werden soll, mit dem Schlüsselwort async deklariert wurde, ist die Verwendung von import() dank der in ECMAScript 6 eingeführten await-Anweisung übersichtlicher, und die Fehlerbehandlung wird wie bei synchroner Ausführung an den Aufrufer übergeben:

if (condition) {
  myOtherModule = await import('./my-other-module');
}

Die Verwendung von import als Funktion ist zwar kein Bestandteil von ECMAScript 6, aber ein sogenanntes Stage 3 Proposal und wird als solches vermutlich in einer der nächsten JavaScript-Versionen standardisiert werden. Sie wird zudem bereits in vielen Browsern wie Firefox, Chrome und Safari sowie in Node.js unterstützt.

Verwendung in Node.js

Die Schwierigkeit der Unterscheidung von CommonJS- und ES-Modulen hat dazu geführt, dass für ES-Module unter Node.js eine neue Dateinamenserweiterung eingeführt wurde: Dateien, deren Name mit .mjs endet, können bereits von Node.js als ES-Module geladen werden, wenn die Option --experimental-modules gesetzt wurde. Speichert man den folgenden Code als testmodul.mjs ab, kann er seit der im September 2017 veröffentlichten Version Node.js 8.5.0 durch den Befehl node --experimental-modules testmodul.mjs ausgeführt werden:

export function helloWorld(name) {
  console.log(`Hallo, ${name}!`);
}

helloWorld('entwickler.de');

Node.js 12 erweitert die Unterstützung für ES-Module. Wichtig ist, dass nun die package.json-Datei zu Hilfe genommen wird, die Teil eines jeden Pakets ist und Informationen wie beispielsweise den eindeutigen Namen des Pakets enthält. Das genutzte JSON-Format wird nun um eine neue Eigenschaft type erweitert. Diese kann wahlweise auf commonjs oder module gesetzt werden, um festzulegen, in welchem Modus die im Paket enthaltenen JavaScript-Dateien standardmäßig geladen werden sollen. Die folgende Konfiguration spezifiziert ein Paket beispiel-paket, das mindestens das ES-Modul index.js enthalten muss:

{
  "name": "beispiel-paket",
  "type": "module",
  "main": "index.js"
}

Das Feld „main“ gibt wie üblich an, welche Datei als Einstiegspunkt dienen soll; das Modul index.js könnte beispielsweise folgendermaßen aussehen:

import { userInfo } from 'os';

export function greet() {
  return `Hello ${userInfo().username}!`;
}

Dieses Modul kann nun von anderen Dateien geladen werden. Üblicherweise befinden sich dabei Pakete in jeweils einem eigenen Ordner innerhalb des node_modules-Verzeichnisses. Um das soeben erstellte Paket laden zu können, verwenden wir die folgende Verzeichnisstruktur und eine neue Datei main.js:

- main.js
+ node_modules
  + beispiel-paket
    - package.json
    - index.js

Bei der Datei main.js kann es sich entweder um ein herkömmliches CommonJS- oder ein neues ECMAScript-Modul handeln. In beiden Fällen kann das beispiel-paket nicht über den üblichen Aufruf von require() geladen werden, da ECMAScript-Module stets asynchron geladen werden müssen. CommonJS-Module müssen ES-Module folglich über die auch dort verfügbare import-Funktion laden:

import('beispiel-paket')
.then(paket => {
  console.log(paket.greet());
})
.catch(err => {
  console.error(err);
});

Dies hat den Nachteil, dass CommonJS-Module nicht wie üblich gleich zu Beginn auf andere Module oder Pakete zugreifen können, sondern dies erst im Nachhinein und asynchron geschehen kann. Ausgeführt wird das Skript wie oben beschrieben: node --experimental-modules main.js. Noch einfacher ist es, wenn der Einstiegspunkt selbst auch ein ES-Modul ist. Benennt man main.js in main.mjs um, kann die import-Anweisung verwendet werden:

import { greet } from 'beispiel-paket';

console.log(greet());

Es ist also möglich, sowohl CommonJS- als auch ECMAScript-Module in einer Anwendung zu verwenden, es ist allerdings mit Komplikationen verbunden. CommonJS-Module müssen wissen, ob es sich bei dem zu ladenden Modul um ein CommonJS- oder ES-Modul handelt, und können ES-Module nur asynchron laden. Dies trifft auch auf das Laden von Paketen zu, die beispielsweise über npm installiert wurden. Eingebaute Module wie etwa fs und crypto können auf beiden Wegen geladen werden.

Unterschiede in Node.js

Abgesehen von der Problematik des asynchronen Ladens von Abhängigkeiten gibt es weitere Unterschiede zum bisherigen Modulsystem in Node.js zu beachten. Insbesondere sind Node.js-spezifische Features wie die Variablen __dirname, __filename, export und module in ES-Modulen nicht mehr verfügbar. __dirname und __filename können bei Bedarf aus dem neuen import.meta-Objekt rekonstruiert werden:

import { fileURLToPath } from 'url';
import { dirname} from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

Die Variablen module und exports entfallen ersatzlos und somit auch Eigenschaften wie module.filename, module.id und module.parent. Ebenso sind require() und require.main nicht mehr verfügbar.

Während zirkuläre Abhängigkeiten in CommonJS durch das Cachen der module.exports-Objekte der jeweiligen Module gelöst wurden, verwendet ECMAScript 6 sogenannte Bindings. Einfach ausgedrückt exportieren und importieren ES-Module keine Werte, sondern lediglich Verweise auf Werte. Module, die einen solchen Verweis importieren, können zwar auf den Wert zugreifen, ihn jedoch nicht ändern. Das Modul, das den Verweis exportiert hat, kann dem Verweis einen neuen Wert zuweisen, der von dem Zeitpunkt an von anderen Modulen verwendet wird, die den Verweis importiert haben. Dies ist ein wesentlicher Unterschied zum bisherigen Konzept, das es erlaubte, dem module.exports-Objekt eines CommonJS-Moduls zu jedem Zeitpunkt Eigenschaften zuzuweisen, wobei diese Änderungen in anderen Modulen nur bedingt reflektiert wurden.

iJS React Cheat Sheet

Free: React Cheat Sheet

You want to improve your knowledge in React or just need some kind of memory aid? We have the right thing for you: the iJS React Cheat Sheet (written by Joel Lord). Now you will always know how to React!

Gemäß der ECMAScript-Spezifikation vervollständigt import den Dateipfad standardmäßig nicht um die Dateinamenserweiterung, wie Node.js es bisher für CommonJS-Module getan hat; daher muss diese explizit angegeben werden. Ebenso ändert sich das Verhalten, wenn es sich beim angegebenen Pfad um ein Verzeichnis handelt: import './verzeichnis' sucht nicht – wie bislang in Node.js üblich – nach einer index.js-Datei im angegebenen Ordner, sondern schlägt fehl. Beides lässt sich ändern, indem die experimentelle Option --es-module-specifier-resolution=node übergeben wird.

Fazit und Ausblick

In der kürzlich veröffentlichten Version Node.js 12.1.0 muss die Verwendung von ECMAScript-Modulen noch explizit durch die Option --experimental-modules aktiviert werden, da es sich um ein experimentelles Feature handelt. Ziel der Entwickler ist jedoch, diese Funktionalität zu finalisieren und ES-Module ohne explizite Aktivierung zu unterstützen, bevor Node.js 12 zur neuen Long-Term-Support-Version wird, was voraussichtlich im Oktober 2019 geschehen wird.

Der Umstieg von CommonJS auf ECMAScript-Module wird durch die Vielzahl existierender CommonJS-Module erschwert. Einzelne Pakete können nicht zu ES-Modulen wechseln, ohne Inkompatibilität mit bestehenden Anwendungen und Paketen zu riskieren, die die jeweiligen Pakete mittels require() laden. Tools wie Babel, die die neuere Syntax in Code übersetzen, der mit älteren Umgebungen kompatibel ist, können den Übergang erleichtern. Neuere Frameworks wie Deno kehren den vielfältigen Modulsystemen der letzten Jahre den Rücken zu und setzen ausschließlich auf ECMAScript-Module. Für die Weiterentwicklung von JavaScript als Programmiersprache ist die Einführung standardisierter Module ein wichtiger Schritt und ebnet den Weg für zukünftige Verbesserungen.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -