Die Besonderheiten von JavaScript im Überblick

JavaScript Tutorial: Eine Einführung für Umsteiger
Keine Kommentare

An JavaScript als Programmiersprache gibt es mittlerweile kaum einen Weg vorbei. Es ergibt für viele Programmierer also durchaus Sinn, wenn sie sich zumindest mit den Grundlagen beschäftigen. Umsteiger sollten sich ebenfalls zuerst mit den Grundlagen und Besonderheiten befassen.

JavaScript weist im Vergleich zu anderen Sprachen einige Besonderheiten auf, die teilweise beabsichtigt, aber zum Teil auch in der Entstehungsgeschichte der Sprache begründet sind. Die große Verbreitung und viele der Einsatzszenarien waren zum Entstehungszeitpunkt von JavaScript noch nicht absehbar. Das ist auch einer der Gründe, warum sich der Sprachkern in den vergangenen Jahren an einigen Stellen deutlich zum Positiven verändert hat. Diese Tatsache und die Existenz von Hilfsmitteln wie TypeScript, die auf JavaScript basieren, machen den Umstieg für Entwickler, die ursprünglich von anderen Programmiersprachen kommen, leichter. Es spielt keine Rolle, von welcher Programmiersprache Sie kommen – mit den grundlegenden Elementen von JavaScript müssen Sie sich immer beschäftigen, da auch Dialekte wie TypeScript auf derselben Basis aufsetzen.

Angular Camp im November 2020

Mit Manfred Steyer (SOFTWAREarchitekt)

Manfred ist Trainer und Berater mit Fokus darauf, im gesamten deutschen Sprachraum Unternehmen bei der Umsetzung webbasierter Geschäftsanwendungen mit Angular zu unterstützen.

Neuer Blogpost zu Angular 10

Angular 10, Ivy und die Zukunft von Angular

Das Typsystem von JavaScript

Die Aussage, dass JavaScript über kein Typsystem verfügt, ist so nicht ganz richtig. Es gibt eine Reihe primitiver und komplexer Datentypen. Der Umgang mit diesen unterscheidet sich zum Teil jedoch gravierend von anderen Programmiersprachen. Die Basistypen in JavaScript sind:

  • Boolean

  • Number

  • String

  • Null, undefined

  • Symbol

Diese Basistypen werden by Value übergeben. Wenn Sie also eine Zeichenkette an eine Funktion übergeben, wird diese kopiert. Modifizieren Sie den Wert innerhalb der Funktion, ändert sich der Wert außerhalb nicht. Listing 1 verdeutlicht diesen Zusammenhang.

let name = 'Klaus';
 
function doSomethingWithName(name) {
  name = 'Petra';
  console.log(name); // Petra
}
 
doSomethingWithName(name);
console.log(name); // Klaus

Unabhängig vom Effekt dieses Beispiels handelt es sich um schlechten Stil, wenn Sie die Argumente einer Funktion direkt manipulieren und sich auf eventuelle Seiteneffekte verlassen. Eine bessere Idee ist es, eine tiefe Kopie des Arguments anzufertigen, mit dieser weiterzuarbeiten und den veränderten Wert zurückzugeben. Das wird insbesondere dann interessant, wenn es um komplexe Datentypen wie Arrays oder Objekte geht, die by Reference übergeben werden. Das bedeutet, dass Sie an eine Funktion lediglich die Referenz auf das Objekt übergeben. Eine Änderung des Objekts wirkt sich auf das ursprüngliche Objekt aus und sorgt für einen, gegebenenfalls unbeabsichtigten Seiteneffekt. Wie sich das im Code auswirkt, sehen Sie in Listing 2.

let person = { name: 'Klaus' };
 
function doSomethingWithPerson(person) {
  person.name = 'Petra';
  console.log(person.name); // Petra
}
 
doSomethingWithPerson(person);
console.log(person.name); // Petra

Eine tiefe Kopie eines Objekts können Sie auf mehrere Arten erzeugen. In den meisten Fällen sollten Sie jedoch auf eine etablierte Bibliothek zurückgreifen. Hier haben Sie die Wahl zwischen einfachen und leichtgewichtigen Lösungen wie Lodash und Immutability Helper oder umfangreichen Bibliotheken wie Immer und immutable. js. Hier sollten Sie sich je nach Anwendungsfall für die passende Bibliothek entscheiden.

Ansonsten handelt es sich bei JavaScript-Objekten um Key-Value Stores, deren Schlüssel immer Zeichenketten sein müssen und deren Werte beliebige Datentypen annehmen können. So können Sie einfache Eigenschaften mit primitiven Werten, aber auch untergeordnete Objekte erzeugen. Enthält eine Eigenschaft ein Funktionsobjekt, kann dieses als Methode des Objekts verwendet werden. Seit ECMAScript 6 gibt es eine Kurzschreibweise, bei der Sie das function-Schlüsselwort bei solchen Methoden weglassen können. Listing 3 enthält ein Beispiel hierfür.

const klaus = {
  firstname: 'Klaus',
  lastname: 'Müller',
  getFullName() {
    return `${this.firstname} ${this.lastname}`;
  },
};

Arrays sind die mächtigsten Datenstrukturen, die im Kern von JavaScript enthalten sind. Hierbei handelt es sich um Objekte, die einen fortlaufenden numerischen Index als Schlüssel für die Eigenschaften aufweisen. Die Werte können Sie auch hier beliebig wählen. Neben diesen Basiseigenschaften verfügen Arrays über eine Reihe von Methoden, mit deren Hilfe Sie mit dem Array arbeiten können. So ist es möglich, über das Array zu iterieren, die Elemente mit der map-Methode umzuwandeln oder mit der some-Methode herauszufinden, ob eine bestimmte Bedingung für mindestens ein Element gilt.

Das lose Typsystem von JavaScript bietet eine große Flexibilität, die sich zahlreiche Entwickler nicht nehmen lassen möchten. Allerdings entsteht daraus auch eine Reihe von Problemen, so können Sie einer Variablen beispielsweise eine Zeichenkette als Wert zuweisen und zu einem späteren Zeitpunkt eine Zahl übergeben. Diese dynamischen Wechsel des Typs einer Variablen in JavaScript stellen keinen Fehler dar, die Verlässlichkeit ist gerade bei umfangreichen Applikationen an dieser Stelle jedoch eingeschränkt. Eine Lösung für dieses Problem bietet TypeScript. Diese Sprache fügt Typeninformationen und weitere Features wie Interfaces, Generics oder Enums zum Sprachumfang von JavaScript hinzu. Der TypeScript-Compiler führt, wie im Folgenden zu sehen, eine statische Überprüfung des Quellcodes durch und übersetzt den TypeScript-Code in JavaScript, das Sie im Browser oder serverseitig in Node.js ausführen können:

function add(a: number, b: number): number {
  return a + b;
}

Scoping

Ein weiterer wichtiger Aspekt einer Programmiersprache ist der Gültigkeitsbereich von Variablen. JavaScript verfügt über vier Gültigkeitsbereiche:

  • Globaler Scope: Der globale Scope reicht in JavaScript am weitesten. Er umfasst die gesamte Applikation. Variablen, die im globalen Scope deklariert werden, stellen insofern eine Gefahr für die Applikation dar, da sie vom Garbage Collector der JavaScript Engine während der Laufzeit der Applikation nicht freigegeben werden können, weil immer eine Referenz auf den jeweiligen Speicherbereich existiert. Aus diesem Grund und um eventuelle Namenskonflikte zu vermeiden, sollten Sie möglichst sparsam mit globalen Variablen umgehen.

  • Funktions-Scope: In der ursprünglichen Version von JavaScript war der Funktions-Scope die kleinste Einheit, in der Sie eine Variable deklarieren konnten. Der Funktions-Scope umfasst eine Funktion und alle ihre Unterfunktionen.

  • Closure Scope: Der Closure Scope ist eine Erweiterung des Funktions-Scopes um den erstellenden Kontext der Funktion. Sie haben also nicht nur Zugriff auf die Funktion selbst, sondern auch auf den umliegenden Scope. Wird eine Funktion also innerhalb einer Funktion definiert und von dieser zurückgegeben, hat sie immer noch Zugriff auf den Scope der definierenden Funktion, auch wenn die Abarbeitung dieser Funktion bereits beendet ist. Mit diesem Mechanismus lassen sich Variablen definieren, auf die Sie nur über eine solche Funktion zugreifen können. Listing 4 enthält ein Beispiel hierfür.

  • Block-Scope: Der Block-Scope ist der jüngste Vertreter der JavaScript Scopes. Ein Block wird in JavaScript normalerweise durch geschweifte Klammern umschlossen. So stellt beispielsweise eine Funktion einen Block dar, aber auch eine if-Bedingung, eine for-Schleife oder ein try-catch-Statement sind Blöcke. Mit dem Block-Scope haben Sie eine wesentlich feinere und damit bessere Kontrolle über die Variablen Ihrer Applikation.

function myPrivate() {
  let value = ''; // "private" variable
  return {
    setValue(v) { // schreibender Zugriff
      value = v;
    },
    getValue() { // lesender Zugriff
      return value;
    },
  };
}
const myPriv = myPrivate();
 
console.log(value); // ReferenceError
myPriv.setValue('Klaus');
console.log(myPriv.getValue()); // Klaus

Zur Deklaration von Variablen nutzen Sie das let-Schlüsselwort. Es wurde mit der 6. Version des Sprachstandards eingeführt und deklariert Variablen auf Block-Ebene. Das ältere var-Schlüsselwort wird in modernen Applikationen kaum noch verwendet. Mit Block-Variablen können Sie alle Fälle abdecken, die Sie auch mit Funktionsvariablen implementiert haben, mit dem Bonus, dass Sie noch mehr Kontrolle über die Gültigkeit der Variablen haben. Ergänzt werden die Variablen in JavaScript durch das const-Schlüsselwort. Damit können Sie konstante Werte definieren. Bei primitiven Datentypen bedeutet das, dass der Wert der Variablen nicht verändert werden kann. Wenn Sie const in Verbindung mit einem composite-Typ wie einem Objekt nutzen, ist lediglich die Referenz auf das Objekt konstant. Das Objekt selbst können Sie verändern und ihm beispielsweise neue Eigenschaftswerte zuweisen. Als Regel zum Umgang mit Variablen hat es sich in vielen JavaScript-Applikationen etabliert, grundsätzlich mit const zu arbeiten. Erst wenn der Wert einer Variablen wirklich überschrieben wird, wird let verwendet.

Die prototypenorientierte Objektorientierung von JavaScript

Die Objektorientierung von JavaScript unterscheidet sich gravierend von der der meisten anderen Programmiersprachen, wie beispielsweise Java. JavaScript setzt auf eine prototypenbasierte Objektorientierung. Die Grundlage bildet eine Konstruktorfunktion. Das kann jede beliebige Funktion sein. Für die Erzeugung einer Instanz eines solchen Konstruktors kommt der new-Operator zum Einsatz. Innerhalb des Konstruktors wird über this auf die Instanz verwiesen, sodass Eigenschaften direkt auf dem resultierenden Objekt definiert werden. Methoden werden etwas anders behandelt: Sie werden nicht innerhalb des Konstruktors definiert, sondern auf der prototype-Eigenschaft der Konstruktor-Funktion. Bei der Instanziierung erbt das Objekt die Eigenschaften des Prototyps. Somit verfügen alle Instanzen über die gleichen Methoden. Ein wichtiger Unterschied zur klassenbasierten Objektorientierung ist, dass der Prototyp zur Laufzeit dynamisch ist. Sie können also während der Laufzeit der Applikation Eigenschaften zum Prototyp hinzufügen und auch wieder entfernen (was Sie allerdings tunlichst vermeiden sollten). Listing 5 enthält ein einfaches Beispiel für den Einsatz des Prototyps.

function Person(firstname, lastname) {
  this.firstname = firstname;
  this.lastname = lastname;
}
 
Person.prototype.getFullname = function() {
  return `${this.firstname} ${this.lastname}`;
};
 
const klaus = new Person('Klaus', 'Müller');
console.log(klaus.getFullname());

Dieser Ansatz sorgte lange Zeit für Kritik an der Syntax der Sprache, da es den Einstieg gerade für Entwickler, die andere Sprachen gewöhnt sind, unnötig erschwert. Mit ECMAScript 6 wurde als Reaktion auf diese Kritik das class-Schlüsselwort eingeführt, mit dem Sie eine Klasse erzeugen können. Von dieser Klasse können Sie dann, ebenfalls mit dem new-Operator, eine Instanz erzeugen. Bei diesem Konstrukt handelt es sich jedoch lediglich um syntaktischen Zucker. Den Klassen liegt nach wie vor der prototypenbasierte Ansatz zugrunde mit dem Unterschied, dass Sie bei der Entwicklung Ihrer Applikation in der Regel nicht mehr mit ihm in Berührung kommen.

Die Konstruktorfunktion findet sich im Konstruktor der Klasse wieder. Hierbei handelt es sich um eine spezielle Funktion, die immer den Namen constructor trägt. Alle übrigen Methoden definieren Sie, wie den Konstruktor ebenfalls, innerhalb der Klasse. Diese werden implizit dem Prototyp der Klasse zugewiesen und sind damit auf jeder Instanz der Klasse definiert.

Aktuell sieht der JavaScript-Standard noch nicht vor, dass Sie neben Methoden auch Eigenschaften innerhalb der Klasse definieren können. Hierfür gibt es jedoch bereits einen Entwurf, der sich aktuell auf der dritten Stufe der Standardisierung befindet. Dieser Entwurf wird bereits von einigen Browsern wie Chrome und Firefox implementiert, sodass Sie dort bereits auf die Definition von Eigenschaften zurückgreifen können. Für die übrigen Browser können Sie Hilfsmittel wie Babel oder TypeScript verwenden, die beide in der Lage sind, dieses Feature zu simulieren.

JavaScript unterstützt auch statische Eigenschaften und Methoden. Dabei handelt es sich um Eigenschaften, die auf der Klasse beziehungsweise auf der Konstruktor-Funktion direkt und nicht auf dem Prototyp definiert sind. Auf eine statische Methode greifen Sie zu, indem Sie zuerst den Klassennamen und anschließend durch einen Punkt getrennt direkt den Methodennamen angeben. Häufig wird dieses Konstrukt für die Implementierung von Factories eingesetzt.

Ein Feature, das viele Entwickler beim Umstieg auf JavaScript vermissen, sind Zugriffsmodifikatoren, also die Einschränkung der Sichtbarkeit von Eigenschaften und Methoden durch die Schlüsselwörter protected und private. Grundsätzlich ist in JavaScript alles public; Eigenschaften und Methoden können also immer und von überall aus verwendet werden. Ein neuer Featureentwurf für den JavaScript-Standard führt zwar private Felder in Klassen ein, diese werden jedoch mit dem #-Zeichen gekennzeichnet und nicht mit den bekannten Schlüsselwörtern. Außerdem befindet sich dieses Feature zusammen mit den öffentlichen Klassenfeldern noch in der dritten Standardisierungsstufe und ist noch nicht in allen Browsern verfügbar. Auch an dieser Stelle müssen Sie wieder auf ein sogenanntes Polyfill ausweichen, um sicherzustellen, dass das Feature auf allen Browsern verfügbar ist. Abhilfe schaffen hier wiederum Babel und TypeScript. Wobei TypeScript mit publicprotected und private drei Sichtbarkeitsstufen unterstützt. Die Sichtbarkeit der Eigenschaften wird in diesem Fall jedoch nur zum Zeitpunkt der Kompilierung des Quellcodes und nicht zur Laufzeit überprüft.

Weitere Features, die sich aufgrund der Eigenheiten der Sprache nicht umsetzen lassen, sind beispielsweise Interfaces, mit denen Sie die Struktur von Objekten vorgeben können und die bei der Implementierung zahlreicher Designpatterns eine Rolle spielen, oder abstrakte Klassen und Methoden, die sich nicht direkt instanziieren lassen beziehungsweise die über keine konkrete Implementierung verfügen. In beiden Fällen können Sie jedoch auf TypeScript zurückgreifen. Die Script-Sprache stellt Ihnen sowohl Interfaces als auch abstrakte Klassen zur Verfügung und überprüft die Einhaltung während der Kompilierung des Quellcodes in JavaScript.

Das JavaScript-Modulsystem

Führen Sie JavaScript im Browser aus, haben Sie mehrere Möglichkeiten, es einzubinden. Die am wenigsten erstrebenswerte Variante besteht aus der Integration in HTML-Attribute. So erlaubt der Browser beispielsweise JavaScript-Quellcode als Wert des onclick-Attributs. Klickt ein Benutzer dann auf das entsprechende Element, wird der Quellcode ausgeführt. Diese Lösung ist weder besonders lesbar noch an mehreren Stellen wiederverwendbar. Eine bessere Variante stellt die Implementierung von JavaScript innerhalb von Skripttags in der HTML-Seite dar. Hier haben Sie zumindest eine Trennung zwischen HTML und JavaScript. Die sauberste Lösung besteht jedoch darin, den JavaScript-Quellcode in eine eigene Datei auszulagern. Schon früh haben Entwickler festgestellt, dass es für umfangreichere Applikationen nicht praktikabel ist, allen JavaScript-Code einer Applikation in einer Datei zu verwalten. Deshalb wurden verschiedene Werkzeuge entwickelt, die es erlauben, den Quellcode in vielen kleinen Dateien zu organisieren und diese dann für die Auslieferung der Applikation zu einer beziehungsweise wenigen Dateien zusammenzufassen. Das hat mehrere praktische Gründe: Externe JavaScript-Dateien werden über Skripttags mit dem src-Attribut geladen. Für jede Datei wird eine Anfrage an den Server gestellt, für die jeweils eine Verbindung aufgebaut werden muss. Außerdem erlauben Browser nur eine bestimmte Anzahl paralleler Anfragen pro Subdomain, sodass eine Parallelisierung nur in einem begrenzten Rahmen möglich ist. Diese eine große Datei lässt sich durch sogenannte Minifier weiter optimieren, indem unnötige Leerzeichen und Zeilenumbrüche entfernt und Variablennamen gekürzt werden. Bei der Entwicklung größerer Applikationen mit einer Vielzahl von Dateien müssen Abhängigkeiten zwischen den Dateien aufgelöst werden. Bei mehreren hundert Dateien fällt es schwer, den Überblick zu behalten, sodass die Integration über Skripttags hier ebenfalls keine Option ist. Die populärsten Modulsysteme, die für JavaScript entwickelt wurden, sind das CommonJS-Modulsystem, das mit der require-Funktion zum Importieren von Dateien und der module.exports-Eigenschaft zur Definition der Exporte einer Datei arbeitet, und das AMD-Modulsystem, bei dem die require– und define-Funktion verwendet wird.

Das CommonJS-Modulsystem verdankt seine Popularität vor allem der Tatsache, dass es das Modulsystem von Node.js ist. Die serverseitige JavaScript-Plattform baut seit jeher auf ein Modulsystem, sodass Node.js-Applikationen schon immer auf Modularisierung gesetzt haben.

Mittlerweile verfügt der JavaScript-Standard ebenfalls über ein eigenes Modulsystem, das sich jedoch weder an CommonJS noch an AMD orientiert, sondern eigene Regeln im Umgang mit Modulen definiert. Im Zuge des ECMAScript-Modulsystems werden die Schlüsselwörter import und export verwendet. Das Modulsystem kümmert sich, wie die früheren Bibliothekslösungen auch, um das Laden von Dateien und die Auflösung von Abhängigkeiten. Die führenden Browserhersteller unterstützen mittlerweile das neue Modulsystem, sodass Sie es in Ihrer Applikation nutzen können. Momentan wird das Modulsystem jedoch häufiger in Verbindung mit Werkzeugen wie Babel und webpack verwendet. Der Grund hierfür ist, dass sich mit diesen Werkzeugen die Anzahl der erforderlichen Anfragen an den Server reduzieren lassen, da das Modulsystem standardmäßig für jedes angegebene Modul eine Anfrage an den Server stellt. Das lässt sich durch die Verwendung von HTTP/2 als Kommunikationsprotokoll relativieren, da es die Wiederverwendung von bestehenden Verbindungen erlaubt. Ein weiteres Argument für die Verwendung von Werkzeugen ist, dass sich die zusammengefassten Module einfacher optimieren lassen, indem Whitespaces und Kommentare entfernt und Variablennamen verkürzt werden.

Das ECMAScript-Modulsystem funktioniert im Grunde genommen wie schon die älteren Lösungen. Sie können beliebige Datenstrukturen exportieren und diese dann in anderen Modulen wieder importieren. In Listing 6 sehen Sie ein einfaches Modul, das eine Collection von Userobjekten darstellt. Für die einfachere Verarbeitung der Objekte wird zusätzlich die Lodash-Bibliothek geladen, die zuvor als npm-Paket mit dem Kommando npm install lodash installiert wurde.

import { find, includes } from 'lodash';
 
export default class UserCollection {
  items = [];
  add(item) {
    this.items.push(item);
  }
  find(name) {
    return find(this.items, item => includes(item.name, name));
  }
}

Listing 6 zeigt Ihnen eine weit verbreitete Konvention, nach der Sie die Importe einer Datei an deren Anfang setzen. Das hat den Vorteil, dass Sie als Entwickler auf den ersten Blick sehen, welche anderen Dateien und Pakete von der aktuellen Datei benötigt werden. Eine weitere Verschärfung dieser Konvention besteht darin, zunächst die Importe von externen Paketen und anschließend die relativen Importe für Dateien in Ihrer Applikation aufzulisten. Bei den Exporten gibt es unterschiedliche Ansätze: Sie können die Exporte entweder dort vornehmen, wo die Struktur definiert wurde, oder gesammelt am Ende der Datei. Diese Konvention wird allerdings relativiert, da Sie pro Modul nur eine begrenzte Anzahl von Exporten definieren sollten. In der Regel sollten Sie nur eine Klasse pro Datei implementieren. Dieses Modul weist dann auch nur einen Export auf. Bei Sammlungen von Funktionen können Sie sich überlegen, ob Sie sie nicht in einem Objekt zusammenfassen, das Sie am Ende der Datei exportieren. Das sorgt dafür, dass Sie auf einen Blick eine gute Zusammenfassung über die externe Schnittstelle des Moduls erhalten.

Eine Besonderheit des ECMAScript-Modulsystems ist die Unterscheidung zwischen Named– und Default-Exporten. Bei einem Named-Export wird eine Struktur, also eine Klasse, Funktion oder Variable direkt mit dem export-Schlüsselwort exportiert. Beim Import laden Sie diese Struktur dann, indem Sie den Namen in geschweifte Klammern fassen. Innerhalb dieses Klammernpaars können Sie mehrere Named-Exporte des Moduls angeben. Falls es beim Importieren zu einem Namenskonflikt mit anderen Importen oder lokalen Variablen des Moduls kommt, haben Sie die Möglichkeit, einen Named-Export mit Hilfe des as-Schlüsselworts umzubenennen. Soll also die UserCollection-Klasse mit dem Namen Users importiert werden, lautet das zugehörige import-Statement import {UserCollection as Users} from ‚./user-collection‘.

Im Gegensatz zu den Named-Exporten, von denen ein Modul mehrere aufweisen kann, hat ein Modul maximal einen Default-Export. Beim Importieren geben Sie nur den Namen an, mit dem der Default-Export importiert werden soll. Diesen Namen können Sie frei wählen und sind dabei nicht abhängig von der Benennung in der Quelldatei. Haben Sie die UserCollection mit export default exportiert, kann der Import so aussehen: import Users from ‚./user-collection‘.

Egal, für welche Variante der Exporte Sie sich entscheiden, Sie sollten die Wahl begründet und nachvollziehbar treffen, sodass Ihre Applikation an dieser Stelle konsistent ist. Das erleichtert die Weiterentwicklung und Wartung, da ein Entwickler genau weiß, wann welche Variante zum Einsatz kommt.

Asynchrone Operationen in JavaScript

Nicht weniger wichtig als das Modulsystem ist der Umgang mit Asynchronität. Der Begriff Asynchronität bezeichnet das Auslagern bestimmter Operationen aus dem JavaScript-Prozess und die Verarbeitung der Ergebnisse über verschiedene Lösungsstrategien wie beispielsweise Callback-Funktionen. Es gibt zwei Gründe für die Präsenz von Asynchronität in allen Bereichen von JavaScript: JavaScript wurde von Beginn an mit einem Single-Threaded-Ansatz entwickelt. Das bedeutet, dass der Quellcode einer JavaScript-Applikation in nur einem Thread ausgeführt wird. Die Sprache wurde jedoch entwickelt, um mehr Dynamik in Webbrowser zu bringen, was vor allem bedeutet, auf Benutzereingaben zu reagieren. Ein Warten auf beispielsweise einen Klick auf einen Button würde jedoch den JavaScript-Prozess komplett blockieren und keine weiteren Aktionen erlauben. Deshalb existiert der Event Loop, ein Konstrukt, bei dem Sie für verschiedene Ereignisse Callback-Funktionen registrieren können. Klickt der Benutzer Ihrer Applikation nun auf den Button, wird die zugehörige Callback-Funktion ausgeführt. Durch diesen Ansatz entsteht eine Reihe von Problemen: Ein Ereignis wie ein Buttonklick kann öfter als nur einmal auftreten und durch den Callback-Charakter ist die Logik für die Behandlung des Klicks vom Rest Ihrer Applikation entkoppelt. Sie können also den Fluss Ihrer Applikation nicht mehr direkt beeinflussen, außer Sie tauchen noch tiefer in die Asynchronität ein und definieren weitere Callbacks. Diese ineinander geschachtelten Callback-Funktionen für mehrere voneinander abhängige asynchrone Operationen führen zu einem Konstrukt, das als Callback Hell bekannt ist. Um dieses Problem zu lösen, wurde mit Promises ein weiteres Konstrukt für JavaScript standardisiert. Ähnlich wie beim Modulsystem existierten Promises in verschiedenen Formen bereits vor der Standardisierung in Form von Bibliotheken. Der Standard richtet sich in diesem Fall jedoch weitestgehend nach den bestehenden Implementierungen. Eine Promise ist ein Objekt, das für das Resultat einer asynchronen Operation steht. In diesem Zusammenhang müssen Sie beachten, dass ein Promise-Objekt lediglich einmal als erledigt markiert werden kann. Durch diese Beschränkung eignet es sich nicht zur Behandlung von Ereignissen aus der Benutzeroberfläche. Promises kommen vor allem bei der Kommunikation mit einem Webserver zum Einsatz. Aber auch neuere Browserschnittstellen wie Service Worker und das Payment Request API setzen mittlerweile vollständig auf Promises. Listing 7 enthält den Quellcode einer einfachen Timerfunktion.

function timer(time) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve();
    }, time);
  });
}
 
timer(1000).then(() => console.log("it's time"));

Die Timerfunktion in Listing 7 gibt ein neues Promise- Objekt zurück. Dem Promise-Konstruktor übergeben Sie eine Callback-Funktion mit den beiden Parametern resolve und reject. Hierbei handelt es sich wiederum um Callback-Funktionen. Die resolve-Funktion rufen Sie auf, wenn Sie das Promise-Objekt erfolgreich auflösen möchten. reject kommt im Fehlerfall zum Einsatz. Im Beispiel wird die timer-Funktion nach einer bestimmten Anzahl von Millisekunden mit dem Wert true aufgelöst. Damit Sie auf dieses Ereignis reagieren können, implementiert das Promise-Objekt die then-Funktion. Dieser können Sie wiederum zwei Callback-Funktionen übergeben. Die erste wird im Erfolgsfall, die zweite im Fehlerfall ausgeführt. Mittlerweile hat sich in den meisten Applikationen durchgesetzt, dass die zweite Funktion nicht mehr verwendet und stattdessen auf dem Rückgabewert der then-Methode die catch-Methode zur Fehlerbehandlung benutzt wird. Wie Sie hier sehen, sparen Sie sich mit dem Promise API keine Callback-Funktionen, sondern müssen stattdessen mit mehr Funktionen arbeiten. Der entscheidende Vorteil von Promises ist die Möglichkeit, statt einer Verschachtelung der Operationen eine flache Kette zu implementieren. In Listing 8 sehen Sie ein Beispiel hierfür.

timer(1000)
  .then(() => {
    console.log('Timer 1');
    return timer(1000);
  })
  .then(() => {
    console.log('Timer 2');
  });

Der Rückgabewert der then-Methode ist in jedem Fall wieder ein Promise-Objekt, egal, ob Sie explizit ein Promise-Objekt oder einen anderen Wert zurückgeben. Im zweiten Fall wird dieser automatisch in ein Promise-Objekt gekapselt. In Listing 8 verwenden Sie das fetch API des Browsers, um mit einem Webserver zu kommunizieren. Die fetch-Funktion gibt ein Promise-Objekt zurück, das im Erfolgsfall in ein Response-Objekt aufgelöst wird. Auf diesem Response-Objekt ist die json-Methode definiert, die den Response Body extrahiert und ihn wiederum in ein Promise-Objekt kapselt. Innerhalb der letzten then-Methode der Kette können Sie also auf die JSON-Daten der Serverantwort zurückgreifen. Tritt an einer Stelle der Kette ein Fehler auf, wird die nächste Fehlerbehandlungsroutine ausgeführt. Diese Tatsache macht es möglich, so etwas wie einen Fall-through für Fehler zu implementieren und alle Fehler der Kette an einer Stelle behandeln.

Die letzte Stufe der Behandlung asynchroner Operationen, die nur einmal aufgelöst werden, erfolgt mit den Schlüsselwörtern async und await. Dieses Konzept baut auf dem Promise API auf und hat das Ziel, die zahlreichen Callback-Funktionen zu reduzieren, die durch die Verwendung von Promises eingeführt wurden. Listing 9 zeigt das Beispiel der Promise-Kette als async-Funktion.

(async () => {
  await timer(1000);
  console.log('Timer 1');
  await timer(1000);
  console.log('Timer 2');
}());

Mit dem await-Schlüsselwort können Sie auf die Auflösung eines Promise warten und erhalten den Wert des Promise als Rückgabewert, den Sie dann beispielsweise einer Variablen zuweisen können. Intern wartet die JavaScript Engine auf das Ergebnis der asynchronen Operation und führt anschließend den weiteren Quellcode aus. Da das await-Schlüsselwort nur in async-Funktionen verwendet werden kann, ist sichergestellt, dass eine solche Operation nur die aktuelle Funktion, nicht aber die gesamte Applikation in einen Wartezustand versetzt. Ein Benutzer kann also weiterhin mit der Applikation interagieren und Sie können innerhalb der Applikation darauf reagieren. Auch die Fehlerbehandlung ist deutlich komfortabler, da Sie mit try/catch wie gewohnt Fehler fangen und behandeln können. Wichtig für Sie zu wissen ist an dieser Stelle, dass eine async-Funktion immer ein Promise-Objekt zurückgibt. Müssen Sie also mit dem Rückgabewert dieser Funktion weiterarbeiten, können Sie das entweder in Form des Promise-Objekts machen, das die Funktion zurückgibt oder Sie verwenden wiederum das async/await-Konstrukt.

Bisher haben wir hauptsächlich Operationen wie die Serverkommunikation behandelt, bei der die Rückgabe genau einmal vorliegt und mit ihr weitergearbeitet wird. Anders verhält es sich bei mehrfach auftretenden Ereignissen, wie sie beispielsweise bei der Interaktion eines Benutzers, aber auch bei der Kommunikation über WebSockets auftreten. In diesem Fall bleibt Ihnen nur die Arbeit mit Callbacks oder die Verwendung einer externen Bibliothek wie RxJS. Die mehrfach auftretenden Ereignisse können als Strom von Ereignissen (oder Event Stream) betrachtet werden. Das Streaming-Konzept existiert schon sehr lange Zeit und kam schon bei den Unix Pipes zum Einsatz. Beim Streaming haben Sie eine Datenquelle und ein Ziel. Zwischen diesen beiden Endpunkten können Sie weitere Funktionen einhängen, die den Event Stream manipulieren oder auf bestimmte Ereignisse reagieren. Dieses Konzept wird von RxJS aufgegriffen und um sogenannte Operatoren ergänzt. Dabei handelt es sich um Funktionen, die bestimmte Operationen auf dem Event Stream vornehmen, wie beispielsweise ihn zu filtern oder ihn mit anderen Streams zu verbinden.

Fazit

JavaScript kann mittlerweile auf eine lange Geschichte von Höhen und Tiefen zurückblicken. Ausgehend von einer einfachen Skriptsprache, die den Browser etwas dynamischer machen sollte, bis hin zu einer Sprache, die weltweit auf nahezu jedem Gerät zu finden ist. Dieser Entwicklung geschuldet entwickelt sich die Sprache kontinuierlich weiter und wird kontinuierlich modernisiert, um mit neuen Anforderungen standzuhalten. Ergänzt wird der Kern der Sprache um zahlreiche Frameworks und Bibliotheken, die sich über Paketmanager wie npm oder Yarn installieren lassen. Für die Auslieferung einer Applikation steht Ihnen eine Reihe von Build-Tools wie webpack oder Babel zur Verfügung, die es erlauben, die meisten der anstehenden Aufgaben zu automatisieren.

Mit diesem Ökosystem haben Sie große Flexibilität bei der Gestaltung Ihrer Applikation. Sie können klassisch objektorientiert programmieren und sich mit TypeScript zusätzlich ein Typsystem in Ihre Applikation integrieren. Sie können aber auch funktionale Aspekte in Ihre Applikation einfließen lassen. Wofür Sie sich auch immer entscheiden: Achten Sie darauf, dass der Quellcode Ihrer Applikation konsistent und gut lesbar strukturiert ist.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Abonnieren
Benachrichtige mich bei
guest
0 Comments
Inline Feedbacks
View all comments
X
- Gib Deinen Standort ein -
- or -