Turbolinks! Warum, wieso, weshalb?

In einem internen Grüne-Wiese-Projekt™ haben wir uns darauf konzentriert, möglichst alle Features serverseitig umzusetzen und dennoch das Look-And-Feel einer Desktop-Anwendung zu erreichen.

Dies haben wir bis ins Extrem durchgezogen und sind mit nur wenig Javascript-Frontend-Code ausgekommen, obwohl diese Art der Anwendung - eine klassische Eingabemaske mit kaskadierender Filterung in 3 Ebenen - geradezu nach einer Single-Page-App ruft.

Beispiel kaskadierende Filterung

Um unser System flott antworten zu lassen, haben wir uns im Backend mit Presentern, Russian-Doll-Caching und Nebenläufigkeit beschäftigt (diese Themen verdienen sicher irgendwann ihre eigenen Artikel).

Nach aller Optimierung im Backend gibt es aber immernoch eine Reihe von Faktoren, die dafür sorgen, dass sich eine Webanwendung nicht wie eine Desktopanwendung anfühlt.

Bei jedem URL-Wechsel im Browser werden Assets (JS, CSS, Webfonts, …) heruntergeladen oder aus dem Cache geholt und anschließend interpretiert. Dies dauert je nach Größe der Assets einige Millisekunden, kann aber bei Backend-Antwortzeiten unterhalb 100ms zu einem Problem werden, das sich als ein Flackern darstellt.

Dieses Problem kann man klassisch nicht lösen, denn CSS + Javascript müssen geparst und evaluiert werden, damit sie auf die Webseite angewendet werden können. Eine Beschleunigung kann nur erreicht werden, indem die Menge (Größe) verringert wird.

Singe-Page-Apps haben dieses Problem nicht, da dort kein kompletter Seitenwechsel stattfindet und so müssen die Assets nicht erneut ausgewertet werden.

Das von Webfonts bekannte Flackern (FOUT) ist ein störender Faktor.

Dem kann man entgegenwirken, indem der Webfont im Head-Bereich der Seite geladen wird. Dies hat allerdings den Nachteil, dass jetzt das Rendering der gesamten Seite so lange blockiert wird, bis der Webfont fertig geladen ist.

Desweiteren ist das Scrolling innerhalb der einzelnen Ebenen über URL-Wechsel hinaus ein großes Problem. Man stelle sich vor, in allen 3 Ebenen wird zu einem Element gescrollt und nach dem Klick auf einen Link verschwinden die gewählten Elemente auf Nimmerwiedersehen.

Dies ist der zustandslosen Natur von HTTP-Anfragen geschuldet. Den konkreten Zustand einer Websiten-Ansicht (Scroll-Positionen, Formulareingaben, etc…) müsste man bei jeder Anfrage mitsenden und in jede Antwort wieder einbauen. Dafür bräuchte man wieder Code auf der Client-Seite, den wir ja eigentlich vermeiden wollten.

Im Marketing-Pitch von Basecamp (die Firma, die Turbolinks™ federführend entwickelt) wird vollmundig versprochen:

Get the performance benefits of a single-page application without the added complexity of a client-side JavaScript framework.

Dies wird mit folgenden Techniken erreicht:

Jeder Klick auf einen Link wird von Turbolinks abgefangen und verarbeitet. Dem Browser wird verboten, dem Link eigenständig zu folgen. Stattdessen ändert Turbolinks die angezeigte URL im Browser via HTML-5-History-API und startet eine Ajax-Anfrage an die neue URL und rendert deren Ergebnis ins DOM.

Während des Renderns wird der body der Seite ausgetauscht.

Möchte man dies unterbinden und einen “echten” Seitenwechsel erzwingen, so kann man folgendes annotieren.

<a href="/" data-turbolinks="false">Echter Seitenwechsel</a>

Dies funktioniert auch blockweise:

<div data-turbolinks="false">
  <a href="/">Echter Seitenwechsel</a>
</div>

Und kann in einem deaktiviertem Block auch selektiv wieder aktiviert werden:

<div data-turbolinks="false">
  <a href="/">Echter Seitenwechsel</a>
  <a href="/" data-turbolinks="true">Turbolinks</a>
</div>

Dadurch ist man flexibel und kann situativ entscheiden, ob man mit Turbolinks navigieren möchte oder nicht.

Assets

Da der Head-Bereich einer Webseite nicht mehr ausgetauscht wird, werden alle dort hinterlegten Assets nicht erneut geladen. Das Reevaluieren der Assets entfällt komplett.

Zusätzlich bietet Turbolinks an, Assets bei Änderung neuzuladen, sofern man dies im Markup annotiert.

= stylesheet_link_tag 'application', media: :all,
                      'data-turbolinks-track' => :reload

= javascript_include_tag 'application', async: true,
                         'data-turbolinks-track' => :reload

Auch der FOUT-Effekt kann dadurch komplett ignoriert werden, da nur beim ersten Laden einer Seite der Font blockierend geladen werden muss.

Bei zukünftigen Seitenwechseln ist der Font bereits komplett geladen.

Caching

Um das Gefühl einer Desktop-App/Single-Page-App nachzuahmen ist es notwendig, dass Seitenwechsel sehr rasant vonstatten gehen. Speziell bei Back-Forward-Navigation durch die Browser-Historie kann man es sich nicht leisten, erstmal den Lade-Spinner zu zeigen.

Turbolinks hat dafür eine eigene Technik entwickelt. Bei Navigation in der Historie wird ein bereits gecachtes Abbild der Seite sofort ins DOM eingesetzt, während noch eine Ajax-Anfrage an die ursprüngliche URL gesendet wird. Sollte sich der Content von gecachter und frisch angefragter Seite unterscheiden, so wird letztere ins DOM eingefügt.

Dies vermittelt einen Eindruck von rasend schnellen Seitenwechseln.

Fortschrittsanzeige

Da Turbolinks den üblichen Reload einer Seite umgeht, eine Ajax-Anfrage aber durchaus eine gewisse Zeit dauern kann, ist es notwendig, den Fortschritt dieser zu visualisieren. Standardmäßig wird eine Fortschrittsanzeige ähnlich der von iOS verwendet.

Man kann diese aber mit CSS jederzeit umstylen.

.turbolinks-progress-bar {
  background-color: red;
  
}

Scroll-Position

Leider gibt es keine eingebaute Möglichkeit, Scroll-Positionen im Cache abzuspeichern. Bei Back-Forward-Navigation ist es also notwendig, dies selbst in die Hand zu nehmen. Mit Hilfe der Lifecycle-Hooks ist es aber ein Leichtes, diese Funktionalität nachzurüsten.

In unserem konkreten Projekt war es so auch möglich, mehrere Scroll-Positionen innerhalb verschiedener Container abzuspeichern.

Hooks

Turbolinks bietet einige Events an, um sich in dessen Lebenszyklus einzuklinken. Die drei Wichtigsten sind:

  • turbolinks:load wird nach jeder erfolgreichen URL-Änderung und auch initial aufgerufen, sodass man ein Äquivalent zu jQuery.ready erhält, das innerhalb des Turbolinks-Kontext arbeitet.

  • turbolinks:before-cache wird aufgerufen, wenn eine Seite in den Client-Cache gelegt wird. Hier hat man die Möglichkeit, State im DOM zu persistieren.

  • turbolinks:render wird nach jedem Rendern angestoßen. Dieser Hook wird bei Back-Forward-Navigation sogar zweimal aufgerufen, einmal mit dem gecachten Seiteninhalt und einmal mit dem Neuen. Dies ist der ideale Ausgangspunkt um persistierten State wieder aus dem DOM zu extrahieren.

Persistente Elemente zwischen Seitenwechseln

Es gibt Bereiche einer Webseite, die nicht bei jedem Seitenwechsel server-seitig bereitgestellt werden können. Stellt man sich das Beispiel eines Warenkorbs vor, der im oberen Seitenbereich dargestellt wird, dann leuchtet es ein, diesen per Javascript zu updaten, wann immer ein Artikel hineingelegt wird.

Wenn man jetzt mit Turbolinks navigiert, würde dieser Bereich überschrieben werden.

<div id="shopping_cart" data-turbolinks-permanent>10 Artikel</div>

Mit dieser Annotation weißt man Turbolinks an, bei jedem Seitenwechsel den Inhalt der vorherigen Seite für diesen Bereich zu übernehmen. Die permanenten Elementen werden anhand ihrer ID verglichen und Turbolinks kümmert sich darum, den Inhalt und die Event-Listener aufrecht zu erhalten.

Wenn auch nicht von uns verwendet, soll hier noch kurz erwähnt werden, dass Turbolinks auch verwendet werden kann, um hybride Anwendungen für iOS und Android zu bauen.

Man verwendet dann native Elemente zur Navigation, die sich in den Lebenszyklus von Turbolinks einhängen. Dies erlaubt eine Erfahrung wie bei einer nativen App.

Turbolinks Desktop vs. Mobil

Fallstricke

Browserunterstützung

HTML-5-History-API und Request-Animation-Frame werden zwingend benötigt, damit Turbolinks funktioniert. Sind diese Mindestvorraussetzungen nicht erfüllt, deaktiviert es sich selbstständig und alles funktioniert auf klassische Weise.

Dies war für uns kein Problem, da wir abteilungsübergreifend mit dem aktuellsten Chrome arbeiten und es sich um ein internes Projekt handelte.

Möchte man allerdings öffentliche Webseiten mit Turbolinks ausstatten, sollte man dies im Hinterkopf behalten.

jQuery.ready

jQuery.ready sollte man nur mit Vorsicht mit Turbolinks zusammen verwenden. Dieses Event wird einmal im gesamten Turbolinks-Lebenszyklus aufgerufen, nämlich nach dem ersten Seitenaufruf. Wenn man partout nicht darauf verzichten mag oder bereits vorhandene Bibliotheken sich darauf stützen, kann man das Gem jquery-turbolinks verwenden.

Andere Events

In Verbindung mit Turbolinks ist zu beachten, dass die Event-Bindings nicht durch einen Seitenwechsel verschwinden. Wenn Elemente gern die gleichen Klassen und IDs auf verschiedenen Seiten verwenden, aber die Funktionalität unterschiedlich sein soll, kollidieren diese Handler miteinander.

Kein Reload => Memory-Leaks

Wenn man die berühmt-berüchtigten Element-Event-Handler verwendet ($("#some_id").click(…)), muss man sich bewusst sein, dass man unter Umständen sogar ein Memory-Leak in seine Anwendung einbaut.

Solche Events müssen unter allen Umständen wieder aufgeräumt werden, wenn Turbolinks die Seite entlädt. Tut man dies nicht, so bleiben immer Referenzen auf die gebundenen DOM-Elemente im Speicher.

Mit Event-Delegierung kann man diese Probleme komplett umgehen.

Komplexität

Während es durch Turbolinks deutlich weniger Code auf der Client-Seite gibt - was die Komplexität gegenüber einer SPA dort stark reduziert - steigt die Komplexität auf der Server-Seite unter Umständen enorm.

In einer klassischen SPA würde man für jede Stufe unserer 3-stufigen Kaskadierung einen eigenen Endpunkt schaffen und vom Browser konkurrierend anfragen lassen.

Server-seitig werden bei Turbolinks aber immer komplette HTML-Dokumente angefordert. Das bedeutet z.B. für unsere Kaskadierung, dass auf einen Schlag alle komplexen Geschäftsobjekte verarbeitet und dargestellt werden müssen.

Die gleichen Performance-Herausforderungen hätte man allerdings zu bewältigen, wenn man ohne Turbolinks (strikt server-seitig) entwickeln würde. Die eingangs angesprochenen Optimierungen via Nebenläufigkeit und Russian-Doll-Caching halfen uns dabei enorm.

Fazit

Abschließend kann man sagen, dass sich der Einsatz von Turbolinks für uns rentiert hat. Wir haben extrem wenig Frontend-Code schreiben müssen und die Anwendung fühlt sich sehr flott an.

Hat man sich einmal an den Turbolinks-Event-Lebenszyklus gewöhnt und die jQuery-Gewohnheiten über Bord geworfen, merkt man auf Entwicklerseite kaum noch einen Unterschied.