Das Wissensportal für IT-Professionals. Entdecke die Tiefe und Breite unseres IT-Contents in exklusiven Themenchannels und Magazinmarken.

SIGS DATACOM GmbH

Lindlaustraße 2c, 53842 Troisdorf

Tel: +49 (0)2241/2341-100

kundenservice@sigs-datacom.de
Wer in die Entwicklung von JavaScript-Anwendungen einsteigen möchte, muss sich nicht nur mit der Sprache vertraut machen, sondern steht auch einem komplexen Ökosystem gegenüber. Dieser Artikel soll eine Orientierung bieten.
Author Image
Nils Hartmann

Softwareentwickler und -architekt


  • 27.01.2023
  • Lesezeit: 17 Minuten
  • 43 Views

Der Einsatz von JavaScript beschränkt sich schon lange nicht mehr auf einzelne Skript-Schnipsel, die auf einer Webseite eingebunden werden und dort kleinere Aufgaben wie die Validierung von Eingabefeldern oder Animationen realisieren. Mittlerweile werden komplexe Anwendungen gebaut, die vollständig im Browser laufen und in Sachen Benutzungskomfort und Verhalten ihren Desktop-Pendants in nichts nachstehen sollen. Prominente Anwendungen wie Office365, Miro oder das Design-Tool Figma sind Beispiele dafür.
Genau wie bei komplexen Server-seitigen (Java-)Anwendungen bringt die Entwicklung solcher Singe-Page-Anwendungen einige Anforderungen an die Entwicklung mit sich. Themen wie Wartbarkeit, Testbarkeit oder auch Langlebigkeit werden zu wichtigen Themen auch in der Client-Entwicklung. Im Folgenden werden Werkzeuge vorgestellt, die typischerweise in der Entwicklung mit JavaScript eine Rolle spielen.

Die Sprache ECMAScript

Zunächst aber zur Sprache selbst. Ähnlich wie in Java gibt es mehrere Versionen der Sprachspezifikation (die ECMAScript oder kurz ES heißt). Von den meisten (auch älteren) Browsern implementiert ist die Version 5. Das bedeutet, wenn eine Anwendung nach dieser Spezifikation implementiert ist, ist die Chance sehr hoch, dass sie in allen Browsern auch funktioniert.

ECMAScript 5 wurde im Jahre 2009 veröffentlicht, danach gab es erst 2015 die nächste Version, die aber zumindest vom Internet Explorer nicht vollständig implementiert wird. Neben den inhaltlichen Änderungen in dieser Version gibt es seitdem auch ein neues Versions- und Releasekonzept. Seit 2015 gibt es einmal pro Jahr eine neue ECMAScript-Version, die nun immer nach ihrem Erscheinungsjahr benannt ist. Die Änderungen in einer Version sind dabei meistens überschaubar, sodass sie von den JavaScript-Herstellern schnell umgesetzt und in den Browsern zur Verfügung gestellt werden können.

Der Sprung von ECMAScript 5 auf ECMAScript 2015 stellte zuvor noch einmal eine sehr große Änderung der Sprache dar, weswegen die Versionen ab 2015 auch als „modernes Java-Script" bezeichnet werden. So haben beispielsweise Klassen und Promises Einzug in die Sprache erhalten. Mit der Einführung von Block-Scoping und Arrow-Funktionen wurde auf einige der gravierendsten Unzulänglichkeiten in der Sprache reagiert. Das berüchtigte und schwer verständliche „Hoisting" von Variablen gehört damit in vielen Bereichen der Vergangenheit an.

Wie erwähnt, betreffen die Releases nur die Sprachspezifikation, nicht deren Umsetzung und Verfügbarkeit in den Browsern. Es ist allerdings zu beobachten, dass die Browserhersteller bemüht sind, die neusten Spezifikationen schnell umzusetzen und zur Verfügung zu stellen. Trotz allem kann es eine Zeit dauern, bis alle Browser, für die eine Anwendung entwickelt wird, alle relevanten neuen ECMAScript-Sprachfeatures unterstützen.

Um dieses Problem zu lösen, haben sich in der JavaScript-Entwicklung Compiler etabliert. Mit diesen JavaScript-Compilern wird allerdings nicht wie in Java Source-Code zu Byte-Code übersetzt, sondern JavaScript-Code aus einer neueren Version in eine ältere „zurück compiliert“. Dadurch ist es auch möglich, experimentelle Sprachfeatures auszuprobieren, die es noch nicht abschließend in die Sprach-Spezifikation geschafft haben.

Die prominentesten Compiler sind Babel und TypeScript. Babel zeichnet sich unter anderem dadurch aus, dass dort sehr schnell neue JavaScript-Features unterstützt werden. Außerdem gibt es eine Plug-in-API, über die der Compiler um Features erweitert werden kann, die über das reine Compilieren hinausgehen (z. B. das Extrahieren von Texten in einer i18n-Datei zur späteren Übersetzung). TypeScript hingegen ist nicht nur ein Compiler, sondern vor allem eine Spracherweiterung für JavaScript, die ein statisches Typsystem zur Verfügung stellt. Mehr dazu später.

Polyfills

Mit einem Compiler werden Sprachfeatures beziehungsweise Syntax von einer Version zu einer anderen Version zurückübersetzt. Neben der Sprache gibt es aber auch APIs, die von der Laufzeitumgebung zur Verfügung gestellt werden müssen. Dazu gehören beispielsweise die Funktionen, die auf den JavaScript-Typen Object, String und Number definiert sind, oder die Web APIs-des Browsers. Hier ergibt sich dasselbe Problem wie bei der Sprache: Fehlen die APIs in einem der gewünschten Ziel-Browser, können diese in der eigenen Anwendung nicht verwendet werden, da es ansonsten zu Laufzeitfehlern kommt.

Abhilfe an dieser Stelle schaffen Polyfills. Hierbei handelt es sich um Bibliotheken, die eine oder mehrere der Standard-APIs implementieren und in die eigene Anwendung eingebunden werden können. Die meisten Polyfills sind dabei so intelligent, dass sie sich selbst nur dann aktivieren, wenn die von ihnen implementierten APIs zur Laufzeit wirklich nicht vorhanden sind. Wenn die API in einem Browser bereits vorhanden ist, wird von der Anwendung automatisch die nativ implementierte Variante verwendet.

Laufzeitumgebungen

JavaScript ist die Sprache des Browsers. Das dürfte in den meisten Fällen wohl auch die Motivation bei der Entwicklung von JavaScript-Anwendungen sein: JavaScript-Anwendungen können direkt und nativ vom Browser ausgeführt werden.

Allerdings können JavaScript-Anwendungen auch außerhalb eines Browsers laufen. Hierbei kommt dann in der Regel Node.JS zum Einsatz. Node.JS verwendet die JavaScript-Implementierung von Google, die auch die JavaScript-Engine von Chrome ist. Auf Basis von Node.JS werden einerseits Serverprozesse (zum Beispiel Webserver) implementiert. Andererseits dient Node.JS auch als Ausführungsumgebung für eine ganze Reihe von Tools, wie Compiler (die oben genannten Compiler Babel und TypeScript sind in JavaScript implementiert), Package-Manager oder Test-Tools. Das ist in gewisser Weise mit Java vergleichbar, wo die JRE auch nicht nur zum Ausführen von Server-Prozessen verwendet wird.

Genau wie in Java existieren von Node.JS mehrere Versionen parallel, die unterschiedliche Stabilität und Entwicklungsstände abbilden.
Zur Entwicklung von Desktop-Anwendungen kann das Framework Electron verwendet werden. Electron verwendet eine Kombination von Node.JS und Chromium. Die Anwendungen werden mit HTML, CSS und JavaScript geschrieben – wie für einen Browser. Sie laufen aber in einer lokalen, abgespeckten Chromium-Umgebung unter Windows, Linux und MacOS. Im Gegensatz zu Browser-Anwendungen können sie dabei auf die lokale Infrastruktur wie das Dateisystem direkt zugreifen. Bekannte Anwendungen wie Slack oder Miro sind mit diesem Technologie-Stack implementiert.

Modulsysteme

Unabhängig von der späteren Laufzeitumgebung müssen größere Anwendungen gut strukturiert werden, damit der Code verständlich und wartbar bleibt. Eine Möglichkeit dazu bieten Module. Ein Modul in JavaScript ist technisch betrachtet eine Datei und damit eine sehr viel kleinere Einheit als die meisten Java-Module, die in der Regel aus mehreren Klassen und Packages bestehen. In JavaScript ist es nicht ungewöhnlich, dass Module nur aus ein oder zwei Funktionen bestehen.

Da es in JavaScript lange Zeit kein Modulsystem gab, sind im Laufe der Zeit proprietäre Lösungen entstanden, unter anderem CommonJS und AMD. CommonJS ist Grundlage für das in Node.JS verwendete Modulsystem und für den Einsatz außerhalb eines Browsers konzipiert. Das AMD-Modulsystem ist für die Verwendung im Browser ausgelegt und kann unter anderem Module zur Laufzeit asynchron nachladen, um beispielsweise den Start einer Anwendung zu beschleunigen.

In ECMAScript 2015 ist erstmalig ein natives Modulsystem spezifiziert und in den Sprachstandard aufgenommen worden, das ECMAScript Module System (ESM). Mit dem nativen Modulsystem lassen sich aus Modulen (also Dateien) einzelne Bestandteile exportieren und damit für andere Module sichtbar machen. Alle Bestandteile eines Moduls (Funktionen, Klassen, Konstanten usw.), die nicht explizit exportiert werden, bleiben für andere Module unsichtbar (ähnlich wie Sichtbarkeiten in Java). Um etwas aus einem anderen Modul zu importieren, muss es explizit importiert werden. Listing 1 zeigt dafür ein Beispiel: Das UserService-Modul exportiert eine Klasse UserService, die vom App-Modul importiert wird. Alle anderen Teile aus dem Modul (die Konstante database und die Funktion initDatabase) sind für andere Module nicht sichtbar und können auch nicht importiert werden.

// UserService.mjs
export default class UserService {
constructor() { ... }
loadUser(id) { ... }
}
// Nur Modul-intern sichtbar
const database = ...;
function initDatabase() { ... }
// App.mjs
import UserService from "./UserService.mjs";
const userService = new UserService();
userService.loadUser("U1");
Listing 1: Beispiel für ECMAScript-Modulsystem

Für die Verwendung von Modulen in JavaScript gibt es zwei Herausforderungen: Erstens existieren nicht alle Bibliotheken für alle Modulsysteme. Die Wahrscheinlichkeit, in einer ESM-basierten Anwendung ein CommonJS-Modul verwenden zu wollen oder zu müssen, ist sehr groß. Zweitens funktionieren nicht alle existierenden Modulsysteme im Browser. Um dieses Problem zu lösen, gibt es eine Klasse von Tools, die sich Bundler nennt.

Ein Bundler kann JavaScript-Code statisch analysieren und die Abhängigkeit zwischen den Modulen erkennen. Der Bundler löst diese Abhängigkeiten dann auf und erzeugt für die Ausführung Modul-freien Code. Ganz vorsichtig könnte man das vergleichen mit dem Erzeugen eines „Fat Jars" in Java, in dem alle abhängigen JAR-Dateien enthalten sind. Die meisten Bundler können je nach Bedürfnissen konfiguriert werden, sodass zum Beispiel eingestellt werden kann, ob die Anwendung in Form einer großen Java-Script-Datei ausgeliefert werden soll oder in mehreren kleineren Dateien, die der Browser nach und nach laden kann. Viele Bundler bieten neben dem Support für JavaScript-Source-Code auch Unterstützung für andere Artefakte wie CSS oder Fonts. Zu den wichtigsten Bundlern gehören Webpack, Rollup und Parcel.

Package-Manager

Module lassen sich (ähnlich wie mit Maven in Java) in einem zentralen Repository veröffentlichen und von dort auch installieren. Das dafür zuständige Tool ist der „Node Package Manager“ (npm), der Bestandteil der Node.JS-Distribution ist. Das Repository ist die npm Registry. Die dort veröffentlichten Artefakte werden Packages genannt und können aus mehreren Modulen und weiteren Source-Artefakten, etwa CSS-Dateien, bestehen. Packages, von denen das eigene Projekt abhängig ist, werden im Projekt in einer Beschreibungsdatei (package.json) hinterlegt, vergleichbar mit den Abhängigkeiten in der pom.xml von Maven.

In JavaScript-Projekten ist es üblich, über die Abhängigkeitsverwaltung auch die für die Entwicklung benötigten Tools zu installieren. So stehen beispielsweise Babel und TypeScript, aber auch andere Build-Tools als npm-Pakete zur Verfügung. Auf diese Weise kann jedes Projekt individuell festlegen, welche Tools in welcher Version benötigt werden. Alle Mitglieder des Entwicklungsteams erhalten dann automatisch die benötigten Tools in den korrekten Versionen. Dadurch entfällt weitgehend die Notwendigkeit, Tools global zu installieren, wodurch oft Versionskonflikte entstehen. Listing 2 zeigt einen Ausschnitt aus einer package.json-Datei, in der sowohl Abhängigkeiten definiert sind, die von der Anwendung selber benötigt werden (dependencies), als auch Abhängigkeiten, die nur für die Entwicklung notwendig sind (devDependencies).

{
"name": "my-javascript-app",
"version": "1.0.0",
"devDependencies": {
"prettier": "^2.7.",
"eslint": "8.24.0",
"typescript": "^4.8"
},
"dependencies": {
"react": "^18.2",
"react-dom": "^18.2",
"react-router": "^6.0.0"
}
}
Listing 2: Ausschnitt aus einer package.json-Datei

Für (In-House-)Module, die nicht in einem öffentlichen Repository veröffentlicht werden sollen, kann man neben der offiziellen npm-Registry auch eigene Registries betreiben. Nexus und Artifactory beispielsweise unterstützen auch das npm-Format. Eine sehr leichtgewichtige standalone Open-Source-Registry ist Verdaccio.

Neben npm gibt es alternative Package-Manager, von denen insbesondere Yarn und pnpm verbreitet sind. Beide Tools verwenden dieselbe Abhängigkeitsbeschreibung in der package.json wie npm und können außerdem auch dieselbe Registry verwenden. Bei den wichtigsten Features unterscheiden sich die drei Package-Manager-Tools nicht wesentlich voneinander. Allerdings ist die Bedienung, insbesondere das CLI, verschieden. Yarn bringt mit „plug'n'play" außerdem eine alternative Möglichkeit mit, mit der Packages lokal installiert werden können, was Speicher sparen und die Performance beim Installieren verbessern soll. Auch pnpm geht beim Installieren der Packages im lokalen Workspace einen anderen Weg. Hier lautet das Versprechen, die Package-Installation zu beschleunigen und weniger Platz auf der Festplatte zu verbrauchen als mit npm und yarn. Durch die gewählte Verzeichnisstruktur von pnpm ist außerdem sichergestellt, dass ein Projekt nur Zugriff auf Packages hat, die direkt als Abhängigkeit in der package.json-Datei aufgeführt sind und nicht auch die transitiven Abhängigkeiten davon verwenden können.

Der Vollständigkeit halber sei erwähnt, dass aus früheren Frontend-Projekten noch bower bekannt ist, mit dem sich ebenfalls Abhängigkeiten verwalten lassen. Dieses Tool wird zwar noch gepflegt, aber die Entwickler raten selbst dazu, stattdessen auf andere Tools wie npm oder yarn zu setzen.

TypeScript

JavaScript verfügt über ein dynamisches Typsystem, in dem eine Variable zur Laufzeit ihren Typ verändern kann. Eine statische Analyse und entsprechende Compile-Fehler, wie von der Entwicklung von Java-Anwendungen bekannt, ist dadurch nicht oder nur sehr eingeschränkt möglich. Gerade in größeren JavaScript-Anwendungen wird es somit schnell schwierig zu verstehen, wie Code funktioniert oder welche Argumente beispielsweise an eine Funktion übergeben werden müssen. Auch das Refactoring von Code oder die Suche nach Referenzen ist schwer möglich.

Abhilfe bietet hier TypeScript. TypeScript ist ein von Microsoft entwickelter Aufsatz von JavaScript. Jeder JavaScript-Code ist demnach auch gültiger TypeScript-Code, was eine Migration von JavaScript nach TypeScript sehr einfach macht. Neben einigen Sprach-Features stellt TypeScript vor allem ein statisches Typsystem zur Verfügung. Damit können – ähnlich wie in Java – Typ-Annotationen im Code angegeben werden, um beispielsweise den Typ einer Variablen oder eines Funktionsarguments zu beschreiben. Die korrekte Verwendung der Typen wird dann von TypeScript im Build-Prozess überprüft. Verstöße dagegen werden wie in Java mit entsprechenden Fehlermeldungen quittiert.

Im Gegensatz zu Java sind die Typ-Angaben in den meisten Stellen im TypeScript-Code optional. Wo sie nicht explizit angegeben werden, leitet TypeScript die Typen ab und stellt deren korrekte Verwendung sicher. Listing 3 zeigt ein entsprechendes Beispiel. Die Variable firstname wird explizit auf den Typ string gesetzt. Die Variable lastname wird ohne explizite Typ-Angabe mit einem String initalisiert, sodass TypeScript dafür den Datentypen string ableitet. Eine Zuweisung mit einer Zahl in der darauffolgenden Zeile führt zu einem Compile-Fehler. In JavaScript wäre dieser Code gültig.

von IDEs und Editoren ist durchgängig vorhanden. Die Verwendung von TypeScript im eigenen Projekt ist in der Regel lohnend und empfehlenswert.

let firstname: string = "Klaus";
let lastname = "Müller";
lastname = 7; // ERROR
Listing 3: Statisches Typsystem in TypeScript

TypeScript ist mittlerweile sehr weit verbreitet und wird in einer Vielzahl von großen Projekten eingesetzt. Alle wichtigen Bibliotheken unterstützen TypeScript, und auch die Tool-Unterstützung insbesondere von IDEs und Editoren ist durchgängig vorhanden. Die Verwendung von TypeScript im eigenen Projekt ist in der Regel lohnend und empfehlenswert.

Code-Qualität

In Ergänzung oder bis zu einem gewissen Grad auch alternativ zu TypeScript lässt sich ESLint verwenden. Dabei handelt es sich um ein Tool, das über statische Code-Analyse typische Probleme und Code-Smells im Source-Code aufdeckt, etwa nicht verwendete Variablen und Funktionen. ESLint ist damit vergleichbar mit den Code-Analysetools für Java wie PMD oder FindBugs. Außerdem lassen sich darüber selbstdefinierte Code-Konventionen überprüfen. ESLint funktioniert auch mit TypeScript-Code und lässt sich in den Build-Prozess (zum Beispiel Jenkins oder SonarQube) einbinden, sodass der Build fehlschlägt, wenn ein Problem erkannt wird. Zum Einhalten identischer Code-Konventionen eignet sich das Formatierungstool Prettier, das auch in IntelliJ und Visual Studio Code als Formatter verwendet werden kann.

Testen

Type-Checker und Linter ersetzen (leider?) keine Tests für die eigene Anwendung, und genau wie in Java gibt es auch für Java-Script-Anwendungen diverse Test-Tools und -Frameworks für Tests auf allen möglichen Ebenen. Sehr weitverbreitet für Tests außerhalb des Browsers, zum Beispiel für Unittests, ist das Framework Jest. Jest enthält unter anderem eine headless Implementierung der DOM-API, sodass man in den Tests auch Code testen kann, der zur Laufzeit diese Browser-APIs benötigt. Für die wichtigsten IDEs, wie WebStorm oder Visual Studio Code, gibt es Plug-ins, mit denen Jest-Tests ausgeführt werden können. Außerdem können die Tests natürlich über die Kommandozeile zum Beispiel im Build-Prozess ausgeführt werden. Auf Wunsch ermittelt Jest auch die Code-Abdeckung. Listing 4 zeigt einen einfachen Unittest mit Jest.

// sum.ts
export function sum(a: number, b: number) {
return a+b
}
// sum.test.ts
import {sum} from './sum';
test('sum of 2 and 2 is 4', () => {
expect(sum(2, 2)).toBe(4);
});
test('sum of 2 and 2 is not 3', () => {
expect(sum(2, 2)).not.toBe(3);
});
Listing 4: Unittest mit Jest

Automatisierung

Mit dem bis hier gezeigten Technologie-Stack lassen sich echte Anwendungen entwickeln, testen und betreiben. Zur Entwicklungszeit gibt es aber eine Reihe von wiederkehrenden Aufgaben, etwa die Ausführung des Compilers, des Bundlers oder anderer Build-Tools. Außerdem sollen die Tests laufen und eventuell auch der Linter. Um diese Aufgaben zu automatisieren, können mit den gängigen Package-Managern Skripte ausgeführt werden, die in der package.json-Datei definiert werden.

Die angegebenen Skripte werden von dem eingesetzten Package-Manager dann über die Betriebssystem-spezifische Kommandozeile (z. B. Bash) ausgeführt. Dabei sind sämtliche lokal im Projekt installierten Packages (z. B. TypeScript oder Webpack) automatisch im Path vorhanden und können somit einfach aufgerufen werden. In der Praxis spielt das Problem, dass die Skripte vom Betriebssystem abhängig sind, daher selten eine Rolle, und für häufige Probleme, wie das Setzen von Umgebungsvariablen, gibt es bereits auch fertige Lösungen.

Mit Gulp und Grunt gibt es zwei Task-Runner, mit denen auch sehr komplexe Workflows entwickelt werden können. Für die meisten Aufgaben reichen die Skripte in der package.json-Datei allerdings vollkommen aus.

Mono-Repo-Tools

Große JavaScript-Projekte werden oft in Module oder Packages aufgeteilt, die dann von unterschiedlichen Teams bearbeitet werden. Um die Entwicklung und das Testen der Gesamtanwendung möglichst einfach zu halten, werden die Module dann aber in einem gemeinsam Git-Repository (dem „Mono"-Repo) abgelegt – vergleichbar mit Multi Module Maven-Projekten.

Zur Verwaltung solcher Projekte bringen zum einen die Package-Manager selbst Features (oftmals Workspace genannt) mit – zum Beispiel zum Erstellen von Releases. Zum anderen gibt es spezialisierte Tools wie TurboRepo, Rush, Lerna oder NX. Diese sorgen zum Beispiel dafür, das nach Änderungen in einem Modul nur die minimal notwendigen Compiler- und Testschritte der abhängigen Projekte neu ausgeführt werden. Teilweise lassen sich die Build-Artefakte auch cachen, sodass die neusten Ergebnisse immer dem ganzen Team zur Verfügung stehen, ohne dass jedes Teammitglied selbst einen Build durchführen müsste.

Frontend-Tools

Die Entwicklung von Single-Page-Anwendungen beispielsweise mit Angular, React oder Vue erfordert eine Reihe von Tools wie Compiler und Bundler. Die Konfiguration der einzelnen Tools und das Aktualisieren bei neuen Releases kann sehr komplex werden. Aus diesem Grunde gibt es für alle UI-Frameworks mittlerweile spezialisierte Lösungen, die dabei helfen, Projekte einzurichten und Abhängigkeiten zu pflegen, zum Beispiel die Angular CLI oder create- react-app. Zusätzlich gibt es mit Vite, NX und Neutrino Tools, die unabhängig von einer konkreten Bibliothek sind. NX und Neutrino bieten auch Unterstützung für das Arbeiten mit Mono-Repos.

Zusammenfassung

Für typische Probleme und Anforderungen, die sich beim professionellen Entwickeln von JavaScript-Anwendungen ergeben, gibt es mittlerweile ausgereifte Lösungen, auch wenn das Ökosystem in Teilen etwas fragmentierter erscheint als in Java. Vor der Auswahl der Tools sollte man sich unbedingt klar machen, welche Probleme ein Tool löst, und auf der Basis entscheiden, ob es das richtige für das eigene Projekt ist. Nicht für jedes Projekt werden zum Beispiel ausgereifte Mono-Repo-Tools benötigt.

. . .

Author Image

Nils Hartmann

Softwareentwickler und -architekt
Zu Inhalten

Nils Hartmann ist freiberuflicher Softwareentwickler und -architekt aus Hamburg. Er programmiert sowohl in Java als auch in JavaScript beziehungsweise TypeScript und beschäftigt sich mit der Entwicklung von React-basierten Anwendungen. Nils Hartmann bietet Schulungen und Workshops an.


Artikel teilen