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 versendet, einen modernen Minesweeper-Klon für das Web. Was PROXX so besonders macht, sind die Barrierefreiheit (man kann mit einem Screenreader spielen!) und die Möglichkeit, sowohl auf einem Feature-Phone als auch auf einem High-End-Desktop-Gerät zu laufen. 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 laufen allerdings mit einem 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 hat sich gezeigt, dass eine gute Leistung mit einer höheren Nutzerbindung, mehr Conversions und – vor allem – mehr Inklusion zusammenhängt. Jeremy Wagner hat noch viel mehr Daten und Informationen dazu, warum Leistung wichtig ist.

Dies ist Teil 1 einer zweiteiligen Reihe. In Teil 1 geht es um die Ladeleistung, in Teil 2 um 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.

Die Messung der 3G-Geschwindigkeit ist gut. 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. Die Leistung entspricht dann eher 3G und ist manchmal sogar noch schlechter.

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 Nutzer sehen, während Ihre App geladen wird. Bei 2G funktioniert die nicht optimierte PROXX-Version ziemlich schlecht:

Das Filmstrip-Video 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 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 die gesamten 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?

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. Ein Blick auf 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. Es dauert eine weitere Sekunde bei 3G und 3 Sekunden bei 2G, bis die App einsatzbereit ist. Insgesamt braucht die App bei 3G 6 Sekunden und bei 2G 11 Sekunden, um interaktiv zu werden.

Abfolgeanalyse

Nachdem wir nun wissen, was die Nutzer sehen, müssen wir herausfinden, warum. Dazu können wir uns die Vermittlungsabfolge ansehen und analysieren, warum Ressourcen zu spät geladen werden. Im 2G-Trace für PROXX sehen wir zwei wichtige Warnsignale:

  1. Es gibt mehrfarbige, dünne Linien.
  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 Vermittlungsabfolge gibt Aufschluss darüber, welche Ressourcen wann und wie lange das Laden 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 Nr. 14: 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 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 von fonts.googleapis.com übermittelte CSS ansehen, 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 Kosten für die Verbindungseinrichtung für die Schriftartdateien 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 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 für 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-App 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. Wir verwenden dieses, um unser Markup und unseren JavaScript-Code einzuschleusen und das DOM dann als HTML-String zurückzulesen. 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.

Der Filmstreifen zeigt eine deutliche Verbesserung für unseren FMP-Messwert. TTI ist davon 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, den sowohl die Entwicklertools als auch WebPageTest liefern, ist Time To First 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. In den DevTools und WebPageTest wird die TTFB im Anfrage/Antwort-Block in einer hellen Farbe dargestellt.

Der helle Bereich einer Anfrage bedeutet, 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. Im Moment ist es die einfachste Lösung, alle kritischen Ressourcen einzubetten, was zulasten der Caching-Effizienz geht.

Unser kritisches CSS ist dank CSS-Modulen und unserem Puppeteer-basierten Pre-Renderer bereits inline. Für JavaScript müssen wir unsere kritischen Module und ihre Abhängigkeiten inline einbetten. Diese Aufgabe ist je nach verwendetem Bundler unterschiedlich schwierig.

Durch die Einbindung von JavaScript haben wir die TTI von 8,5 s auf 7,2 s reduziert.

Dadurch verkürzte sich unser TTI um 1 Sekunde. 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.

Aggressive Codeaufteilung

Ja, unsere index.html enthält alles, was für die Interaktivität erforderlich ist. Bei genauerer Betrachtung stellt sich jedoch heraus, dass sie auch alles andere enthält. Unsere index.html ist etwa 43 KB groß. Betrachten wir das im Verhältnis dazu, womit der Nutzer zu Beginn interagieren kann: Wir haben ein Formular zum Konfigurieren des Spiels, das einige Komponenten, eine Startschaltfläche und wahrscheinlich etwas Code zum Speichern und Laden der Nutzereinstellungen enthält. So ziemlich alles. 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 den Verlust 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 verringern.

Beim Analysieren des Inhalts von „index.html“ von PROXX finden Sie viele nicht benötigte Ressourcen. Wichtige Ressourcen sind hervorgehoben.

Wir müssen 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 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 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();
    }
  };
}

Jetzt können wir ein Promise einer Komponente in unseren render()-Funktionen 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. Wie wirkt sich das auf FMP und TTI aus? 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 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 keine Zeit für Probleme aufwenden, die nicht wirklich real sind, sollten Sie immer zuerst Analysen durchführen, bevor Sie Optimierungen vornehmen. 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 den 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
  • Nutzen Sie eine aggressive Codeaufteilung, um den für die Interaktivität erforderlichen Code zu reduzieren.

In Teil 2 geht es um die Optimierung der Laufzeitleistung auf stark eingeschränkten Geräten.