Techniken, mit denen eine Webanwendung auch auf einem Feature-Phone schneller geladen wird

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 Touchbedienung
  • 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 allein in Indien rund 400 Millionen Feature-Phones verkauft. Nutzer von Feature-Phones könnten also einen erheblichen 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?

PROXX-Gameplay.

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 sitzen Sie in einem Zug, sind auf einer Konferenz, bei einem Konzert oder in einem Flugzeug. Die Leistung entspricht dann eher 3G und ist manchmal sogar noch schlechter.

In diesem Artikel konzentrieren wir uns jedoch auf 2G, da PROXX seine Zielgruppe explizit auf Feature-Phones und aufstrebende Märkte ausrichtet. 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 Nutzer sehen, während Ihre App geladen wird. Bei 2G ist das Laden der nicht optimierten Version von PROXX ziemlich schlecht:

Das Filmstreifenvideo zeigt, was der Nutzer sieht, wenn PROXX auf einem echten Low-End-Gerät über eine emulierte 2G-Verbindung geladen wird.

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 ist Leistung wichtig? gelesen haben, wissen Sie, dass wir jetzt einen Großteil unserer potenziellen Nutzer aufgrund von Ungeduld verloren haben. Der Nutzer muss alle 62 KB JavaScript herunterladen, damit etwas auf dem Bildschirm angezeigt wird. Der Vorteil in diesem Szenario ist, dass alles, was auf dem Bildschirm erscheint, interaktiv ist. Oder vielleicht doch?

Die [First Meaningful Paint][FMP] in der nicht optimierten Version von PROXX ist _technisch_ [interaktiv][TTI], aber für den Nutzer nutzlos.

Nachdem etwa 62 KB komprimierter JS-Code heruntergeladen und das DOM generiert wurde, sieht der Nutzer unsere App. 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:

  1. Es sind mehrere, mehrfarbige dünne Linien zu sehen.
  2. 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.
Die Abfolge gibt Aufschluss darüber, welche Ressourcen wann geladen werden und wie lange das dauert.

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 Verbindung für Google Analytics könnte vermieden werden, indem etwas wie Minimal Analytics eingefügt wird. Google Analytics verhindert jedoch nicht, dass unsere App gerendert oder interaktiv wird. Daher ist es uns nicht so wichtig, 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 das CSS ansehen, das von fonts.googleapis.com gesendet wird, sehen wir 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

In der Ablaufgrafik sehen wir, dass nach dem Laden der ersten JavaScript-Datei sofort neue Dateien geladen werden. Das ist typisch für Modulabhängigkeiten. Unser Hauptmodul enthält wahrscheinlich statische Importe. Das JavaScript kann also erst ausgeführt werden, wenn diese Importe geladen wurden. 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:

Mit dem Filmstreifen von WebPageTest sehen wir uns an, welche Auswirkungen unsere Änderungen hatten.

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 bei FMP lassen sich mithilfe von gestyltem Markup in Ihrem index.html erzielen. Gängige Techniken sind das Pre-Rendering und das serverseitige Rendering. Diese beiden Techniken sind eng miteinander verwandt und werden im Abschnitt 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 ist und keine Serverseite hat, haben wir uns für das Pre-Rendering entschieden.

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 das Einbetten der benötigten Stile in CSS.

  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);

Dadurch 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.

Der Filmstreifen zeigt eine deutliche Verbesserung für unseren FMP-Messwert. TTI ist größtenteils nicht betroffen.

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 Wahrnehmungsänderung vorgenommen. Manche würden es sogar als Zauberei bezeichnen. 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 umfasst nicht die Verarbeitungszeit der Anfrage auf der Serverseite. In den DevTools und WebPageTest wird die TTFB im Anfrage/Antwort-Block in einer hellen Farbe dargestellt.

Der helle Bereich einer Anfrage gibt an, dass die Anfrage auf den Empfang des ersten Bytes der Antwort wartet.

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. HTTP/2-Push erwies sich als zu schwierig, um ihn richtig zu implementieren, und wird daher nicht empfohlen. 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 ist dank CSS-Modulen und unserem Puppeteer-basierten Pre-Renderer bereits inline. Bei JavaScript müssen wir unsere kritischen Module und ihre Abhängigkeiten inline einbetten. Diese Aufgabe ist je nach verwendetem Bundler unterschiedlich schwierig.

Durch das Einfügen von JavaScript haben wir die TTI von 8,5 Sekunden auf 7,2 Sekunden reduziert.

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 HTML-Parsing und die Ausführung abgeschlossen sind, ist die App interaktiv.

Aggressives Code-Splitting

Ja, unsere index.html enthält alles, was für die Interaktivität erforderlich ist. Bei genauerer Betrachtung stellt sich jedoch heraus, dass er 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. Das war's auch schon. 43 KB erscheint mir viel.

Die Landingpage von PROXX. Hier werden nur kritische Komponenten verwendet.

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 die Niederlage und eine Reihe von Dienstprogrammen. Für die Landingpage ist nur ein kleiner Teil dieser Module erforderlich. 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 reduzieren.

Bei der Analyse des Inhalts von PROXX's `index.html` werden viele nicht benötigte Ressourcen angezeigt. Wichtige Ressourcen sind hervorgehoben.

Wir müssen den 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 die Code-Spaltung mithilfe von dynamischem import(). Der Bundler analysiert Ihren Code und fügt alle statisch importierten Module inline ein. 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 durchgeführt werden, 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 />}
  />
);

So konnten wir die Größe unserer index.html auf nur 20 KB reduzieren, also auf weniger als die Hälfte der ursprünglichen Größe. Welche Auswirkungen hat das auf die durchschnittliche Wiedergabedauer und die durchschnittliche Wiedergabezeit? WebPageTest kann Ihnen dabei helfen.

Der Filmstreifen bestätigt: Unsere TTI liegt jetzt bei 5,4 Sekunden. Eine drastische Verbesserung gegenüber der ursprünglichen Version von Pixel 11.

Unsere FMP und TTI liegen nur 100 ms auseinander, da es nur darum geht, das Inline-JavaScript zu parsen und auszuführen. Nach nur 5, 4 Sekunden bei 2G ist die App vollständig interaktiv. Alle anderen, weniger wichtigen Module werden im Hintergrund geladen.

Mehr Fingerfertigkeit

Wie Sie in der Liste der kritischen Module oben sehen, gehört die Rendering-Engine nicht zu den kritischen Modulen. 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 jedoch in der Regel so lange, bis der Nutzer seine 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 „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, 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. Anhand der Abfolge können Sie sehen, 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
  • Verwenden Sie eine aggressive Codeaufteilung, um die Menge des für die Interaktivität erforderlichen Codes zu reduzieren.

In Teil 2 erfahren Sie, wie Sie die Laufzeitleistung auf Geräten mit sehr begrenzten Ressourcen optimieren.