Eine Single Page Application in Plain JS implementieren

👨‍💻 Maximilian Röttgen, — 16 Minuten Lesezeit

Manchmal muss man aus der Not eine Tugend machen: Für ein privates (unveröffentlichtes) Bastelprojekt brauchte ich eine Möglichkeit, meinen JS-State über mehrere Seiten zu erhalten. LocalStorage und Konsorten kamen nicht in Frage, weil a) langweilig und b) das Jonglieren mit JSON Strings anstrengend wurde und ich c) Bedenken zur Performance hatte.
Siehe zu dem Thema auch diesen Artikel hier.

Gut, also LocalStorage schonmal nicht. Beim Arbeiten mit Vue für meinen Studentenjob war ich zu der Zeit bereits ein paar Mal über den Begriff Single Page Application (im Folgenden SPA) gestolpert und war neugierig, was es damit auf sich hat. Stellt sich heraus – eine SPA bietet genau das, was ich gerne wollte.

Was kann eigentlich eine SPA? permalink

Eine SPA stellt, wie der Name sagt, eine Anwendung auf einer einzigen Webseite dar. Wobei mit Webseite in diesem Fall die tatsächlich geladene HTML-Seite gemeint ist.

Dadurch ergeben sich zwei große Vorteile:

Knusprige Nutzer-Interaktion permalink

Dadurch, dass man sich bei der Navigation innerhalb der Anwendung die Client-Server-Kommunikation und damit Zeit spart, kann die Seite schneller auf den Nutzer reagieren. Denn solange keine Daten nachgeladen oder aufwändigere Berechnungen angestellt werden müssen, kann sämtliche Interaktion und Navigation synchron stattfinden.

Für mich war das in diesem Fall zwar nur ein netter Bonus, trotzdem hat sich die Performance der App durch die Migration auf eine SPA merklich[1] verbessert.

Entspanntes Programmieren permalink

Wo nicht zwischen HTML-Dokumenten hin- und hernavigiert wird, geht auch kein lieb gewonnener JavaScript-State verloren. Solange Variablen referenziert sind, bleiben sie vom Garbage Collector verschont. Alles, worum man sich noch kümmern muss ist, sein JavaScript so zu strukturieren, dass man den Überblick behält.

Insgesamt also zwei gute Gründe, die Arbeit auf sich zu nehmen.

Warum kein Angular/Vue/React? permalink

Die online vorherrschende Meinung scheint zu sein, dass man beim Bauen einer SPA quasi nicht umhinkommt, ein Framework einzusetzen. Und klar – Frameworks bieten Struktur, zusätzliche Features, vorgefertigte Lösungen und vieles mehr.

Klare Argumente gegen ein Framework waren für mich, dass ich

  1. keine Lust hatte, die bestehende App komplett über den Haufen werfen zu müssen
  2. den Strukturoverhead, den ein Framework mitbringt, für mein vergleichsweise kleines Projekt nicht in Kauf nehmen wollte
  3. die Herausforderung spannend fand, meine eigene Implementierung einer SPA zu entwerfen

Wo das geklärt ist, genug des Was und Warum – auf zum Wie.

Die App vorher permalink

Vor der Migration hatte meine App ungefähr folgende Struktur:

static/
_ css/
_ images/
_ javascript/
___ application.js
___ sub-application-1.js
___ sub-application-2.js
___ helper.js

templates/
_ application.html
_ sub-application-1.html
_ sub-application-2.html

So weit, so nichts besonderes.

Die SPA-Bastelanleitung permalink

Um jetzt von hier zu einer SPA zu kommen, braucht man folgende Dinge:

  1. Eine Haupt-HTML-Datei „To rule them all“
  2. Eine Möglichkeit, in diese HTML-Datei dynamisch die alten Inhalte hineinzuladen

Ich beschreibe nun mein (möglicherweise dilettantisches) Vorgehen chronologisch und fehlerbereinigt – Nachmachen auf eigene Gefahr!

One HTML to rule them all permalink

Dieser Schritt war wohl der einfachste:

<!DOCTYPE html>
<html lang="DE">
<head>
<meta charset="utf-8" />
<title>Application</title>

<script src="../webjars/jquery/3.3.1/dist/jquery.min.js"></script>
<script type="module" src="../javascript/router.js"></script>
</head>

<body>
<div id="app">
<!-- Und hier passiert später der Content -->
</div>
</body>
</html>

Zack, feddich – Haupt-HTML. Man muss jQuery übrigens nicht notwendigerweise verwenden, aber meine App setzt auf Bootstrap und wenn man das Paket sowieso schon geladen hat…

HTML dynamisch laden permalink

Jetzt ging der Spaß los. Erstmal musste ich die alte, langweilige und mittlerweile unnütze application.html und ihre Äquivalente loswerden. Dazu habe ich meinen alten HTML-Code innerhalb der äußersten div kopiert und innerhalb von Backticks in meine application.js eingefügt. Anschließend habe ich eine Funktion drumherumgebaut, die besagtes HTML unterhalb von meiner neuen Container-div mit der id="app" einfügt.

let renderHTML = () => {
return new Promise(resolve =>
$('#app').html(
`<h1>Application</h1>
<!-- Viel toller HTML-Code -->
`

);
)}

Das Ganze habe ich asynchron als Promise implementiert, damit ich meinen Code innerhalb von $(document).ready() weiter sinnvoll benutzen kann. Das sähe dann so aus:

renderHTML().then(() => {
// Was auch immer erst ausgeführt werden soll,
// Wenn das DOM fertig ist.
});

Das Vorgehen kann man jetzt für alle alten HTML-Dateien wiederholen und hat zumindest den ersten Schritt geschafft.

Was jetzt noch fehlt ist das Routing. Die Funktion renderHTML wird aktuell an keiner Stelle aufgerufen. Hier kommt die ominöse router.js ins Spiel, die oben bereits im HTML ist.

Routing permalink

Ein Router (das Ding in meiner router.js) wird benötigt, um URL-Eingaben parsen und an die richtige Stelle in der Applikation zu leiten. Entsprechend muss mein Router folgende Funktionen bieten:

  1. Die URL in der Adresszeile verstehen
  2. Die aufrufbaren URLs kennen
  3. Die passende renderHTML-Funktion aufrufen

Der fertige Router liest sich wie folgt:

'use strict';
import {application} from './application.js';
import {subApplication1} from './sub-application-1.js';
import {subApplication2} from './sub-application-2.js';

export let parseRequestURL = () => {
let url = location.hash.slice(1).toLowerCase() || '/';
let r = url.split('/');
let request = {
resource: null,
id: null,
action: null
};

request.resource = r[1]; // Klasse von Dingen, z. B. "books"
request.id = r[2]; // ID eines speziellen Dings, z. B. "9788580444490"
request.action = r[3]; // Aktion, die für 'Ding mit ID' ausgeführt wird, z. B. "create" oder "edit"

return request;
};

let route = () => {
let request = parseRequestURL();

let parsedURL =
(request.resource ? '/' + request.resource : '/') +
(request.id ? '/:id' : '') +
(request.action ? '/' + request.action : '');
let page = routes[parsedURL] ? routes[parsedURL] : application;

page.display();
};

let routes = {
'/': application,
'/sub-application-1/:id': subApplication1,
'/sub-application-1/:id/edit': subApplication1,
'/sub-application-2': subApplication2
};

window.addEventListener('hashchange', route);
window.addEventListener('load', route);

Dieser Router ist praktisch 1:1 der, der hier von Rishav Sharan benutzt wurde. Im Allgemeinen hat mir dieser Blogpost ungemein geholfen und ist definitiv eine Lektüre wert, wenn mir jemand nacheifern möchte.

Von Oben nach Unten erklärt:

Die Imports

'use strict';
import {application} from './application.js';
import {subApplication1} from './sub-application-1.js';
import {subApplication2} from './sub-application-2.js';

Importiert werden hier die Wrapper-Objekte der anzuzeigenden Seiten. Die befinden sich in den passenden JavaScript-Dateien und sehen jeweils wie folgt aus:

export const application = {
display: () => {
renderHTML()
.then(() => {
// Per JavaScript generiertes HTML anhängen
return renderGeneratedHTML();
})
.then(() => {
// Daten laden
loadData();

// Event-Handler registrieren
registerEventHandlers();
})
.catch(ex => console.error(ex));
}
};

Die URL verstehen

export let parseRequestURL = () => {
let url = location.hash.slice(1).toLowerCase() || '/';
let r = url.split('/');
let request = {
resource: null,
id: null,
action: null
};

request.resource = r[1];
request.id = r[2];
request.action = r[3];

return request;
};

Erstmal hole ich mir vom Browser die aktuelle URL ab dem #-Symbol. Die Raute deshalb, weil Browser praktischerweise alles Nachfolgende ignorieren. Bei einer URL wie https://localhost:8080/#/sub-application-1/9788580444490/edit wird so z. B. nicht versucht, auf dem Server etwas vom Verzeichnis /sub-application-1/9788580444490/edit aufzurufen. Ich bin also der einzige, der etwas mit dem so erhaltenen String tut:

Zunächst zerlege ich den URL-String in einzelne, durch / getrennten Abschnitte, die in meiner Anwendung (ganz im Sinne von CRUD/REST) mit resource, id, und action korrespondieren.

Dann speichere ich die so erhaltenen Parameter im request-Objekt und gebe das ganze an denjenigen, der es haben wollte, nämlich…

Die Routing-Funktion

let route = () => {
let request = parseRequestURL();

let parsedURL =
(request.resource ? '/' + request.resource : '/') +
(request.id ? '/:id' : '') +
(request.action ? '/' + request.action : '');
let page = routes[parsedURL] ? routes[parsedURL] : application;

page.display();
};

Diese Funktion holt sich eine Anfrage, die von parseRequestURL vorformatiert und in die Struktur

  • resource
  • id
  • action

gebracht wurde und sucht das passende Wrapper-Objekt.

Im Anschluss wird aus der vorformatierten Anfrage wieder ein String zusammengesetzt, um diesen mit der Liste der bekannten Routen (siehe weiter unten) abzugleichen. Eine parsedURL könnte etwa so aussehen: sub-application-1/:id/edit

Jetzt ist natürlich die Frage, wie sub-application-1 weiß, welches Objekt geladen werden soll, denn die ID ist in parsedURL augenscheinlich nicht mehr enthalten. Braucht man die ID, so kann man sie mit folgender Methode[2] bekommen:

let getID = () => {
let url = new URL(window.location.href);
return parseRequestURL().id;
};

Achtung: Das implementierende Modul muss die Methode parseRequestURL vom Router importieren. Mehr zu JavaScripts import/export Funktionen hier.

Router, Router, sag mir den Weg

let routes = {
'/': application,
'/sub-application-1/:id': subApplication1,
'/sub-application-1/:id/edit': subApplication1,
'/sub-application-2': subApplication2
};

Die bekannten Routen habe ich in einem Objekt als Zuordnung von den URL-Strings zu meinen Wrapper-Objekten gespeichert. Bei Routen auf Ressourcen mit einer bestimmten ID möchte ich natürlich nicht jede ID einzeln auflisten, daher der Platzhalter :id. Die „echte“ ID wird, wie oben erwähnt, bei Bedarf von der aufgerufenen Seite selbst erfragt.

Wer nicht fühlen kann muss window.addEventListener

window.addEventListener('hashchange', route);
window.addEventListener('load', route);

Klar – irgendwie muss der Router ja mitbekommen, dass eine URL aufgerufen (bzw. geladen) wurde. Das passiert über die beiden ganz unten im Router registrierten Eventhandler.

Jetzt sind HTML und Router soweit fertig. Es beginnen die Reparatur-, Umbau- und Aufräumarbeiten im JavaScript.

Aus alt mach neu permalink

An diesem Punkt bin ich einmal durch meine Anwendung geflitzt und habe alle Links aktualisiert, bzw. mit hippen #s aufgepeppt. Damit sollte die Anwendung theoretisch wieder zumindest so laufen wie vorher.

Tut sie das nicht, fehlt (oder fehlte zumindest bei mir) garantiert in irgendwo im JavaScript ein "use-strict", ein export oder ein import.

Und damit ist es auch schon vollbracht – eine einfache SPA, nur mit JavaScript.

Was habe ich draus gelernt? permalink

Wohl am meisten genutzt hat die ganze Aktion meinem Verständnis davon, welche Aufgaben ein Router bei einer SPA übernimmt. Und ich habe festgestellt, dass das alles gar nicht so gruselig ist, wie es in den Dokumentationen und Tutorials diverser Frameworks aussieht. Außerdem konnte ich endlich mal das ganze Wissen, was ich hier und da über ECMAScript 6 aufgeschnappt habe, anwenden. Module, Klassen, Methodendefinitionen als Arrow-Function – das alles habe ich so zum ersten Mal wirklich eingesetzt.

Überraschendes weiteres Plus: Die unfassbar nervigen und unübersichtlichen Script-Imports im HTML haben sich durch die Migration auf sage und schreibe einen Import reduziert. Jetzt muss ich noch lernen, wie ich mein CSS besser Strukturiere und schon ist mein HTML-Header keine Kurzgeschichte mehr, yay!

Ob ich beim nächsten Projekt gleich mit so einer SPA-Architektur starte, kann ich allerdings nicht sagen. Während das beschriebene Umziehen und Anpassen vom HTML ziemlich bequem machbar war, stelle ich es mir unpraktisch vor, mein komplettes HTML in einem JavaScript-Editor ohne Code-Completion usw. zu schreiben. Hier ist zumindest für kleine Projekte vermutlich VueJS die bessere Wahl.

Eine SPA-Architektur werde ich in Zukunft aber vermutlich häufiger einsetzen. Denn so bequem und übersichtlich hatte ich es als JavaScript-Entwickler noch nie. Und die Vorteile im Ergebnis sind ja auch ganz nett. 😎


  1. Achtung, gefühlter Fakt! Persönliche Erfahrungen können abweichen. ↩︎

  2. Es gibt aber mit Sicherheit andere, möglicherweise elegantere Wege, das zu lösen – so könnte man sicherlich auch im Router der display-Funktion des Wrapper-Objekts die ID als Argument mitgeben. Für mich war die o.g. Lösung fürs Erste gut genug. ↩︎