So haben wir in PROXX Code-Splitting, Code-Inlining und serverseitiges Rendern verwendet.
Auf der Google I/O 2019 haben Mariko, Jake und ich PROXX veröffentlicht, einen modernen Minensuch-Klon für das Web. PROXX zeichnet sich durch den Fokus auf Barrierefreiheit aus (es kann mit einem Screenreader gespielt werden) und kann sowohl auf einem einfachen Mobiltelefon als auch auf einem High-End-Desktopgerät ausgeführt werden. Feature-Phones sind in vielerlei Hinsicht eingeschränkt:
- Schwache CPUs
- Schwache oder nicht vorhandene GPUs
- Kleine Displays ohne Touch-Eingabe
- Sehr begrenzter Arbeitsspeicher
Sie haben jedoch einen modernen Browser und sind sehr erschwinglich. Aus diesem Grund erleben sie in Schwellenländern ein Comeback. Durch den niedrigen Preis können nun auch Nutzer, die sich ein Smartphone bisher nicht leisten konnten, online gehen und das moderne Web nutzen. Für 2019 wird prognostiziert, dass allein in Indien rund 400 Millionen Mobiltelefone ohne Touchscreen verkauft werden. Nutzer solcher Geräte könnten also einen erheblichen Teil Ihrer Zielgruppe ausmachen. Außerdem sind Verbindungsgeschwindigkeiten, die 2G ähneln, in Schwellenländern die Norm. Wie haben wir es geschafft, dass PROXX auch auf einem einfachen Mobiltelefon gut funktioniert?
Die Leistung ist wichtig, sowohl die Ladeleistung als auch die Laufzeitleistung. Es hat sich gezeigt, dass gute Leistung mit einer höheren Nutzerbindung, besseren Conversions und vor allem mit mehr Inklusivität einhergeht. Jeremy Wagner hat viel mehr Daten und Statistiken dazu, warum Leistung wichtig ist.
Dies ist Teil 1 einer zweiteiligen Reihe. Teil 1 konzentriert sich auf die Ladeleistung und Teil 2 auf die Laufzeitleistung.
Status quo erfassen
Es ist wichtig, die Ladeleistung auf einem echten Gerät zu testen. Wenn Sie kein echtes Gerät zur Hand haben, empfehle ich WebPageTest, insbesondere die einfache Einrichtung. WPT führt eine Reihe von Ladetests auf einem echten Gerät mit einer emulierten 3G-Verbindung durch.
3G ist eine gute Geschwindigkeit für Messungen. Auch wenn Sie vielleicht an 4G, LTE oder bald sogar 5G gewöhnt sind, sieht die Realität des mobilen Internets ganz anders aus. Vielleicht sind Sie gerade im Zug, auf einer Konferenz, bei einem Konzert oder auf einem Flug. Die Geschwindigkeit, die Sie dort erleben, entspricht höchstwahrscheinlich eher 3G und ist manchmal sogar noch langsamer.
In diesem Artikel konzentrieren wir uns jedoch auf 2G, da PROXX explizit auf Feature-Phones und Schwellenländer ausgerichtet ist. Nachdem WebPageTest den Test ausgeführt hat, erhalten Sie oben ein Wasserfalldiagramm (ähnlich dem in den DevTools) sowie einen Filmstreifen. Der Filmstreifen zeigt, was der Nutzer sieht, während Ihre App geladen wird. Auf 2G ist das Laden der nicht optimierten Version von PROXX ziemlich langsam:
Bei einer 3G-Verbindung sieht der Nutzer 4 Sekunden lang nur Weiß. Bei 2G sieht der Nutzer über 8 Sekunden lang nichts. Wenn Sie Warum Leistung wichtig ist gelesen haben, wissen Sie, dass wir aufgrund von Ungeduld einen großen Teil unserer potenziellen Nutzer verloren haben. Der Nutzer muss die gesamten 62 KB JavaScript herunterladen, damit etwas auf dem Bildschirm angezeigt wird. Der Vorteil in diesem Szenario ist, dass alles, was auf dem Bildschirm angezeigt wird, auch interaktiv ist. Oder vielleicht doch?

Nachdem etwa 62 KB komprimiertes JS heruntergeladen und das DOM generiert wurde, sieht der Nutzer unsere App. Die App ist technisch interaktiv. Die Abbildung zeigt jedoch eine andere Realität. Die Webfonts werden weiterhin im Hintergrund geladen. Bis sie bereit sind, kann der Nutzer keinen Text sehen. Dieser Zustand gilt zwar als First Meaningful Paint (FMP), ist aber sicherlich nicht interaktiv, da der Nutzer nicht erkennen kann, wofür die Eingaben stehen. Bei 3G dauert es eine weitere Sekunde und bei 2G drei weitere Sekunden, bis die App einsatzbereit ist. Insgesamt dauert es bei 3G sechs Sekunden und bei 2G elf Sekunden, bis die App interaktiv wird.
Wasserfallanalyse
Nachdem wir wissen, was der Nutzer sieht, müssen wir herausfinden, warum. Dazu können wir uns das Wasserfalldiagramm ansehen und analysieren, warum Ressourcen zu spät geladen werden. In unserem 2G-Trace für PROXX sehen wir zwei wichtige Warnsignale:
- Es sind mehrere dünne Linien in verschiedenen Farben zu sehen.
- JavaScript-Dateien bilden eine Kette. Die zweite Ressource wird beispielsweise erst geladen, wenn die erste Ressource fertig ist, und die dritte Ressource erst, wenn die zweite Ressource fertig ist.

Anzahl der Verbindungen reduzieren
Jede dünne Linie (dns
, connect
, ssl
) steht für die Erstellung einer neuen HTTP-Verbindung. Das Einrichten einer neuen Verbindung ist kostspielig, da es bei 3G etwa 1 Sekunde und bei 2G etwa 2,5 Sekunden dauert. Im Wasserfalldiagramm sehen wir eine neue Verbindung für:
- Anfrage 1: Unser
index.html
- Anfrage 5: Die Schriftstile von
fonts.googleapis.com
- Anfrage 8: Google Analytics
- Anfrage 9: Eine Schriftartdatei von
fonts.gstatic.com
- Anfrage 14: Das Web-App-Manifest
Die neue Verbindung für index.html
ist unvermeidlich. Der Browser muss eine Verbindung zu unserem Server herstellen, um die Inhalte abzurufen. Die neue Verbindung für Google Analytics könnte durch Inlining von Minimal Analytics vermieden werden. Da Google Analytics jedoch nicht verhindert, dass unsere App gerendert wird oder interaktiv ist, ist es uns nicht so wichtig, wie schnell sie geladen wird. Idealerweise sollte Google Analytics in der Leerlaufzeit geladen werden, wenn alles andere bereits geladen wurde. So werden beim ersten Laden keine Bandbreite oder Rechenleistung benötigt. Die neue Verbindung für das Web-App-Manifest wird durch die Fetch-Spezifikation vorgeschrieben, da das Manifest über eine Verbindung ohne Anmeldedaten geladen werden muss. Das Web-App-Manifest verhindert nicht, dass unsere App gerendert wird oder interaktiv wird. Daher müssen wir uns nicht so viele Gedanken darüber machen.
Die beiden Schriftarten und ihre Formatierungen sind jedoch ein Problem, da sie das Rendern und die Interaktivität blockieren. Wenn wir uns das CSS ansehen, das von fonts.googleapis.com
bereitgestellt wird, sehen wir nur zwei @font-face
-Regeln, eine für jede Schriftart. Die Schriftstile sind so klein, dass wir uns entschieden haben, sie in unser HTML einzufügen, um eine unnötige Verbindung zu vermeiden. Um die Kosten für die Einrichtung der Verbindung für die Schriftartdateien zu vermeiden, können wir sie auf unseren eigenen Server kopieren.
Ladevorgänge parallelisieren
Im Wasserfalldiagramm sehen wir, dass neue Dateien sofort geladen werden, sobald die erste JavaScript-Datei geladen wurde. Das ist typisch für Modulabhängigkeiten. Unser Hauptmodul hat wahrscheinlich statische Importe, sodass das JavaScript erst ausgeführt werden kann, wenn diese Importe geladen sind. Wichtig ist hier, dass diese Art von Abhängigkeiten zur Build-Zeit bekannt sind. Wir können <link rel="preload">
-Tags verwenden, um dafür zu sorgen, dass alle Abhängigkeiten geladen werden, sobald wir unseren HTML-Code erhalten.
Ergebnisse
Sehen wir uns an, was wir mit unseren Änderungen erreicht haben. Es ist wichtig, dass wir keine anderen Variablen in unserem Testaufbau ändern, die die Ergebnisse verfälschen könnten. Daher verwenden wir für den Rest dieses Artikels die einfache Einrichtung von WebPageTest und sehen uns den Filmstreifen an:
Durch diese Änderungen wurde unser TTI von 11 auf 8,5 Sekunden reduziert. Das entspricht in etwa den 2,5 Sekunden für die Verbindungsherstellung, die wir entfernen wollten. Gut gemacht.
Pre-Rendering
Wir haben zwar gerade die TTI reduziert, aber den ewig langen weißen Bildschirm, den der Nutzer 8,5 Sekunden lang sehen muss, haben wir nicht wirklich beeinflusst. Die größten Verbesserungen für FMP lassen sich wahrscheinlich erzielen, wenn Sie formatiertes Markup in Ihrem index.html
senden. Gängige Techniken hierfür sind Prerendering und serverseitiges Rendering. Diese sind eng miteinander verbunden und werden unter Rendering im Web erläutert. Bei beiden Techniken wird die Web-App in Node ausgeführt und das resultierende DOM in HTML serialisiert. Beim serverseitigen Rendern wird dies pro Anfrage auf dem Server durchgeführt, während beim Vorrendern dies zur Build-Zeit geschieht und die Ausgabe als neue index.html
gespeichert wird. Da PROXX eine JAMStack-App ist und keine Serverseite hat, haben wir uns für die Implementierung von Prerendering entschieden.
Es gibt viele Möglichkeiten, einen Prerenderer zu implementieren. In PROXX haben wir uns für Puppeteer entschieden. Damit wird Chrome ohne Benutzeroberfläche gestartet und Sie können diese Instanz mit einer Node-API per Fernzugriff steuern. Damit fügen wir unser Markup und unseren JavaScript-Code ein und lesen dann das DOM als HTML-String zurück. Da wir CSS-Module verwenden, werden die benötigten Stile automatisch inline eingebettet.
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(rawIndexHTML);
await page.evaluate(codeToRun);
const renderedHTML = await page.content();
browser.close();
await writeFile("index.html", renderedHTML);
Wenn das der Fall ist, können wir mit einer Verbesserung unseres FMP rechnen. Wir müssen weiterhin dieselbe Menge an JavaScript laden und ausführen wie zuvor. Daher ist nicht mit einer großen Änderung der TTI zu rechnen. Wenn überhaupt, ist unsere index.html
größer geworden und könnte unsere TTI etwas nach hinten verschieben. Es gibt nur eine Möglichkeit, das herauszufinden: WebPageTest ausführen.
Die Zeit für „Inhalte weitgehend gezeichnet“ hat sich von 8,5 Sekunden auf 4,9 Sekunden verkürzt – eine enorme Verbesserung. Unsere TTI liegt weiterhin bei etwa 8,5 Sekunden und wurde durch diese Änderung kaum beeinträchtigt. Hier haben wir eine wahrnehmungsbezogene Änderung vorgenommen. Manche würden es vielleicht als Taschenspielertrick bezeichnen. Durch das Rendern eines Zwischenbilds des Spiels wird die wahrgenommene Ladeleistung verbessert.
Inline-Anzeige
Ein weiterer Messwert, der sowohl in DevTools als auch in WebPageTest verfügbar ist, ist Time To First Byte (TTFB). Die Zeit, die vom Senden des ersten Byte der Anfrage bis zum Empfang des ersten Byte der Antwort vergeht. Diese Zeit wird auch oft als Round Trip Time (RTT) bezeichnet. Technisch gesehen gibt es jedoch einen Unterschied zwischen diesen beiden Zahlen: Die RTT umfasst nicht die Verarbeitungszeit der Anfrage auf der Serverseite. In DevTools und WebPageTest wird TTFB mit einer hellen Farbe im Anfrage-/Antwortblock visualisiert.
Wenn wir uns unser Wasserfalldiagramm ansehen, stellen wir fest, dass bei allen Anfragen der größte Teil der Zeit mit Warten verbracht wird, bis das erste Byte der Antwort eintrifft.
Genau für dieses Problem wurde HTTP/2-Push ursprünglich entwickelt. Der App-Entwickler weiß, dass bestimmte Ressourcen benötigt werden, und kann sie übertragen. Wenn der Client feststellt, dass er zusätzliche Ressourcen abrufen muss, befinden sich diese bereits in den Browsercaches. HTTP/2-Push hat sich als zu schwierig erwiesen und wird nicht mehr empfohlen. Dieses Problem wird bei der Standardisierung von HTTP/3 noch einmal aufgegriffen. Die einfachste Lösung besteht derzeit darin, alle wichtigen Ressourcen inline zu platzieren, was jedoch die Caching-Effizienz beeinträchtigt.
Unser wichtiges CSS ist dank CSS-Modulen und unserem auf Puppeteer basierenden Prerenderer bereits inline eingebettet. Für JavaScript müssen wir unsere wichtigen Module und ihre Abhängigkeiten inline einfügen. Diese Aufgabe ist je nach verwendetem Bundler unterschiedlich schwierig.
Dadurch konnten wir die TTI um 1 Sekunde verkürzen. Wir haben jetzt den Punkt erreicht, an dem unser index.html
alles enthält, was für das erste Rendern und die Interaktivität erforderlich ist. Das HTML kann gerendert werden, während es noch heruntergeladen wird. So wird unser FMP erstellt. Sobald das HTML geparst und ausgeführt wurde, ist die App interaktiv.
Aggressives Aufteilen von Code
Ja, unser index.html
enthält alles, was für die Interaktivität erforderlich ist. Bei genauerer Betrachtung stellt sich jedoch heraus, dass es auch alles andere enthält. Unsere index.html
ist etwa 43 KB groß. Sehen wir uns an, womit der Nutzer zu Beginn interagieren kann: Wir haben ein Formular zum Konfigurieren des Spiels mit einigen Komponenten, eine Schaltfläche zum Starten und wahrscheinlich etwas Code zum Speichern und Laden von Nutzereinstellungen. Das war's im Grunde. 43 KB scheinen viel zu sein.

Um herauszufinden, woher die Bundle-Größe kommt, können wir einen Source Map Explorer oder ein ähnliches Tool verwenden, um aufzuschlüsseln, woraus das Bundle besteht. Wie erwartet enthält unser Bundle die Spiellogik, die Rendering-Engine, den Gewinner- und den Verliererbildschirm sowie eine Reihe von Dienstprogrammen. Für die Landingpage ist nur ein kleiner Teil dieser Module erforderlich. Wenn Sie alles, was für die Interaktivität nicht unbedingt erforderlich ist, in ein Lazy-Loading-Modul verschieben, wird die TTI erheblich verkürzt.
Wir müssen Code aufteilen. Beim Code-Splitting wird Ihr monolithisches Bundle in kleinere Teile aufgeteilt, die bei Bedarf verzögert geladen werden können. Beliebte Bundler wie Webpack, Rollup und Parcel unterstützen das Aufteilen von Code mithilfe von dynamischen import()
. Der Bundler analysiert Ihren Code und fügt alle Module inline ein, die statisch importiert werden. Alles, was Sie dynamisch importieren, wird in eine eigene Datei eingefügt und erst dann aus dem Netzwerk abgerufen, wenn der import()
-Aufruf ausgeführt wird. Das Aufrufen des Netzwerks ist natürlich mit Kosten verbunden und sollte nur erfolgen, wenn Sie die Zeit dafür haben. Das Mantra lautet hier, die Module, die beim Laden unbedingt benötigt werden, statisch zu importieren und alles andere dynamisch zu laden. Sie sollten jedoch nicht bis zum allerletzten Moment warten, um Module, die definitiv verwendet werden, verzögert zu laden. Phil Waltons Idle Until Urgent ist ein hervorragendes Muster für einen gesunden Mittelweg zwischen Lazy Loading und Eager Loading.
In PROXX haben wir eine lazy.js
-Datei erstellt, in der alles statisch importiert wird, was wir nicht benötigen. In unserer Hauptdatei können wir lazy.js
dann dynamisch importieren. Einige unserer Preact-Komponenten landeten jedoch in lazy.js
, was sich als etwas kompliziert erwies, da Preact keine Komponenten mit Lazy Loading unterstützt. Aus diesem Grund haben wir einen kleinen deferred
-Komponenten-Wrapper geschrieben, mit dem wir einen Platzhalter rendern können, bis die eigentliche Komponente geladen wurde.
export default function deferred(componentPromise) {
return class Deferred extends Component {
constructor(props) {
super(props);
this.state = {
LoadedComponent: undefined
};
componentPromise.then(component => {
this.setState({ LoadedComponent: component });
});
}
render({ loaded, loading }, { LoadedComponent }) {
if (LoadedComponent) {
return loaded(LoadedComponent);
}
return loading();
}
};
}
Jetzt können wir in unseren render()
-Funktionen ein Promise einer Komponente verwenden. Die Komponente <Nebula>
, die das animierte Hintergrundbild rendert, wird beispielsweise durch ein leeres <div>
ersetzt, während die Komponente geladen wird. Sobald die Komponente geladen und einsatzbereit ist, wird <div>
durch die tatsächliche Komponente ersetzt.
const NebulaDeferred = deferred(
import("/components/nebula").then(m => m.default)
);
return (
// ...
<NebulaDeferred
loading={() => <div />}
loaded={Nebula => <Nebula />}
/>
);
So konnten wir die Größe unseres index.html
auf nur 20 KB reduzieren – weniger als die Hälfte der ursprünglichen Größe. Welche Auswirkungen hat das auf FMP und TTI? WebPageTest gibt Aufschluss!
FMP und TTI liegen nur 100 ms auseinander, da nur das Inline-JavaScript geparst und ausgeführt werden muss. Nach nur 5, 4 Sekunden auf 2G ist die App vollständig interaktiv. Alle anderen, weniger wichtigen Module werden im Hintergrund geladen.
Mehr Fingerfertigkeit
Wenn Sie sich die Liste der wichtigen Module oben ansehen, werden Sie feststellen, dass die Rendering-Engine nicht dazu gehört. Das Spiel kann natürlich erst gestartet werden, wenn wir eine Rendering-Engine haben, um das Spiel zu rendern. Wir könnten die Schaltfläche „Starten“ deaktivieren, bis unsere Rendering-Engine bereit ist, das Spiel zu starten. Erfahrungsgemäß dauert es aber so lange, bis der Nutzer die Spieleinstellungen konfiguriert hat, dass dies nicht erforderlich ist. In den meisten Fällen sind die Rendering-Engine und die anderen verbleibenden Module geladen, wenn der Nutzer auf „Start“ drückt. In dem seltenen Fall, dass der Nutzer schneller ist als seine Netzwerkverbindung, wird ein einfacher Ladebildschirm angezeigt, auf dem auf das Laden der verbleibenden Module gewartet wird.
Fazit
Messungen sind wichtig. Um Zeit für Probleme zu sparen, die nicht wirklich existieren, empfehlen wir, vor der Implementierung von Optimierungen immer zuerst Messungen durchzuführen. Außerdem sollten Messungen auf echten Geräten mit einer 3G-Verbindung oder mit WebPageTest durchgeführt werden, wenn kein echtes Gerät verfügbar ist.
Der Filmstreifen kann Aufschluss darüber geben, wie sich das Laden Ihrer App für den Nutzer anfühlt. Im Wasserfalldiagramm sehen Sie, welche Ressourcen für potenziell lange Ladezeiten verantwortlich sind. Hier ist eine Checkliste mit Maßnahmen, die Sie ergreifen können, um die Ladeleistung zu verbessern:
- Stellen Sie möglichst viele Assets über eine Verbindung bereit.
- Laden Sie Ressourcen vorab oder sogar inline, die für das erste Rendern und die Interaktivität erforderlich sind.
- Rendern Sie Ihre App vorab, um die wahrgenommene Ladeleistung zu verbessern.
- Verwenden Sie eine aggressive Codeaufteilung, um die für die Interaktivität erforderliche Menge an Code zu reduzieren.
Im zweiten Teil erfahren Sie, wie Sie die Laufzeitleistung auf Geräten mit sehr eingeschränkten Ressourcen optimieren können.