Mit Server-side Rendering fit fürs offene Web

Angular für öffentliche Webportale
Keine Kommentare

Angular und andere SPA-Frameworks punkten vor allem bei Geschäftsanwendungen. Einmal in den Browser geladen, erlauben sie ein flüssiges Arbeiten, ohne weitere Seiten anfordern zu müssen. Bei öffentlichen Webportalen gelten jedoch andere Regeln: Hier muss den Benutzern unter anderem sehr schnell eine erste Ansicht geboten werden, um Absprünge zu verhindern. Das lässt sich erreichen, indem die Anwendung die erste Seite am Server vorrendert.

Dieser Artikel wird zeigen, wie dieser Spagat, der das Beste aus beiden Welten bieten soll, mit Angular realisierbar ist. Das dazu verwendete Beispiel findet sich wie immer in meinem GitHub-Account.

Motivation für serverseitiges Rendering

Statistiken von Internetkonzernen wie Google oder Amazon zeigen ganz deutlich, dass bereits eine Verzögerung von mehreren hundert Millisekunden zu einem deutlichen Umsatzrückgang führt. Gerade ein erster Seitenaufbau verlangsamt sich beim Einsatz von SPAs, die auf clientseitiges Rendering setzen. Wie Abbildung 1 veranschaulicht, muss nämlich einiges geschehen, ehe dort aktuelle Daten angezeigt werden können.

Abb. 1: Bei SPAs vergeht viel Zeit, bis das initiale clientseitige Rendering stattfinden kann

Abb. 1: Bei SPAs vergeht viel Zeit, bis das initiale clientseitige Rendering stattfinden kann

Zunächst mal gilt es, die Seite selbst zu laden. Dann lädt die SPA die einzelnen JavaScript Bundles, und der darin enthaltene Code lädt anschließend die Daten. Erst danach ist die SPA in der Lage, sinnvolle Informationen zu präsentieren.

Bei einer klassischen Website, die auf serverseitiges Rendering (SSR) setzt, kann das Rendering bereits nach dem Abruf der Seite selbst erfolgen. Dadurch, dass die Seite bereits alle Daten enthält, können Suchmaschinen sie auch besser auswerten. Diese werden zwar auch bei clientseitig gerenderten Inhalten immer besser, wer jedoch auf Nummer sicher gehen möchte, setzt in Sachen SEO auf SSR.

Ein weiterer Vorteil des serverseitigen Ansatzes ist, dass andere Portale einfacher eine Linkvorschau erzeugen können. Genau aus diesen Gründen versucht man, für öffentliche Portale die Vorzüge von SSR mit denen des Renderings auf dem Client zu kombinieren und das Beste aus beiden Welten zusammenzuführen. Um das zu erreichen, führt bereits der Server die SPA aus, um eine erste Seite samt Inhalten zu erzeugen (Abb. 2). Der Browser rendert diese augenblicklich und fordert die Bundles an. Sobald diese eingetroffen sind, übernimmt die clientseitige Logik und ermöglicht ein flüssiges Arbeiten.

Abb. 2: Kombination von server- und clientseitigem Rendering

Abb. 2: Kombination von server- und clientseitigem Rendering

Performancemessung mit Lighthouse

Bevor ich hier auf die Implementierung von SSR mit Angular eingehe, möchte ich die zuvor getätigten Behauptungen mit einer Performancemessung untermauern. Dazu kommt das bei Google entwickelte Chrome-Plug-in Lighthouse zum Einsatz. Zunächst möchte ich damit die Performance meiner Testanwendung beim Einsatz von clientseitigem Rendering analysieren.

Um realistische Bedingungen zu schaffen, kommt ein Production Build zum Einsatz:

ng build --prod

Zur Ausführung der Anwendung nutze ich den einfachen, kommandozeilenbasierten Webserver local-web-server, der auch eine Komprimierung der zu übertragenden Inhalte erlaubt. Da er auf Node.js basiert, lässt er sich via npm installieren (npm i -g local-web-server). Der Start erfolgt auf der Kommandozeile im Root des Projekts:

ws -z --spa index.html -o -d dist/browser

Der Schalter -z aktiviert die Komprimierung, –spa verweist auf die Startseite der SPA, -o öffnet den Browser nach dem Start und -d gibt das Verzeichnis mit dem Production Build an.

Nach dem Start findet sich Lighthouse in der Entwicklerkonsole unter Audits. Ein Performanceaudit mit der Standardeinstellung liefern die in Abbildung 3 dargestellten Resultate.

Abb. 3: Performancemessung ohne SSR

Abb. 3: Performancemessung ohne SSR

Wichtig für die hier durchgeführte Betrachtung sind die Messwerte First Meaningful Paint und Time to Interactive. Ersterer repräsentiert die verstrichene Zeit bis zur Anzeige erster sinnvoller Inhalte; Letzterer die Zeit, die verstreicht, bis die Anwendung auf Benutzereingaben reagiert. Gerade First Meaningful Paint weist hier einen sehr problematischen Wert auf, vor allem wenn man bedenkt, dass lediglich eine sehr einfache Testanwendung untersucht wurde. Dieses Problem lässt sich mit SSR lösen!

Anwendungen um Server-side-Rendering erweitern

Zum Aktivieren von serverseitigem Rendering lässt sich die Angular CLI nutzen:

ng add @nguniversal/express-engine --clientProject flight-app

Der Schalter clientProject ist notwendig, da ein Angular CLI Workspace aus mehreren Projekten bestehen kann. Der hier zu verwendende Wert findet sich in der angular.json.

Diese Anweisung aktualisiert ein paar Dateien und generiert außerdem weitere, die sich um SSR kümmern. Unter den generierten Dateien befindet sich eine main.server.ts zum Bootstrappen der Anwendung für den serverseitigen Betrieb (Abb. 4).

Abb. 4: Für SSR generierte Dateien (Auswahl)

Abb. 4: Für SSR generierte Dateien (Auswahl)

Sie verweist auf eine Datei app.server.module.ts, die das Root-Modul für den Einsatz am Server bereitstellt. Dieses Modul wiederum importiert das Hauptmodul für den clientseitigen Betrieb, das sich in der Datei app.module.ts befindet. Diese Konstellation erlaubt es, bestehende Angular Services für SSR auszutauschen. Das ist auch häufig notwendig, da serverseitig andere Bedingungen vorherrschen. Beispielsweise gestaltet sich ein Redirect oder ein Zugriff auf Cookies serverseitig anders als im Browser.

In einer weiteren, hier nicht gezeigten Datei namens server.ts befindet sich der Code für einen einfachen Node.js/Express-basierten Webserver, der die kompilierte Anwendung lädt und mittels SSR bereitstellt. Um einen fairen Vergleich mit dem zuvor beschriebenen Messergebnis zu erlauben, habe ich für diesen Server die Möglichkeit zur Komprimierung nachgerüstet. Hierzu kommt das npm-Paket compression zum Einsatz.

Nach der Installation via npm (npm i compression –save) kann es als Express-Middleware in der server.ts registriert werden:

import * as compression from 'compression';
[…]
app.use(compression());

Zum Kompilieren des Servers kommt anschließend das npm-Skript build:ssr, das der oben beschriebene Aufruf von ng add ebenfalls generiert hat, zum Einsatz:

npm run build:ssr

Performancevergleich

Nach dem Start der SSR-Lösung mit dem generierten Script serve:ssr (npm run serve:ssr) können die getätigten Performancemessungen wiederholt werden (Abb. 5).

Abb. 5: Performancemessung mit SSR

Abb. 5: Performancemessung mit SSR

Es fällt auf, dass sich der Messwert First Meaningful Paint drastisch verbessert hat. Das bedeutet, dass der Benutzer viel schneller erste sinnvolle Informationen erhält, was wiederum die Absprungrate verringert. Das bedeutet aber auch, dass die erste abgerufene Seite größer ausfällt, und das verschlechtert den Wert für Time to Interactive ein wenig. Diese Beobachtung lässt sich immer beim Einsatz von SSR machen: Man tauscht also eine drastische Verbesserung bei einem Messwert gegen eine geringfügige Verschlechterung bei einem anderen.

Das bedeutet aber auch, dass sich die gesamte Startgeschwindigkeit gar nicht verbessert. Da der Benutzer jetzt jedoch schneller sinnvolle Informationen sieht, hat er das Gefühl, dass die Anwendung rascher startet. Das verhindert Absprünge!

NodeJS-Server

Die hier verwendete Lösung setzt voraus, dass ein zumindest sehr schlanker Node-Server neben dem bereits vorgesehenen Webserver betrieben wird. Für den Aufrufer lässt sich das durch Vorschalten eines Reverse Proxys transparent gestalten.

Als Alternative dazu besteht die Möglichkeit, den für SSR notwendigen Node.js-Code auch via ASP.NET Core anzustoßen. Dies führt jedoch zu einer starken Kopplung zwischen dem Client- und dem Serverteil. Das sogenannte statische Pre-Rendering kommt hingegen gänzlich ohne Node.js-Code zur Laufzeit aus. Der nächste Abschnitt geht darauf ein.

Statisches Pre-Rendering

Statisches Pre-Rendering bedeutet, dass das Vorrendern des ersten Seitenaufrufs bereits im Build-Prozess erfolgt. Die so erzeugte Seite kann anschließend in die Startdatei, z. B. index.html, zurückgeschrieben werden. Dynamische Inhalte lassen sich auf diese Weise natürlich nicht vorrendern, sehr wohl jedoch das Grundgerüst der Anwendung.

Diese sogenannte Shell besteht meist aus einem Kopf- und Fußbereich und Navigationselementen. Außerdem kann sie auch schon mit ein paar Platzhaltern bzw. Ghost Elements den Arbeitsbereich andeuten. Auch wenn die eigentlichen Inhalte hier erst über den clientseitigen Code angefordert werden, bewirkt statisches Pre-Rendering, dass der Benutzer rascher einen ersten Seitenaufbau erhält.

Das hier betrachtete Beispiel lässt sich einfach um statisches Pre-Rendering erweitern. Dazu kann zunächst die Datei server.ts dupliziert werden. Ich nenne diese Kopie prerender.ts. Anschließend ist dort der Codeanteil, der den Node.js/Express-Server startet, durch Routinen, die das Pre-Rendering durchführen und das Ergebnis in die index.html zurückschreiben, zu ersetzen (Listing 1).

import 'zone.js/dist/zone-node';
import {enableProdMode} from '@angular/core';

// Import module map for lazy loading
import { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader';

import {join} from 'path';
import { renderModuleFactory } from '@angular/platform-server';
import { readFileSync, writeFileSync } from 'fs';

// Faster server renders w/ Prod mode (dev mode never needed)
enableProdMode();

const DIST_FOLDER = join(process.cwd(), 'dist/browser');
const INDEX_FILE = join(DIST_FOLDER, 'index.html');

// leave this as require() since this file
// is built dynamically from webpack
const {
    AppServerModuleNgFactory,
    LAZY_MODULE_MAP
  } = require('./dist/server/main');

// Prerender Shell und write it into index.html
renderModuleFactory(AppServerModuleNgFactory, {
  document: readFileSync(INDEX_FILE, { encoding: 'UTF-8'}),
  url: '/',
  extraProviders: [
    provideModuleMap(LAZY_MODULE_MAP)
  ]
}).then(html => {
  writeFileSync(INDEX_FILE, html, { encoding: 'UTF-8' });
});

Die hier verwendete LAZY_MODULE_MAP referenziert sämtliche für Lazy Loading konfigurierten Module und stellt sicher, dass sie beim Pre-Rendering zur Verfügung stehen. Das Herzstück stellt der Aufruf der Funktion renderModuleFactory dar. Sie nimmt die Factory entgegen, die Angular aus dem Root-Modul generiert. Außerdem erhält sie den Inhalt der index.html sowie die zu rendernde Route. Das Ergebnis ist ein Promise mit einem HTML-String, den das Beispiel in die index.html zurückschreibt.

Dieses Listing lässt auch schon erahnen, dass solch eine Lösung mehrere Routen vorrendern könnte. Die dadurch entstehenden HTML-Dateien können in weiterer Folge über serverseitige Regeln für verschiedene URLs herangezogen werden. Somit lassen sich mehrere statische Einsprungspunkte für eine Anwendung schaffen.

Damit das npm-Skript build:ssr diese neue Datei ebenfalls kompiliert, ist es in der Datei webpack.server.config.js einzutragen:

entry: {
    // This is our Express server for Dynamic universal
    server: './server.ts',
    prerender: './prerender.ts'
  },

Auch diese Konfigurationdatei wurde beim ursprünglichen Einrichten von SSR mittels ng add generiert. Nach einem erneuten Kompilieren (npm run build:ssr) kann das Pre-render-Skript zum Aktualisieren der index.html aufgerufen werden:

node dist/prerender.js

Fazit und Ausblick

SSR verbessert die wahrgenommene Startgeschwindigkeit drastisch und senkt somit die Absprungrate. Genau das ist bei öffentlichen Weblösungen essenziell. Außerdem erhält der Benutzer bei diesem Ansatz das Beste aus beiden Welten: Den raschen Programmstart sowie das interaktive Verhalten einer SPA.

Während dieser Artikel die Grundlagen von SSR besprochen hat, fangen die eigentlichen Herausforderungen erst nach solch einem grundlegenden Set-up an: Beispielsweise müssen die Unterschiede zwischen Client und Server durch Bereitstellen separater Services oder durch entsprechende Verzweigungen kompensiert werden. Beispiele für solche Unterschiede sind Browser-APIs wie der Session Storage oder das Geo-Location-API, die am Server nicht zur Verfügung stehen. Aber auch Cookies und Umleitungen sind auf dem Server anders handzuhaben.

Außerdem möchte man in der Regel verhindern, dass Daten, die bereits im Rahmen des SSR geladen wurden, am Client noch einmal via HTTP anzufordern sind. Somit bedarf es eines Mechanismus, der diese Daten in die vorgerenderte Seite einbettet und der clientseitige Code muss sie auch von dort lesen. Seit Version 5 unterstützt Angular genau das mit dem Transfer State API.

Eine weitere Herausforderung stellt das sogenannte Uncanny Valley dar: Dieser Begriff bezeichnet die Zeitspanne zwischen dem Anzeigen der serverseitig erzeugten Inhalte und dem Zeitpunkt, zu dem die Anwendung tatsächlich interaktiv ist. Benutzeraktionen wie Klicks oder Eingaben werden in dieser Zeitspanne vom clientseitigen Code nicht wahrgenommen und somit auch nicht berücksichtigt. Eine Lösung hierfür ist die Bibliothek preboot, die diese Benutzeraktionen aufzeichnet und später wieder abspielt. Eine andere Lösung besteht darin, in dieser Zeit keine interaktiven Elemente anzubieten, sondern stattdessen lediglich Ghost-Elemente zu platzieren.

All diese Ausführungen zeigen, dass SSR mit Mehraufwand daherkommt. Deswegen gilt es zu prüfen, ob es für das eigene Projekt tatsächlich benötigt wird. Bei öffentlichen Webportalen wird das sehr häufig der Fall sein, da es dort Absprünge zu verhindern gilt. Bei Intranetanwendungen wird ein gefühlter Performancegewinn von rund drei Sekunden beim Start jedoch nicht zwangsläufig den Mehraufwand rechtfertigen.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -