Vor langer Zeit schrieb ich einen Artikel namens href ist niemals #. In diesem wandte ich mich gegen die damals grassierende Unsitte, Links zu bauen, die außer einem Onlick-Handler nur das Attribut href="#"
tragen. Das ist aus gleich zwei Gründen suboptimal: zum einen sollten Ressourcen im Web immer durch eine URL identifizierbar sind und zum anderen fehlt #-Links ein Alternativ-Dokument für Besucher ohne JavaScript. Nun hat sich aber seit dem Aussterben der Dinosaurier einiges getan und heute kann es unter Umständen durchaus sinnvoll sein, Webapps zu schreiben, die JS zwingend voraussetzen – der HTML5-Revolution sei dank! In diesem Zusammenhang erreichte mich die Frage, was man denn nun für JavaScript-Links anstelle von # als URL verwenden sollte. Wie bringt man einer JS-App URLs bei? Mit der HTML5 History API ist das kein (großes) Problem.
Ziel und Ausgangslage
Das Ziel ist im diesem Beispiel, eine Webapp zu bauen, die drei durch JavaScript definierte Zustände hat: entweder steht in einem <p>
-Element der Text „Foo“ oder „Bar“ oder das Element ist leer. Mittels Links soll zwischen den Zuständen hin- und hergeschaltet werden können. Üblicherweise würde man diese Webapp, die allein aus der Datei index.html
besteht, in etwa wie folgt bauen:
<!doctype html>
<title>FooBar Beta 2.0</title>
<p>
<a onclick="foo()" href="#">Foo-Zustand</a>
<a onclick="bar()" href="#">Bar-Zustand</a>
</p>
<p id="content"></p>
<script>
var content = document.getElementById('content');
var foo = function(){
content.innerHTML = 'Foo';
return false;
};
var bar = function(){
content.innerHTML = 'Bar';
return false;
};
</script>
Die Links lassen sich nun zwar anklicken um zwischen „Foo“ und „Bar“ hin- und her zu schalten, aber es fehlen eben die URLs für die beiden Zustände und auch der Zurück-Button des Browsers macht nicht das, was er soll. Das wollen wir ändern.
URLs für die Zustände
Wenn wir unsere beiden Links mit echten URLs ausstatten …
<p>
<a onclick="foo()" href="foo">Foo-Zustand</a>
<a onclick="bar()" href="bar">Bar-Zustand</a>
</p>
… ändert sich zunächst nichts, denn wenn die Links angeklickt werden, werden nur die Onclick-Handler aktiv. Die URLs selbst funktionieren nicht, denn unter diesen Adressen sind natürlich keine HTML-Seiten zu finden. Und das soll auch so bleiben; foo
und bar
sollen schließlich beide keine eigenen Dokumente sein, sondern unterschiedliche Zustände für index.html
repräsentieren. Also leiten wir einfach die URLs für die beiden Zustände auf unsere App-Datei um:
RewriteEngine on RewriteRule foo$ index.html [L] RewriteRule bar$ index.html [L]
Nun führen alle URLs auf unsere Webapp. Um die App die beiden Zustände repräsentieren zu lassen, müssen wir nur noch die jeweilige URL auslesen und den entsprechenden Inhalt anzeigen:
// Beim laden der Seite die URL parsen und den richtigen Inhalt anzeigen
window.addEventListener('load', function(){
// Letzte drei Zeichen der URL sind entweder "foo" oder "bar"
var zustand = window.location.href.substr(-3);
// Je nach URL mit Foo- oder Bar-Zustand starten
if(zustand === 'foo'){
foo();
}
else if(zustand === 'bar'){
bar();
}
}, false);
Wenn wir nun noch die onlick-Handler aus den Links entfernen …
<p>
<a href="foo">Foo-Zustand</a>
<a href="bar">Bar-Zustand</a>
</p>
… funktioniert die App inklusive URLs! Das ist allerdings auch kein Wunder, denn im Prinzip haben wir die App einfach de-ajaxifiziert. Jeder Klick auf die Links lädt jetzt die Seite neu, die dann wiederum die URL ausliest und per JavaScript den richtigen Content lädt. Wie re-ajaxifizieren wir nun die App so, dass sie sowohl URLs benutzt als auch nicht ständig neu lädt? Hier kommt die History API ins Spiel.
Das History-Script
Die meisten werden wissen, dass man die Browser-History per JavaScript ansprechen kann um zum Beispiel mit window.history.back()
die Funktionalität eines Zurück-Buttons nachzubilden. Seit einiger Zeit erlauben moderne Browser nicht nur das Auslesen der History, sondern auch deren Manipulation. Wie immer bietet MDN einen detaillierten und gleichzeitig verbraucherfreundlichen Überblick, aber ganz kurz gesagt bietet die History-API folgende neuen Features:
- mit
window.history.pushState()
kann man einen neuen Eintrag in der Browser-History anlegen. Der Funktion übergibt man ein Status-Objekt, einen Titel und eine URL, die den neuen Status repräsentiert window.history.replaceState()
ersetzt den aktuellen History-Eintrag, statt einen neuen anzulegen- Das auf
window
feuerndepopstate
-Event teilt mit, wenn der aktuelle History-Eintrag wechselt, also wenn zum Beispiel der Nutzer auf den Zurück-Button klickt und damit einen Schritt in der History zurück geht.
Wenn wir nun möchten, dass unsere Links beim Anklicken nicht nur den Zustand ändern, sondern auch neue History-Einträge anlegen, ist das mit window.history.pushState()
ein Kinderspiel:
// Beim Link-Klick den richtigen Inhalt anzeigen und die History anpassen
document.querySelector('a[href=foo]').addEventListener('click', function(evt){
evt.preventDefault(); // Dies verhindert den "normalen" Aufruf der Link-URL
history.pushState(null, '', 'foo'); // Neuer History-Eintrag "foo"
foo();
});
document.querySelector('a[href=bar]').addEventListener('click', function(evt){
evt.preventDefault(); // Dies verhindert den "normalen" Aufruf der Link-URL
history.pushState(null, '', 'bar'); // Neuer History-Eintrag "bar"
bar();
});
Die ersten beiden Argumente von window.history.pushState()
sind ein Status-Objekt und der Titel für den Histroy-Eintrag. Beides brauchen wir in unserem Fall nicht; der Titel ist sowieso recht nutzlos und das Status-Objekt, das normalerweise den Zustand der App mit einen History-Eintrag verknüpfen würde (ändert sich der aktuelle History-Eintrag, dann ändert sich auch das Status-Objekt entsprechend) brauchen wir auch nicht. Unser App-Status ist komplett aus der URL abzulesen, also übergeben wir der Funktion nur die jeweiligen URLs foo
und bar
. Da wir ja schon den Code zum Parsen dieser URL im load
-Event haben, brauchen wir diesen nur so umzubauen, dass er auch auf das popstate
-Event reagiert.
// Wenn sich die History ändert oder die Seite neu lädt, den
// richtigen Zustand herstellen
var changeState = function(){
// Letzte drei Zeichen der URL sollten entweder "foo" oder "bar" sein
var zustand = window.location.href.substr(-3);
// Je nach URL mit Foo-, Bar- oder Leer-Zustand starten
if(zustand === 'foo'){
foo();
}
else if(zustand === 'bar'){
bar();
}
else {
content.innerHTML = '';
}
};
window.addEventListener('load', changeState, false);
window.addEventListener('popstate', changeState, false);
Der Statuswechsel wird nun nicht nur beim Laden der Seite ausgeführt, sondern auch, wenn sich der Nutzer durch die History bewegt. Es ist beim Umbau einzig darauf zu achten, dass der Zustand neben „Foo“ und „Bar“ nun ja auch via Zurück-Button wieder auf „Leer“ gesetzt werden kann, ansonsten bleibt alles beim Alten. Fertig ist die reine JS-App mit echten URLs und voller History-Funktionalität!
Browser, Tools und Links
Die History API wird von allen vernünftigen aktuellen Browsern unterstützt und ab Version 10 auch vom Internet Explorer, doch die einzelnen Implementierungen unterscheiden sich auf recht lästige Art und Weise. So feuern Beispielsweise einige Browser das popstate
-Event beim Laden der Seite oder wenn sich der Hash ändert, andere nicht. Um sich all diese Probleme vom Leib zu halten und für ältere Browsern keine Extrawürste braten zu müssen, empfiehlt sich der Einsatz von History.js. Hiermit erhält man eine gemeinsame History-API für alle Browser, die sich genau wie das Original verhält, sämtliche Macken repariert und die auch mit allen gängigen JavaScript-Frameworks klarkommt. Weitere Links, die von Interesse sein könnten:
- Spezifikationen von W3C und WHATWG
- Liste der Unterschiede zwischen den diversen Browsern
- URI.js ist das Werkzeug der Wahl zum Parsen von URLs
- History.js vereinheitlicht die verschiedenen Browser
Kommentare (11)
Rodney Rehm ¶
14. Februar 2012, 12:03 Uhr
Auch nicht dämlich mal gelesen zu haben: URL Design - für diejenigen, die noch nicht wissen wie man URL-Strukturen aufbauen sollte.
trenc ¶
14. Februar 2012, 12:32 Uhr
Übersehe ich etwas Essentielles, oder warum nimmt man dafür nicht einfach Anker (#foo und #bar). Das funktioniert ja dann auch ohne JS, wenn man unter den IDs die entsprechenden Inhalte ablegt.
Marc ¶
14. Februar 2012, 12:42 Uhr
DAS musst Du mal näher erläutern :-)
Peter Kröner ¶
14. Februar 2012, 12:48 Uhr
Ohne Javascript funktionieren diese Fragment-Links auch nur nur dann, wenn man jederzeit allen denkbaren Inhalt auf der Seite hat und das ist in den seltensten Fällen möglich. Ansonsten ist das einfach Zweckentfremdung der Fragments und die kann man dann auch nicht mehr für ihren eigentlichen Einsatzzweck (springe in dem geladenen Content an Position X) verwenden. Ausführlicher wird das Problem hier besprochen.
Das heißt nicht, dass Fragment-URLs immer die falsche Wahl sind (Ich hab selbst ein Projekt, in dem ich Fragments als URL-Ersatz verwende), aber sie sind es oft.
trenc ¶
14. Februar 2012, 13:23 Uhr
Stimmt, das mit dem Vorhalten des Contents habe ich so nicht bedacht. Also hab' ich wohl doch etwas übersehen.
Danke für den Link, da les' ich mich gleich mal ein.
erlehmann ¶
14. Februar 2012, 13:55 Uhr
Schöne Erklärung. Aber: Beim Laden URLs mit JavaScript parsen, davon würde ich aus Zugänglichkeits-Gründen mittlerweile ganz die Finger lassen – auch, wenn es sich eher um eine dynamische Web-Anwendung als eine Web-Seite handelt. Kaputte HTTP-Semantik macht nämlich früher oder später irgend jemandem Probleme.
Peter Kröner ¶
14. Februar 2012, 14:08 Uhr
Also wenn ich die Wahl zwischen Problemen und Webapp-Nichtbauen habe, nehme ich ersteres. Manches HTML5-Gefrickel ist eben reiner JS-Sport und wenn man URLs rauslässt, wird es nicht wesentlich besser.
erlehmann ¶
14. Februar 2012, 15:28 Uhr
Klar, verstehe ich. Nur ist die Auswahl zwischen problematischer Umsetzung und Nicht-Bauen eben oft genug ein falsches Dilemma. Dein Beispiel zeigt ja schlicht anderen Text; ein serverseitiger Fallback ist in diesem Fall eher Fleißarbeit.
Peter Kröner ¶
14. Februar 2012, 15:36 Uhr
Sicher ist das manchmal ein falsches Dilemma, aber manchmal eben auch nicht. Das Beispiel in diesem Artikel hätte natürlich auch auch komplett ohne jedes JS funktioniert (oder mit Script plus Fallback) aber das war ja nur ein möglichst simples Beispiel um die History API zu demonstrieren. Andere Anwendungen sind nicht so simpel.
Christian H. ¶
23. Januar 2013, 18:27 Uhr
Ist es möglich, das
popstate
-Event mitevent.preventDefault()
abzubrechen?Peter Kröner ¶
24. Januar 2013, 12:45 Uhr
Das popstate-Event hat wie alle Events eine
preventDefault()
-Methode. Was genau möchtest du denn erreichen?