So haben wir Code-Splitting, Code-Inlining und serverseitiges Rendering in PROXX verwendet.
Bei der Google I/O 2019 haben Mariko, Jake und ich PROXX veröffentlicht, einen modernen Minesweeper-Klon für das Web. PROXX zeichnet sich durch den Fokus auf Barrierefreiheit aus (es kann mit einem Screenreader gespielt werden) und durch die Möglichkeit, es sowohl auf einem einfachen Smartphone als auch auf einem High-End-Desktopgerät auszuführen. Feature-Phones sind in vielerlei Hinsicht eingeschränkt:
- Schwache CPUs
- Schwache oder nicht vorhandene GPUs
- Kleine Bildschirme ohne Eingabe per Berührung
- Sehr begrenzter Arbeitsspeicher
Sie haben jedoch einen modernen Browser und sind sehr erschwinglich. Aus diesem Grund erleben Featurephones in aufstrebenden Märkten eine Renaissance. Durch den günstigen Preis können ganz neue Zielgruppen, die sich das zuvor nicht leisten konnten, online gehen und das moderne Web nutzen. Für 2019 werden prognostiziert, dass allein in Indien etwa 400 Millionen Feature-Phones verkauft werden. Nutzer von Feature-Phones werden also einen beträchtlichen Teil Ihrer Zielgruppe ausmachen. Außerdem sind Verbindungsgeschwindigkeiten, die denen von 2G ähneln, in aufstrebenden Märkten die Norm. Wie haben wir es geschafft, dass PROXX auch auf einfachen Smartphones gut funktioniert?
Die Leistung ist wichtig, sowohl die Lade- als auch die Laufzeitleistung. Es wurde nachgewiesen, dass eine gute Leistung mit einer höheren Nutzerbindung, besseren Conversions und vor allem mehr Inklusion einhergeht. Jeremy Wagner hat noch viel mehr Daten und Informationen dazu, warum Leistung wichtig ist.
Dies ist Teil 1 einer zweiteiligen Reihe. In Teil 1 liegt der Schwerpunkt auf der Ladeleistung, in Teil 2 auf der 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 die Messung. Sie sind vielleicht an 4G, LTE oder bald sogar an 5G gewöhnt, aber die Realität des mobilen Internets sieht ganz anders aus. Vielleicht sind Sie in einem Zug, auf einer Konferenz, bei einem Konzert oder in einem Flugzeug. Dort werden Sie wahrscheinlich näher an 3G liegen, manchmal sogar noch schlimmer.
Wir werden uns in diesem Artikel jedoch auf 2G konzentrieren, da PROXX in seiner Zielgruppe explizit auf Feature-Phones und aufstrebende Märkte ausgerichtet ist. Sobald der Test in WebPageTest abgeschlossen ist, sehen Sie oben eine abfolgeähnliche Ansicht (ähnlich wie in den DevTools) und einen Filmstreifen. Der Filmstreifen zeigt, was der Nutzer sieht, während Ihre App geladen wird. Bei 2G ist das Laden der nicht optimierten Version von PROXX ziemlich schlecht:
Beim Laden über 3G sieht der Nutzer vier Sekunden lang nur eine weiße Fläche. Bei 2G sieht der Nutzer über 8 Sekunden lang absolut nichts. Wenn Sie den Artikel Warum Leistung wichtig ist gelesen haben, wissen Sie, dass wir jetzt einen großen Teil unserer potenziellen Nutzer aufgrund von Ungeduld verloren haben. Der Nutzer muss alle 62 KB JavaScript herunterladen, damit etwas auf dem Bildschirm angezeigt wird. Das Positive dabei ist, dass es sofort interaktiv ist, sobald etwas auf dem Bildschirm erscheint. Oder vielleicht doch?
Nachdem etwa 62 KB an gzip-d-JS heruntergeladen und das DOM generiert wurden, können Nutzer unsere App sehen. Die App ist technisch interaktiv. Das Bild zeigt jedoch eine andere Realität. Die Webfonts werden noch im Hintergrund geladen. Bis sie fertig sind, sieht der Nutzer keinen Text. Dieser Zustand gilt zwar als First Meaningful Paint (FMP), aber nicht als interaktiv, da der Nutzer nicht erkennen kann, wozu die Eingaben dienen. Bei 3G dauert es noch eine Sekunde und bei 2G drei Sekunden, bis die App einsatzbereit ist. Insgesamt dauert es bei 3G sechs und bei 2G elf Sekunden, bis die App interaktiv ist.
Abfolgeanalyse
Nachdem wir nun wissen, was die Nutzer sehen, müssen wir herausfinden, warum. Dazu können wir uns die abfolge 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, mehrfarbige dünne Linien zu sehen.
- JavaScript-Dateien bilden eine Kette. So wird beispielsweise die zweite Ressource 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. Die Einrichtung einer neuen Verbindung ist teuer, da sie bei 3G etwa 1 Sekunde und bei 2G etwa 2,5 Sekunden dauert. In unserer abfolge sehen wir eine neue Verbindung für:
- Anfrage 1: Unser
index.html
- Anfrage 5: Schriftstile von
fonts.googleapis.com
- Anfrage 8: Google Analytics
- Anfrage 9: Schriftdatei von
fonts.gstatic.com
- Anfrage 14: Manifest der Webanwendung
Die neue Verbindung für index.html
ist unvermeidlich. Der Browser muss eine Verbindung zu unserem Server herstellen, um die Inhalte abzurufen. Die neue Verknüpfung mit Google Analytics lässt sich vermeiden, indem eine Methode wie Minimal Analytics eingefügt wird. Google Analytics verhindert jedoch nicht, dass unsere App gerendert oder interaktiv wird. Daher ist es unerheblich, wie schnell sie geladen wird. Idealerweise sollte Google Analytics in der Zeit geladen werden, in der die Seite inaktiv ist und alles andere bereits geladen wurde. So wird beim ersten Laden keine Bandbreite oder Rechenleistung beansprucht. Die neue Verbindung für das Web-App-Manifest wird von der Abrufspezifikation vorgeschrieben, da das Manifest über eine Verbindung ohne Anmeldedaten geladen werden muss. Nochmals: Das Manifest der Webanwendung verhindert nicht, dass unsere App gerendert oder interaktiv wird. Daher müssen wir uns nicht allzu sehr darum kümmern.
Die beiden Schriftarten und ihre Stile sind jedoch ein Problem, da sie das Rendering und auch die Interaktivität blockieren. Wenn wir uns den CSS-Code ansehen, der von fonts.googleapis.com
bereitgestellt wird, sind es nur zwei @font-face
-Regeln, eine für jede Schriftart. Die Schriftstile sind so klein, dass wir sie in die HTML-Datei eingefügt haben, um eine unnötige Verbindung zu entfernen. Um die Kosten für die Verbindungseinrichtung für die Schriftdateien zu vermeiden, können wir sie auf unseren eigenen Server kopieren.
Ladevorgänge parallelisieren
Wenn wir uns die Vermittlungsabfolge ansehen, erkennen wir, dass nach dem Laden der ersten JavaScript-Datei sofort neue Dateien geladen werden. Das ist typisch für Modulabhängigkeiten. Unser Hauptmodul verfügt wahrscheinlich über statische Importe, sodass JavaScript erst ausgeführt werden kann, wenn diese Importe geladen sind. Wichtig ist, dass diese Art von Abhängigkeiten zum Zeitpunkt der Erstellung bekannt sind. Mit <link rel="preload">
-Tags können wir dafür sorgen, dass alle Abhängigkeiten sofort geladen werden, sobald wir unsere HTML-Datei erhalten.
Ergebnisse
Sehen wir uns an, was wir mit diesen Änderungen erreicht haben. Es ist wichtig, keine anderen Variablen in unserer Testeinrichtung zu ä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 der TTI von 11 auf 8,5 reduziert, was ungefähr den 2,5 Sekunden entspricht, die wir bei der Verbindungseinrichtung einsparen wollten. Gut gemacht.
Pre-Rendering
Wir haben zwar die TTI reduziert, aber den endlos langen weißen Bildschirm, den Nutzer 8,5 Sekunden lang ertragen müssen, nicht wirklich beeinflusst. Die größten Verbesserungen für FMP lassen sich mithilfe von gestyltem Markup in Ihrem index.html
erzielen. Gängige Methoden hierfür sind das Pre-Rendering und das serverseitige Rendering. Beide sind eng miteinander verbunden und werden im Artikel Rendering im Web erläutert. Bei beiden Techniken wird die Webanwendung in Node ausgeführt und das resultierende DOM in HTML serialisiert. Beim serverseitigen Rendering geschieht dies pro Anfrage auf der Serverseite, während beim Vorab-Rendering dies zur Buildzeit geschieht und die Ausgabe als neue index.html
gespeichert wird. Da PROXX eine JAMStack-App und keine Serverseite ist, haben wir beschlossen, Pre-Rendering zu implementieren.
Es gibt viele Möglichkeiten, einen Pre-Renderer zu implementieren. In PROXX verwenden wir Puppeteer, mit dem Chrome ohne Benutzeroberfläche gestartet wird und die Instanz per Node API ferngesteuert werden kann. Damit fügen wir unser Markup und unser JavaScript ein und lesen das DOM dann als HTML-String zurück. Da wir CSS-Module verwenden, erhalten wir kostenlos die CSS-Inline-Einbindung der benötigten Stile.
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);
So können wir eine Verbesserung unserer FMP erwarten. Wir müssen weiterhin dieselbe Menge an JavaScript laden und ausführen wie zuvor. Daher sollte sich der TTI nicht wesentlich ändern. Im Gegenteil: Unsere index.html
ist größer geworden und könnte unsere TTI etwas verschieben. Es gibt nur eine Möglichkeit, das herauszufinden: WebPageTest ausführen.
Die Zeit für „Inhalte weitgehend gezeichnet“ ist von 8,5 Sekunden auf 4,9 Sekunden gesunken – eine enorme Verbesserung. Die TTI liegt weiterhin bei etwa 8,5 Sekunden und ist von dieser Änderung weitgehend unberührt. Hier haben wir eine Wahrnehmung geändert. Manche bezeichnen es sogar als Taschentuch. Durch das Rendern eines Zwischenbilds des Spiels wird die wahrgenommene Ladeleistung verbessert.
Einfügen
Ein weiterer Messwert, der sowohl in DevTools als auch in WebPageTest angezeigt wird, ist die Zeit bis zum ersten Byte (TTFB). Das ist die Zeitspanne zwischen dem Senden des ersten Bytes der Anfrage und dem Empfang des ersten Bytes der Antwort. Diese Zeit wird auch oft als Round-Trip-Time (RTT) bezeichnet, obwohl es technisch gesehen einen Unterschied zwischen diesen beiden Zahlen gibt: Die RTT enthält nicht die Verarbeitungszeit der Anfrage auf der Serverseite. DevTools und WebPageTest visualisieren TTFB im Anfrage-/Antwortblock mit einer hellen Farbe.
In der abgebildeten Vermittlungsabfolge sehen wir, dass alle Anfragen den Großteil der Zeit darauf warten, dass das erste Byte der Antwort eintrifft.
Für dieses Problem wurde HTTP/2-Push ursprünglich entwickelt. Der App-Entwickler weiß, dass bestimmte Ressourcen erforderlich sind, und kann sie voranbringen. Wenn der Client feststellt, dass er zusätzliche Ressourcen abrufen muss, befinden sich diese bereits im Cache des Browsers. Der HTTP/2-Push hat sich als zu schwierig erwiesen und wird als davon abgeraten. Dieser Problembereich wird bei der Standardisierung von HTTP/3 noch einmal untersucht. Derzeit ist die einfachste Lösung, alle wichtigen Ressourcen inline einzufügen, was jedoch zu Lasten der Caching-Effizienz geht.
Unser kritisches CSS wurde dank der CSS-Module und unseres Puppeteer-basierten Pre-Renderings bereits eingebunden. Für JavaScript müssen wir unsere kritischen Module und ihre Abhängigkeiten inline einbetten. Diese Aufgabe hat je nach verwendetem Bundler unterschiedliche Schwierigkeitsgrade.
Dadurch konnten wir die TTI um eine Sekunde verkürzen. Wir haben jetzt den Punkt erreicht, an dem unsere 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, wodurch die FMP erstellt wird. Sobald das Parsen und die Ausführung des HTML-Codes abgeschlossen ist, ist die Anwendung interaktiv.
Aggressives Code-Splitting
Ja, die index.html
enthält alles, was für die Interaktion erforderlich ist. Bei genauerer Betrachtung stellt sich jedoch heraus, dass es auch alles andere enthält. Unsere index.html
ist etwa 43 KB groß. Stellen wir das in Bezug dazu, womit der Nutzer zu Beginn interagieren kann: Wir haben ein Formular zum Konfigurieren des Spiels mit einigen Komponenten, einer Startschaltfläche und wahrscheinlich etwas Code zum Speichern und Laden der Nutzereinstellungen. So ziemlich alles. 43 KB scheint eine Menge zu sein.
Um herauszufinden, woher die Größe des Bundles stammt, können wir einen Source Map Explorer oder ein ähnliches Tool verwenden, um die einzelnen Bestandteile des Bundles aufzuschlüsseln. Wie erwartet, enthält unser Paket die Spiellogik, die Rendering-Engine, den Bildschirm für den Sieg, den Bildschirm für den Verlust und eine Reihe von Dienstprogrammen. Nur ein kleiner Teil dieser Module wird für die Landingpage benötigt. Wenn Sie alles, was nicht unbedingt für die Interaktivität erforderlich ist, in ein verzögert geladenes Modul verschieben, lässt sich die TTI erheblich verringern.
Dafür müssen wir den Code aufteilen. Durch die Codeaufteilung wird Ihr monolithisches Bundle in kleinere Teile aufgeteilt, die bei Bedarf per Lazy-Loading ausgeführt werden können. Beliebte Bundler wie Webpack, Rollup und Parcel unterstützen die Code-Spaltung mithilfe von dynamischem import()
. Der Bundler analysiert den Code und inline alle Module ein, die statisch importiert werden. Alles, was du dynamisch importierst, wird in eine eigene Datei aufgenommen und erst dann aus dem Netzwerk abgerufen, wenn der import()
-Aufruf ausgeführt wird. Natürlich hat das Networking Kosten und sollte nur dann erfolgen, wenn Sie die Zeit dafür haben. Das Mantra lautet hier: Die Module, die zum Laden kritisch sind, statisch importieren und alles andere dynamisch laden. Sie sollten jedoch nicht bis zum letzten Moment warten, um Module zu lazy-loaden, die definitiv verwendet werden. 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 wurden jedoch in lazy.js
verschoben, was sich als etwas kompliziert erwies, da Preact standardmäßig keine verzögert geladenen Komponenten verarbeiten kann. Aus diesem Grund haben wir einen kleinen deferred
-Komponenten-Wrapper geschrieben, mit dem wir einen Platzhalter rendern können, bis die eigentliche Komponente geladen ist.
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();
}
};
}
So können wir in unseren render()
-Funktionen ein Promise einer Komponente verwenden. Beispielsweise wird die Komponente <Nebula>
, die das animierte Hintergrundbild rendert, während des Ladevorgangs durch ein leeres <div>
ersetzt. Sobald die Komponente geladen und einsatzbereit ist, wird das <div>
durch die eigentliche Komponente ersetzt.
const NebulaDeferred = deferred(
import("/components/nebula").then(m => m.default)
);
return (
// ...
<NebulaDeferred
loading={() => <div />}
loaded={Nebula => <Nebula />}
/>
);
Vor diesem Hintergrund haben wir die index.html
auf nur 20 KB reduziert, also weniger als die Hälfte der ursprünglichen Größe. Welche Auswirkungen hat das auf die durchschnittliche Wiedergabedauer und die durchschnittliche Wiedergabezeit? WebPageTest liefert die Antwort.
Unser FMP und TTI sind nur 100 ms voneinander entfernt, da nur der Inline-JavaScript-Code geparst und ausgeführt werden muss. Nach nur 5,4 Sekunden bei 2G ist die App vollständig interaktiv. Alle anderen, weniger wichtigen Module werden im Hintergrund geladen.
Mehr Handtauglichkeit
Wenn Sie sich die obige Liste der kritischen Module ansehen, werden Sie feststellen, dass das Rendering-Modul nicht zu den kritischen Modulen gehört. Natürlich kann das Spiel erst gestartet werden, wenn wir eine Rendering-Engine haben, mit der es gerendert werden kann. Wir könnten die Schaltfläche „Starten“ deaktivieren, bis unsere Rendering-Engine bereit ist, das Spiel zu starten. Unserer Erfahrung nach dauert es aber in der Regel so lange, bis der Nutzer seine Spieleinstellungen konfiguriert hat, dass dies nicht erforderlich ist. In den meisten Fällen sind das Rendering-Engine und die anderen verbleibenden Module geladen, wenn der Nutzer auf „Starten“ klickt. Im seltenen Fall, dass der Nutzer schneller ist als seine Netzwerkverbindung, wird ein einfacher Ladebildschirm angezeigt, der darauf wartet, dass die verbleibenden Module fertig sind.
Fazit
Messungen sind wichtig. Damit Sie nicht unnötig Zeit mit der Behebung nicht vorhandener Probleme verschwenden, empfehlen wir Ihnen, immer zuerst zu messen, bevor Sie Optimierungen implementieren. Außerdem sollten Messungen auf echten Geräten mit einer 3G-Verbindung oder auf WebPageTest durchgeführt werden, wenn kein echtes Gerät zur Hand ist.
Der Filmstreifen kann Aufschluss darüber geben, wie sich das Laden Ihrer App für die Nutzer anfühlt. Die Vermittlungsabfolge kann Ihnen Aufschluss darüber geben, welche Ressourcen für potenziell lange Ladezeiten verantwortlich sind. Hier ist eine Checkliste mit Maßnahmen, mit denen Sie die Ladeleistung verbessern können:
- Über eine Verbindung möglichst viele Assets bereitstellen
- Preload oder sogar Inline-Ressourcen, die für das erste Rendern und die Interaktivität erforderlich sind.
- Ihre App vor dem Rendern optimieren, um die Ladeleistung zu verbessern
- Nutzen Sie eine aggressive Codeaufteilung, um den für die Interaktivität erforderlichen Code zu reduzieren.
In Teil 2 erfahren Sie, wie Sie die Laufzeitleistung auf Geräten mit sehr begrenzten Ressourcen optimieren.