Modulsysteme sind nützlich. Sie bieten einen Weg, Code über verschiedene Anwendungen und Plattformen hinweg wiederzuverwenden. Über Im- und Exporte können sie beliebig in anderen Modulen verwendet werden. Sie sind modular, lassen sich unabhängig voneinander editieren und löschen, ohne dass damit die ganze Anwendung crasht.
ES Modules sind nicht der erste Anlauf, eine Modulfunktionalität zu JavaScript hinzuzufügen. CommonJS, ein Modulsystem für Node.js, gibt es schon seit Jahren. Es wurde entwickelt, um eben diese Lücke zu schließen. CommonJS ermöglicht genau diese Modularität. Nützliche Module können damit zu Packages zusammengefasst und über npm publiziert werden, sehr bekannte Beispiele für solche Packages sind zum Beispiel React, Lodash oder jQuery.
Für Browser gab es bis ECMAScript 6 kein Modulsystem. Mit ECMAScript 6 wurden ES Modules zur JS-Spezifikation hinzugefügt. Mittlerweile wird das Format von allen großen Browsern – Safari, Chrome, Edge und Firefox – unterstützt. Auch Node unterstützt ES Modules seit einiger Zeit.
Der Vorteil dabei: Mit ES Modules können JS-Module theoretisch so indiziert und gecached werden, dass von überall auf sie zugegriffen werden kann. Der Nutzwert ist offenkundig: Durch die Modularisierung wird theoretisch möglich, dass der Browser bei auftretenden Änderungen nur die betroffenen Dateien fetchen muss. Warum das relevant ist? Bis zu 90 Prozent des Codes einer Website stammen aus Open-Source-Packages (React, Lodash, jQuery), die bei jeder Änderung des Source-Codes erneut vom Browser geladen werden müssen.
Was steckt dahinter?
Wer in JavaScript programmiert, jongliert viel mit Variablen. Es geht eigentlich einen Großteil der Zeit darum, Variablen Werte zuzuweisen, Zahlen hinzuzufügen oder Variablen zu kombinieren und sie in einer weiteren zu speichern. Weil das einen so großen Anteil an der Arbeit mit JavaScript ausmacht, hat die Art, wie ihr diese Variablen innerhalb einer Codebase organisiert, einen nicht unerheblichen Einfluss darauf, wie gut ihr euch darin zurechtfindet, wie gut ihr coden könnt und wie einfach oder auch weniger einfach ihr euren Code warten könnt.
Scope in JavaScript
Sich jeweils nur über ein paar wenige Variablen Gedanken machen zu müssen, ist hilfreich. In JavaScript wird das über ein Konzept namens Scope erreicht. Es verhindert, dass Funktionen auf Variablen zugreifen können, die in anderen Funktionen definiert wurden. An sich ist das eine gute Sache. Wenn ihr an einer Funktion arbeitet, müsst ihr nicht darüber nachdenken, was außerhalb des Scope passiert. Der offensichtliche Nachteil: von außerhalb des Scopes, in dem eine Variable definiert ist, auf sie zuzugreifen, geht nicht. Wer das machen will, muss diese Variable in einem höheren Scope definieren, zum Beispiel als globale Variable.
Illustrieren lässt sich das ganz gut mit jQuery: Um jQuery-Plugins zu laden, mussten Entwickler sicherstellen, dass jQuery im globalen Scope war. jQuery global zu definieren, funktioniert, aber daraus ergaben sich andere Schwierigkeiten: Ihr müsst aufpassen, dass alle Script-Tags in der richtigen Reihenfolge sind – und dass niemand diese Reihenfolge durcheinanderbringt. Wenn eine Funktion jQuery nicht dort findet, wo sie erwartet, es zu finden – im globalen Scope –, wird eure Anwendung nicht weiter ausgeführt und ihr bekommt eine Fehlermeldung.
Dieses Verhalten macht es schwierig, eine Codebase zu maintainen. Code zu löschen oder Script-Tags zu entfernen, wird zum Spießrutenlauf. Ihr wisst nie, was ihr mit solchen Änderungen vielleicht kaputt macht. Das ist so, weil die Abhängigkeiten zwischen eurem Code implizit – also nicht klar ersichtlich irgendwo ausformuliert – sind. Jede Funktion kann schließlich auf alle globalen Variablen zugreifen. Deshalb weiß man nie genau, welche Funktionen wovon abhängig sind. Grundsätzlich kann Code im globalen Scope Variablen, die ebenfalls global definiert sind, verändern. Das ist nicht immer gut. Globale Variablen bieten Angriffspunkte für bösartigen Code und generell mehr Möglichkeiten für die Entstehung von Bugs.
Module und der Module-Scope
Über Module könnt ihr diese global definierten Variablen und Funktionen zu Module-Scopes gruppieren. Der Module-Scope erlaubt es, Variablen unter den Funktionen, die sich in einem gemeinsamen Module-Scope befinden, gemeinsam zu verwenden. Die Variablen innerhalb eines Module-Scopes könnt ihr – anders als die innerhalb einer Funktion – auch für andere Module verfügbar machen. In einem Module-Scope kann explizit festgelegt werden, auf welche der darin befindlichen Variablen, Klassen oder Funktionen von außerhalb zugegriffen werden darf.
Den Vorgang des Verfügbarmachens nennt man einen Export. Ein solcher Export ermöglicht es anderen Modulen, explizit zu machen, dass sie von einer Variable, Klasse oder Funktion abhängig sind. Durch diese explizite Abhängigkeit wisst ihr dann genau, welche Module ihr kaputt macht, wenn ihr Variablen, Klassen oder Funktionen verändert oder wegnehmt. So wird es einfacher, Code in kleinere Teile zu splitten, die auch unabhängig von einander funktionieren. Und die sich dann beliebig zu unterschiedlichen Applikationen kombinieren lassen.
Und so funktionieren die Module
Verwendet ihr beim Entwickeln Module, entsteht dabei ein Abhängigkeits-Graph oder -Diagramm. Über Import-Statements werden die Verbindungen zwischen verschiedenen Dependencies hergestellt. Über diese Statements weiß der Browser genau, welcher Code geladen werden muss. Ihr gebt dem Browser quasi eine Datei, über die er in den Dependency-Graphen einsteigen kann. Von dort aus kann er über weitere Import-Statements weiteren Code finden.
Die ESM-Syntax
Die Syntax zum Importieren eines Moduls sieht so aus:
import module from 'module-name'
zum Vergleich, in CommonJS sieht sie so aus:
const module = require ('module-name')
Ein Modul ist eine JS-Datei, die einen oder mehrere Values – Funktionen, Variablen oder Objekte – mittels des export
-Keywords exportiert. Zum Beispiel so:
//lowercase.js
export default str => str.toLowerCase()
Dateien sind aber nichts, was der Browser sofort nutzen kann. Vorher muss er all diese Dateien in Datenstrukturen umwandeln. Diese Datenstrukturen werden Module-Records genannt. Diese Module-Records kann der Browser verstehen – über diesen Zwischenschritt findet er heraus, was es mit einer Datei auf sich hat. In einem nächsten Schritt müssen die Module-Records zu Modulinstanzen umgewandelt werden.
Modulinstanz: Der Code und der State
Eine solche Modulinstanz setzt sich aus zwei Dingen zusammen: dem Code und dem State. Der Code ist quasi eine Reihe von Anweisungen. Eine Art Rezept dafür, wie etwas gemacht werden muss. Aber wie wenn ihr einen Kuchen backt, reicht das Rezept alleine nicht aus, damit später ein Kuchen auf der Geburtstagstafel steht. Zum Backen braucht ihr auch Zutaten und Küchengeräte. Der State gibt euch diese Zutaten. Er bezeichnet quasi die eigentlichen Values einer Variable zu jedem beliebigen Zeitpunkt. Um das zu vereinfachen, greifen wir an dieser Stelle auf ein beliebtes mentales Modell zurück: Die Variablen sind nur Bezeichnung für die „Boxen“ im Speicher, die die Values enthalten. Um das noch einmal zusammenzufassen: Die Modulinstanz kombiniert den Code (die Liste der Anweisungen) mit dem State (alle Values einer Variable). Für jedes Modul braucht ihr eine Modulinstanz.
Module werden – wie gesagt – über den Einstiegspunkt, das Import-Statement, nacheinander geladen. Bei ES Modules passiert das in drei Schritten. Der erste besteht im Finden, Downloaden und Parsen der Dateien zu sogenannten Module-Records. Der zweite besteht darin, die Boxen im Speicher zu finden, denen die exportierten Values zugeordnet werden können – noch werden sie aber nicht mit Values befüllt. Dann kommt ein Vorgang, der auch Linking genannt wird: Dabei wird veranlasst, dass beide, Exporte und Importe, auf die Boxen im Speicher zeigen. In einem dritten Schritt wird der Code ausgeführt und die Boxen mit den eigentlichen Values befüllt.
Anders als CommonJS: ES Modules sind asynchron
ES Modules gelten als asynchron, weil dieser Prozess in eben diesen drei unterschiedlichen Phasen passiert: Laden, Instanzieren und Evaluieren – und die drei Phasen voneinander getrennt ausgeführt werden können. In CommonJS werden im Unterschied dazu Module und deren Dependencies gleichzeitig geladen, instanziert und evaluiert. Auch bei ES Modulen kann das theoretisch synchron ablaufen, abhängig davon, wer den ersten Schritt – Finden, Laden und Parsen der Dateien – durchführt. Nicht alle Aufgaben dieser Phase werden nämlich von der ES-Modulspezifikation kontrolliert. Die ES-Modulspezifikation legt zwar fest, wie Dateien zu Module-Records geparst werden und weiß, wie diese Module-Records instanziert und evaluiert werden. Sie weiß allerdings nicht, wie die Dateien überhaupt gefunden werden. Das macht der Loader. Und der ist in einer anderen Spezifikation definiert. Im Fall von Browsern ist das die HTML-Spezifikation. Der Loader kontrolliert genau, wie die Module geladen werden – er ruft die ES-Modul-Methoden Parse.Module, Module.Instantiate und Module.Evaluate auf. Zuerst muss er aber die Datei mit dem Entry-Point finden. Über einen script
-Tag gebt ihr ihm im HTML einen Hinweis, wo diese Dateien zu finden sind:
script src ="main.js" type="module"
Alle weiteren Module mit direkten Dependencies zur main.js findet der Loader über die import-Statements. Die sehen zum Beispiel so aus:
import {count} from "./counter.js"
Der Module-Specifier – im Beispiel grün – sagt dem Loader, wo er das nächste Modul findet. Noch akzeptieren Browser allerdings ausschließlich URL als Module-Specifier. Bevor ES Modules wirklich zu einer Performanz-Zunahme von JavaScript im Browser führen, wird es wohl noch eine Weile dauern. Die Unterstützung für das Sharing von Code potenziell unterschiedlichen Ursprungs in Web-Packaging-Formaten steht zum Beispiel noch aus, Security-Fragen in diesem Zusammenhang sind neben vielen anderen Punkten ebenfalls noch ungeklärt. Spannende zukunftsweisende Projekte in Verbindung mit ESM sind zum Beispiel Deno.JS, Pika oder Snowpack. Snowpack.js etwa basiert auf der Prämisse, dass ES Modules einen Weg bieten, beim Entwickeln von Web-Apps ohne Bundling-Tools wie zum Beispiel Webpack oder Rollup auszukommen.
Weiterlesen könnt ihr in diesem informativen, sehr anschaulich – und niedlich – bebilderten Blogpost von Lin Clark für den Mozilla-Blog.