Single-Page-Anwendungen Framework-unabhängig entwickeln

Beim Entwickeln einer Single-Page App (SPA) haben Programmierer die Qual der Wahl, wenn es um die geeignete Technik geht. Nicht immer ist ein Framework zwingend notwendig.

Architektur/Methoden  –  13 Kommentare
Single-Page-Anwendungen Framework-unabhängig entwickeln

Der Unterschied zwischen klassischen, serverseitig gerenderten Webseiten und Single-Page Apps liegt hauptsächlich in der geänderten Rolle des Servers. Während er ursprünglich neben dem Ausliefern der Ressourcen (HTML, CSS, JS) auch das komplette Rendering übernahm, ist er heute primär ein Datenlieferant (in Form von JSON) und stellt nur noch eine initiale Seite zur Verfügung. Auf ihr beginnt die Single-Page App ihre Arbeit. Navigiert ein User auf der Website, fordert die Anwendung Daten vom Server an und rendert diese anschließend dynamisch auf dem Client. Der übliche Roundtrip zum Server entfällt. Geblieben ist allerdings seine Rolle während der Authentifizierung.

Jeder Entwickler kennt sicherlich die folgende Situation: Ein neues SPA-Framework steht in den Startlöchern und es entsteht innerhalb der Community der Eindruck, es sei sofort auf den Zug aufzuspringen. Um feststellen zu können, ob sich der Umstieg lohnt, ist es unerlässlich, sich näher mit den Konzepten hinter Single-Page Apps zu beschäftigen. Im Kern lassen sich diese auf die folgenden Grundpfeiler zurückführen, die in den nächsten Abschnitten Thema sind:

Grundpfeiler (Abb. 1)

Der Client fordert die Webseite über einen HTTP-Request an und erhält initial vom Server beispielhaft eine Seite mit folgendem Aufbau:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
...

<title>SPA</title>
<link rel="stylesheet" type="text/css" ↵
href="src/app /index.min.css"/>
</head>
<body>

<nav>
<ul>
<li>Home</li>
...
</ul>
</nav>

<main id="app"></main>

<script src="src/app/bundle.min.js"></script>
</body>
</html>

Neben statischem Markup wie der Navigation gibt es ein ausgezeichnetes DOM-Element mit der ID app (im weiteren "Einstiegspunkt" genannt). Dort hinein rendert der Browser später mit JavaScript weitere DOM-Elemente. Entwickler fassen sämtliches JavaScript, das zur Applikation gehört, in einer Datei zusammen – dem sogenannten Bundle. Es stellt den JavaScript-seitigen Einstiegspunkt dar.

Initial besteht jede SPA aus einem Haupteinstiegspunkt, vergleichbar mit einer Hauptfunktion, in der die Ausführung sämtlicher JavaScript-Logik beginnt. Durch die Integration eines clientseitigen Routers entstehen weitere Einstiegspunkte und somit Teilbereiche innerhalb der Applikation. Diese sind als JavaScript-Funktionen abstrahiert. Vor der Aufteilung ist es hilfreich, sich einen Überblick über die Fachlichkeit der Applikation zu verschaffen. Eine Gliederung auf deren Grundlage hat sich bewährt.

Jeder Teilbereich arbeitet dabei unabhängig und umfasst eigenes Markup und JavaScript. Neben der besseren Übersichtlichkeit verschafft einem ein solches Vorgehen die Flexibilität, Technologien für jeden Teilbereich frei zu wählen und zu kombinieren. Das ist insbesondere deshalb wichtig, weil die Anforderungen pro Bereich der Applikation stark variieren können.

Die Funktionsweise eines Routers lässt sich auf eine Zuordnung von Client-URLs zu Einstiegspunkten zurückführen. Die Zuordnungen sind als Routen bekannt, die sich über einen sogenannten Router steuern lassen. Letzterer lauscht auf Veränderungen der URLs und leitet die Anfrage zum entsprechenden Einstiegspunkt weiter. Bei Single-Page Apps findet während der Navigation auf der Seite kein Roundtrip zum Server statt. Realisieren lässt sich das unter anderem durch den Einsatz des Hash-Zeichens (#), der klassischen HTML-Sprungmarke. Sie ist Bestandteil der URL.

Eine beispielhafte Implementierung eines clientseitigen Routers in ECMAScript 2015 sieht wie folgt aus:

const createRouter = domEntryPoint => {
const routes = new Map();

const addRoute = (hashUrl, routeHandler) => {
routes.set(hashUrl, routeHandler);
}

const navigateToHashUrl = hashUrl => {
location.hash = hashUrl;
}

const handleRouting = () => {
const defaultRouteIdentifier = '*';
const currentHash = location.hash.slice(1);
const routeHandler = routes.has(currentHash) ? ↵
routes.get(currentHash) : routes.get(defaultRouteIdentifier);

if (routeHandler) {
routeHandler(domEntryPoint);
}
};

if (window) {
window.addEventListener('hashchange', handleRouting);
window.addEventListener('load', handleRouting);
}

return { addRoute, navigateToHashUrl };
};

Die Funktion createRouter erzeugt ein neues Router-Objekt mit den Methoden addRoute und navigateToHashUrl. Mit addRoute lassen sich dem Router neue Routen, bestehend aus URL-Hash und der routeHandler-Funktion, hinzufügen. Die Methode navigateToHashUrl ermöglicht den dynamischen Wechsel von Routen im Code unter Angabe des URL-Hashes.

Kern des Routers ist die Funktion handleRouting. Sie wird beim initialen Laden der Seite (load-Event) und bei Änderung des URL-Hashes (hashchange-Event) aufgerufen. Innerhalb von handleRouting ermittelt die Implementierung, ob für den aktuellen Hash eine Zuordnung zu einem Einstiegspunkt (routeHandler) existiert. Falls ja, wird der entsprechende Route-Handler aufgerufen. Als Fallback dient die mit dem Stern (*) gekennzeichnete Default-Route, mit der sich zudem beispielsweise 404-Seiten implementieren lassen.

Die Nutzung des Routers ist im Folgenden veranschaulicht:

const domEntryPoint = document.getElementById('app');
const router = createRouter(domEntryPoint);

router.addRoute('home', domEntryPoint => {
domEntryPoint.textContent = 'Home Route';
});

router.addRoute('overview', domEntryPoint => {
domEntryPoint.textContent = 'Overview Route';
});

router.addRoute('*', domEntryPoint => {
domEntryPoint.textContent = 'Default Route';
});

Beim Erzeugen des Routers wählt die Implementierung den DOM-Einstiegspunkt des initialen Markups und übergibt sie dem Router. Somit lässt sich von außen definieren, wo Inhalte pro Route dynamisch hineinzurendern sind. Jede Route erhält eine Referenz auf das festgelegte DOM-Element. Via domEntryPoint.textContent = '...' wird beispielhaft die aktive Route in Form eines Textes auf der Oberfläche dargestellt.

Strukturierung

Immer mehr Logik verlagert sich auf den Client, wodurch auch die Menge an JavaScript zunimmt. Um langfristig eine wartbare und testbare Applikation zu erhalten, ist es unerlässlich, sich über die Strukturierung ihrer Logik Gedanken zu machen. Positiv ist, dass es bereits bewährte Konzepte gibt, die im SPA-Kontext verbreitet sind.

MVC ist der Klassiker unter den Architekturmustern und im SPA-Kontext am verbreitetsten. Frameworks wie Backbone.js oder AngularJS nutzen MVC als Architektur. Es besteht aus den Komponenten Model (Datenrepräsentation), View (Darstellung) und Controller (Steuerung, enthält die Applikationslogik). Jedoch kommt das Pattern nicht immer in Reinform zum Einsatz. AngularJS (in der Version 1.x) beispielsweise verwendet alle Komponenten aus MVC, nutzt darüber hinaus aber auch Konzepte aus MVVM (Data Binding).

Besonders interessant ist es, zu beleuchten, wie die einzelnen Komponenten in den unterschiedlichen Frameworks implementiert sind. Die größten Unterschiede finden sich dabei im Model und innerhalb der View. Der Controller ist immer mit JavaScript umgesetzt, da sich hier die Applikationslogik befindet. Das Model besteht in AngularJS einfach aus JSON-Daten, während Backbone.js es durch JavaScript abstrahiert.

Das Model von Backbone.js ist hier beispielhaft dargestellt:

var Model = Backbone.Model.extend({
...

initialize: function() { ... },

comments: function() { ... }

...
});

Das gleiche gilt für die View. Sie ist in AngularJS reines HTML-Markup, während Backbone.js sie ebenfalls durch JavaScript abstrahiert.

Eine View von Backbone.js sieht wie folgt aus:

var View = Backbone.View.extend({
...

initialize: function() {
this.listenTo(this.model, "change", this.render);
},

render: function() {
...
}

...

});

Werden Model und View durch JavaScript abstrahiert, haben Entwickler die Möglichkeit, durch eigenen JS-Code Einfluss zu nehmen. Beispielsweise um innerhalb der View DOM-Manipulationen vorzunehmen oder um explizit auf Änderungen des Models reagieren zu können.

Sind Model und View hingegen als JSON und HTML realisiert, sind die Einflussmöglichkeiten der Entwickler eingeschränkt. Das Framework gibt in dem Fall vor, wie View und Model durch die API des Frameworks zu verändern sind.

MVVM stellt ein weiteres Architekturmuster im SPA-Kontext dar und besteht aus den Komponenten Model (Datenrepräsentation und Businesslogik), View (Darstellung) und ViewModel (Präsentationslogik). Knockout ist das bekannteste Framework, welches MVVM als Basisarchitektur verwendet.

MVVM wurde von Microsoft mit dem Ziel ins Leben gerufen, die View völlig losgelöst vom Rest der Applikation entwickeln zu können. Die Kapselung der View (reines HTML) ist durch das Verbinden mit dem ViewModel (als Funktion abstrahiert) über Data Binding realisiert. Letzteres ist der typische Indikator für MVVM und ist in zwei Ausprägungen möglich: Beim One-Way Data Binding gibt es eine unidirektionale Verbindung vom ViewModel zur View. Ändert sich etwas im ViewModel, synchronisiert die Anwendung die View automatisch. Beim Two-Way Data Binding findet die Synchronisation in beide Richtungen statt.

Eine beispielhafte Implementierung von MVVM in ES2015 sieht wie folgt aus:

const observable = initialValue => {
let value = initialValue;

const listeners = [];

const subscribe = listener => {
listeners.push(listener);
};

const get = () => value;

const set = newValue => {
value = newValue;
listeners.forEach(listener => listener());
};

return {subscribe, get, set};
};

Um MVVM zu implementieren, bedarf es der Möglichkeit, auf Änderungen reagieren zu können. Dafür eignet sich das Observer-Pattern, das im Beispiel oben durch die Funktion observable implementiert ist. Ein Observable setzt sich aus einem initialen Wert der Möglichkeit, einen neuen Wert zu setzen, und der, den aktuellen abzufragen zusammen.

Durch Aufruf der subscribe-Methode lässt sich von außen auf Änderungen des Observables lauschen. Dafür ist eine Funktion zu übergeben, die bei Veränderungen zum Aufruf kommt.

View und ViewModel werden über Data Binding verbunden. Ein ViewModel sieht dabei folgendermaßen aus:

const viewModel = {
message: observable('Hello World!')
};

Das ViewModel ist in dem Fall ein einfaches JavaScript Object-Literal (Funktionsobjekte oder ES6-Klassen lassen sich ebenfalls verwenden) mit dem Attribut message. Das Attribut ist ein Observable mit dem initialen Wert "Hello World!".

<!DOCTYPE html>
<html>
<head>
<title>MVVM</title>
</head>
<body>
<p data-bind="message"></p>

<label for="msg">Message:</label>
<input type="text" id="msg" data-bind="message">

<script src="./mvvm.js"></script>
</body>
</html>

Um eine Relation zwischen ViewModel und View herzustellen, kommen innerhalb der View data-Attribute zum Einsatz. data-bind="message" gibt sowohl im Paragraph-Element als auch im Input-Element an, dass das Attribut message des ViewModels innerhalb der View darzustellen ist.

Um Data Binding in JavaScript zu aktivieren, gibt es die Funktion applyDataBinding, die sich mit dem aktuellen ViewModel als Parameter aufrufen lässt.

const viewModel = {
message: observable('Hello World!')
};

applyDataBinding(viewModel);

Funktionsweise und Implementierung von
applyDataBinding zeigt der folgende
Quelltextauzug:

const applyDataBinding = viewModel => {

const applyBindingFromViewModelToView = () => {

const updateViewElements = (selector, newValue) => {
const isInputElement = element => {
return element.nodeName === 'INPUT' ||↵
element.nodeName === 'TEXTAREA';
};

Array.from(document.querySelectorAll(selector)).forEach(element
=> { const propertyToUpdate = ↵
isInputElement(element) ? 'value' : ↵
'textContent';
element[propertyToUpdate] = newValue;
})
};

const updateView = propertyName => {
// Get the newest value of the viewmodel property
const newValue = viewModel[propertyName].get();

// Get the DOM Element with the correct propertyName
const selector = `[data-bind='${propertyName}']`;

updateViewElements(selector, newValue);
};

// Iterate over all properties and register change listener
Object.keys(viewModel).forEach(propertyName => {
if (typeof(viewModel[propertyName].subscribe) !== ↵
'undefined') {
viewModel[propertyName].subscribe(() => {
updateView(propertyName);
});
updateView(propertyName);
}
});

};

const applyBindingFromViewToViewModel = () => {
const onInputChanged = evt => {
const attr = evt.target.getAttribute("data-bind");
if (typeof viewModel[attr] !== 'undefined') {
viewModel[attr].set(evt.target.value);
}
}
document.removeEventListener('input', onInputChanged);
document.addEventListener('input', onInputChanged);
};

applyBindingFromViewModelToView();
applyBindingFromViewToViewModel();
};

Die interne Funktion applyBindingFromViewModelToView iteriert dabei über alle Properties des ViewModels und registriert Change Listener. Ändert sich eine Property, wählt die Anwendung die entsprechenden View-Elemente via Data-Attribut aus und aktualisiert sie mit dem neuen Wert (siehe Funktion updateView). Damit auch die initialen Werte des ViewModels in der View dargestellt werden, wird updateView einmalig zu Beginn für jede ViewModel-Property aufgerufen. Die Funktion updateViewElements kümmert sich um das korrekte Übernehmen der neuen Werte in die DOM-Elemente. Handelt es sich um ein Eingabeelement (Text-Input oder TextArea), so wird der neue Wert via value gesetzt, ansonsten via textContent.]]

Die Funktion applyBindingFromViewToViewModel erweitert die Implementierung zum Two-Way Data Binding. Dabei kommt das Input-Event des Document-Objekts zum Einsatz. Es ermöglicht die zentrale Überwachung von Änderungen aller DOM-Elemente. Ändert sich beispielsweise der Wert eines Text-Inputs, liest die Anwendung innerhalb der Funktion onInputChanged das Data-Attribut data-bind aus. Existiert eine ViewModel-Property mit gleichem Namen, gelangt der aktuelle Wert des Text-Inputs in das ViewModel, indem die set-Methode mit dem neuen Wert aufgerufen wird. Da Change-Listener für jede ViewModel-Property registriert sind, schließt sich hier der Kreis und die View wird erneut aktualisiert. Dadurch entsteht Two-Way Data Binding.

Typische Anwendungsbereiche derartiger Datenbindungen sind Applikationen mit vielen Formulareingaben. Ein Beispiel wäre eine Einstellungsseite, die Daten initial vom Server lädt, in einem Formular verändert und anschließend auf dem Server im gleichen Format speichert. Hier spielt Two-Way Data Binding seine Stärken voll aus.

Ist hingegen ein komplexer Prozess mit vielen beteiligten Komponenten abzubilden, stößt eine derartige Datenbindung an ihre Grenzen. An der Stelle ist es hilfreich, den Kontext und somit den Grund für die Änderung der Daten zu kennen.

Datenfluss

In Single-Page Apps ist der State zu berücksichtigen, den es zu verwalten gilt. Zu ihm gehören alle Daten und Informationen, die die Applikation innerhalb der Laufzeit verwendet. In klassischen Architekturen wie MVC und MVVM ist der State in den entsprechenden Controllern und ViewModels verteilt, wodurch er innerhalb der Applikation stark fragmentiert sein kann.

Im Gegensatz dazu gibt es aktuell einen Trend hin zu den One-Way Data Flow Patterns. Während der One-Way Data Flow im Umfeld von React schon immer Bestandteil des Konzepts war, gehen mittlerweile auch Frameworks wie Angular 2.0 konzeptionell in diese Richtung. Das Muster zeichnet sich dadurch aus, dass die Applikation den State zentral verwaltet. Daten fließen in Form von Nachrichten in einer Richtung durch die Applikation.

Ein Vorreiter der One-Way Data Flow Pattern im SPA-Kontext ist Flux. Das Entwurfsmuster ist als die Applikationsarchitektur von Facebook bekannt.

Aktionen sind zentrale Komponente der Flux-Architektur (Abb. 2).

Die Pfeile innerhalb der Darstellung verdeutlichen die Richtung des Datenflusses. Aktionen bewegen sich als Nachrichten durch die Anwendung. Sie sind der Ausgangspunkt der Flux-Architektur und beschreiben allgemein die Intention etwas zu tun. Neben einer eindeutigen Bezeichnung besteht eine Aktion aus allen Daten und Informationen, die für ihre spätere Verarbeitung notwendig sind.

Der Dispatcher nimmt Aktionen entgegen und leitet sie an registrierte Stores weiter. Letztere sind der Kern der Applikation, die sogenannte "Single Source of Truth". Der Applikations-State wird zentral verwaltet, gekapselt und lässt sich nur durch Aktionen explizit verändern. Der Vorgang ist dadurch explizit, dass an der Stelle, wo der State verändert wird, auch der Kontext und somit der Grund für die Änderung bekannt ist.

Immer wenn sich der State ändert, löst die Anwendung ein Event aus, worauf die View durch Anfordern des aktuellen States und seine Darstellung reagiert. Die View ist darüber hinaus der Ort, an dem Aktionen angestoßen werden. Innerhalb von Flux kann es beliebig viele Stores, Actions und Views geben, jedoch nur einen Dispatcher.

Redux ist nach den Prinzipien funktionaler Programmierung gestaltet und stellt die State-Verwaltung ins Zentrum (Abb. 3).

Redux ist ebenfalls den One-Way Data Flow Patterns zuzuordnen. Es handelt sich dabei vereinfacht ausgedrückt um einen State-Container mit dem Ziel, den State zentral und transparent innerhalb der Applikation zu verwalten. Redux ist aus Flux entstanden, seine Entwickler gestalteten es jedoch von Grund auf nach Prinzipien funktionaler Programmierung.

In einer Redux-Applikation gibt es nur einen einzigen Store, in dem der komplette State der Anwendung zentral gespeichert wird. Aktionen stellen (wie bei Flux) die einzige Möglichkeit dar, den State im Store zu verändern. Um zu definieren, wie genau diese Änderungen funktionieren, gibt es das Konzept der Reducer. Sie sind als sogenannte Pure Functions realisiert, JavaScript-Funktionen ohne Seiteneffekte.

Einen weiteren Ansatz stellen die reaktiven Entwurfsmuster dar. Unter reaktiver Programmierung versteht man die Programmierung mit asynchronen Event-Streams. Sie lassen sich beispielsweise mit Bibliotheken wie RX.js oder Bacon.js realisieren. RX.js ist Teil der Reactive Extension. Sie stellt unter der Organisation von ReactiveX die reaktiven Patterns für eine Vielzahl von Programmiersprachen unter einer gemeinsamen API zur Verfügung.

Asynchrone Event-Streams kann man sich vereinfacht als unveränderliche Arrays vorstellen.

[1,2,3,4,5,6].filter(x => x % 2 === 0);
// Result: [2, 4, 6]

Geht man von einem Array mit Zahlen aus, so lässt sich via filter-Funktion beispielsweise nach allen geraden Zahlen suchen. Das Ergebnis ist ein neues Array mit der gefilterten Zahlenmenge.

[2,3,5].map(x => x * 2);
// Result: [4, 6, 10]

Analog dazu ist es mit der map-Funktion möglich, die Werte im Array zu transformieren. Das Ergebnis ist ein neues Array aller Zahlen multipliziert mit 2. Geht man mit dem Wissen über Arrays gedanklich zurück zu den Event-Streams (auch Observables genannt), verhalten sie sich ähnlich.

Event eines Streams lassen sich wie Arrays filtern (Abb. 4).


Betrachtet man einen Event-Stream von Click-Events, lassen sie sich genauso filtern wie Arrays. Das Ergebnis ist ein neuer Event-Stream mit allen Events deren y-Wert im vorliegenden Beispiel kleiner als 100 ist. Neben der filter-Funktion gibt es eine Vielzahlweiterer Funktionen, die sich auf Event-Streams anwenden lassen, beispielsweise um mehrere Streams zu kombinieren.

Umfasst die zu entwickelnde Applikation viel Asynchronität oder sogar Echtzeit-Features, kann die reaktive Programmierung ihre Stärken voll ausspielen.

Bei Single-Page Apps findet das Rendering der dynamischen Inhalte auf dem Client statt. Die Aufgabe besteht darin, Daten der Applikation performant auf der Oberfläche darzustellen.

Was sich trivial anhört, ist bei Single-Page-Anwendungen komplexer, da sich die Daten zur Laufzeit verändern können, sei es durch die Anforderung neuer Daten vom Server oder durch Interaktionen auf der Oberfläche. Um dem zu begegnen, haben sich verschiedene Strategien durchgesetzt, die im Folgenden näher erläutert werden.

Hierbei handelt es sich um die einfachste Art des Renderings, wie man es von serverseitigem Rendering kennt. Für die initialen Daten berechnet die Anwendung eine View-Repräsentation, die Daten werden quasi 1:1 auf der Oberfläche abgebildet, was in gerenderten DOM-Elementen resultiert.

Beim manuellen Rendering sind Entwickler dafür verantwortlich, auf Veränderungen des Applikations-States zu reagieren und sie performant auf der Oberfläche darzustellen. Auf der einen Seite bietet ein solches Vorgehen eine hohe Flexibilität, auf der anderen resultiert das in einem größeren Entwicklungsaufwand. Frameworks wie Backbone.js oder Ext JS sind ein gutes Beispiel für das Prinzip des manuellen Renderings.

Betrachtet man Data-Binding bezüglich der Render-Strategie, so ist das Rendering der View nicht selbst anzustoßen. Stattdessen sind View und ViewModel über Data-Attribute verbunden. Die Synchronisation zwischen ViewModel und View übernimmt dabei ein Data-Binding-Mechanismus, ähnlich dem bereits vorgestellten Konzept innerhalb des Abschnitts MVVM.

Schaut man sich das Data-Binding-Konzept von AngularJS 1.x an, ist die View reines Markup und das ViewModel ein JavaScript-Objekt, das das Framework verwaltet. Zum Ermitteln von Veränderungen führt AngularJS über alle Attribute des ViewModels Buch, die die View verwendet, und legt sogenannte Watcher an. Wenn sich etwas in der Applikation ändert, iteriert AngularJS über alle Watcher und stößt dann das Rendering an. Das ist das Prinzip des Dirty Checking.

Das Prinzip des sogenannten Virtual DOM stellt einen alternativen Rendering-Ansatz dar. Das Grundprinzip ist vergleichbar mit serverseitigem Rendering: Immer, wenn sich etwas ändert, rendert die Anwendung alles komplett neu. Würde man jedoch bei jeder Änderung das komplette DOM neu erzeugen, ginge das auf dem Client sehr langsam. Das Virtual DOM löst das Problem, da es sich um eine JavaScript-seitige Repräsentation des DOM handelt. Sie lässt sich bei jeder Änderung performant berechnen.

Anschließend wird das virtuelle DOM mit dem echten DOM verglichen, und nur die tatsächlichen Änderungen werden im echten DOM durchgeführt. React beispielsweise nutzt Virtual DOM für das Rendering.

In jeder Single-Page App gibt es die Möglichkeit, mit dem Server zu kommunizieren, um Daten anzufordern. Dazu werden via Ajax asynchrone HTTP-Anfragen an den Server gesendet. Umsetzen lässt sich das beispielsweise via XMLHttpRequest-Objekt, das Verwenden der Ajax-Methoden von jQuery oder durch die Fetch-API, deren Einsatz immer mehr zunimmt.

Eine weitere Datenquellen für Single-Page Apps ist beispielsweise der localStorage, in dem sich clientseitig Daten speichern lassen, die die Anwendung beim Start dort auslesen muss. Bei echtzeitfähigen Anwendungen können darüber hinaus WebSockets als Quelle dienen.

Es ist empfehlenswert, die Datenzugriffslogik der Applikation in einem separaten Modul zu kapseln. So lässt sich zu einem späteren Zeitpunkt zentral auf eine andere Technik umstellen, ohne den Rest der Applikation ändern zu müssen.

Asynchronität ist im Web allgegenwärtig. Daher sollte jede Single-Page App damit umgehen können. Callbacks sind in JavaScript die einfachste Möglichkeit, um mit Asynchronität zu arbeiten.

Insbesondere bei komplexeren, verschachtelten asynchronen Operationen, kann sich das schnell zur sogenannten Pyramid of doom – auch Callback Hell genannt – entwickeln:

Der Pyramid of Doom lässt sich unter anderem durch Promises entgegenwirken (Abb. 5).


Um dem entgegenzuwirken, finden immer häufiger Promises Verwendung. Sie sind in ES2015 spezifiziert und haben sich mittlerweile in vielen Frameworks als Standard etabliert. Ihr Aufbau sieht wie folgt aus:

const promise = new Promise((resolve, reject) => {
if (/* success */) {
resolve("success");
}
else {
reject(Error("error"));
}
});

promise.then((result) => {
console.log(result); // "success"
}, (err) {
console.log(err); // Error: "error"
});

Einem Promise werden dabei zwei Funktionen übergeben: eine für den Erfolgs- und eine für den Fehlerfall. Mit promise.then(...) lässt sich ein Promise konsumieren.

Darüber hinaus können Entwickler Observables (Event-Streams) für den Umgang mit Asynchronität verwenden.

Während der Entwicklung gibt es viele wiederkehrende Aufgaben wie das Erzeugen eines JavaScript-Bundles, das Minifizieren von JavaScript beziehungsweise CSS, Linting, das Ausführen von Tests und vieles mehr. Auch das Vorbereiten der Applikation für die Produktion, Continuous Integration und Continuous Delivery zählen dazu.

Zum Erleichtern des Entwickleralltags sollten Programmierer möglichst viel automatisieren, um sich auf die Entwicklung der eigentlichen Applikation konzentrieren zu können. Zur Automatisierung eignen sich JavaScript-Taskrunner wie Grunt, Gulp oder npm-Skripte. Für viele Tasks gibt es zudem Plug-ins, die sich je nach Bedarf des Projektes zu einem Gesamtworkflow kombinieren lassen.

In jeder Single-Page App gibt es grundlegende Konzepte, die immer gleich sind. Es hängt jedoch von den Anforderungen des Produkts ab, wie Entwickler Konzepte und Techniken kombinieren.

Bevor die Technologiefrage geklärt wird, ist die geplante Applikation zunächst hinreichend zu analysieren, um dadurch die Anforderungen und Herausforderungen kennenzulernen. Dazu sind Fragen zu Themen wie der Interaktion auf den einzelnen Seiten zu klären und herauszufinden, an welcher Stelle sich die größte Komplexität versteckt. So lässt sich sicherstellen, dass die simpelste Technik gewählt wird, um alle Anforderungen umzusetzen.

Das muss nicht immer ein komplettes Framework sein – im Gegenteil. Viele der beschriebenen Konzepte lassen sich leicht mit JavaScript umsetzen. Das npm-Ökosystem bietet zudem eine Vielzahl von schlanken, zielgerichteten Bibliotheken, die sich je nach Bedarf kombinieren lassen.

Frameworks geben zwar eine Struktur vor, was eine Hilfe bei der Umsetzung sein kann. Sie nehmen durch ihren Aufbau und ihre Wirkungsweise aber auch konzeptionelle Entscheidungen ab, was eine geringere Flexibilität für das Entwickeln und Umsetzen von Anforderungen zur Folge haben kann.

Allerdings ist gerade diese Flexibilität wichtig, da sich schlecht abschätzen lässt, wie sich die Applikation Jahre später weiterentwickelt und wie es sich über diesen Zeitraum mit der Aktualität des Frameworks verhält, dass so viele Design-Entscheidungen bestimmt hat. Grundlegende Architekturen und Patterns ändern sich seltener als komplette Frameworks. Daher ist es empfehlenswert, den Einsatz Letzterer zu hinterfragen und die Konzepte dahinter zu verstehen und zu verinnerlichen. So lassen sich auch schneller Designentscheidungen treffen. (jul)

René Viering
ist passionierter Frontend-Entwickler und JavaScript-Enthusiast bei der Micromata GmbH in Kassel.