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

So haben wir Codeaufteilung, Code-Inlineing 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. Was PROXX von anderen Anbietern unterscheidet, ist der Fokus auf Barrierefreiheit (Sie können das Programm mit einem Screenreader nutzen!) und die Möglichkeit, sowohl auf Feature-Phones als auch auf High-End-Desktop-Geräten zu funktionieren. Feature-Phones werden auf verschiedene Arten eingeschränkt:

  • Schwache CPUs
  • Schwache oder nicht vorhandene GPUs
  • Kleine Bildschirme ohne Berührungseingabe
  • Sehr geringer Arbeitsspeicher

Aber sie bieten einen modernen Browser und sind sehr erschwinglich. Aus diesem Grund sind Feature-Phones in Schwellenländern wieder im Kommen. Ihr Preisniveau ermöglicht einer ganz neuen Zielgruppe, die es sich zuvor nicht leisten konnte, online zu gehen und das moderne Web zu nutzen. Für 2019 werden allein in Indien etwa 400 Millionen Feature-Phones verkauft. Nutzer von Feature-Phones könnten also einen erheblichen Teil Ihrer Zielgruppe ausmachen. Außerdem sind Verbindungsgeschwindigkeiten wie 2G in Schwellenländern die Norm. Wie haben wir es geschafft, dass PROXX auch unter den Bedingungen für Feature-Phones gut funktioniert?

PROXX-Gameplay.

Die Leistung ist wichtig, und zwar sowohl die Ladeleistung als auch die Laufzeitleistung. Es hat sich gezeigt, dass eine gute Leistung mit einer erhöhten Nutzerbindung, mehr Conversions und – vor allem – einer höheren Inklusion korreliert. Jeremy Wagner hat wesentlich mehr Daten und Einblicke, warum Leistung wichtig ist.

Dies ist Teil 1 einer zweiteiligen Reihe. Teil 1 konzentriert sich auf die Ladeleistung, Teil 2 auf die Laufzeitleistung.

Den Status quo festhalten

Es ist wichtig, die Ladeleistung auf einem echten Gerät zu testen. Falls Sie kein richtiges Gerät zur Hand haben, empfehlen wir Ihnen WebPageTest, insbesondere die einfache Einrichtung. WPT führt einen Akku mit Ladetests auf einem echten Gerät mit einer emulierten 3G-Verbindung aus.

3G ist eine gute Messgeschwindigkeit. 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 bist du im Zug, auf einer Konferenz, einem Konzert oder auf einem Flug. Das Problem ist höchstwahrscheinlich an 3G annähernd, manchmal sogar noch schlimmer.

In diesem Artikel werden wir uns jedoch auf 2G konzentrieren, da PROXX seine Zielgruppe explizit auf Feature-Phones und aufstrebende Märkte abzielt. Sobald WebPageTest seinen Test durchgeführt hat, erhalten Sie einen Wasserfall (ähnlich dem, was Sie in den Entwicklertools sehen) sowie einen Filmstreifen oben. 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:

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

Bei einem Ladevorgang über 3G sieht der Nutzer 4 Sekunden weißes Nichts. Über 2G sieht der Nutzer länger als 8 Sekunden lang überhaupt nichts. Wenn Sie wissen, warum Leistung wichtig ist, wissen Sie, dass wir inzwischen einen großen Teil unserer potenziellen Nutzer aufgrund von Ungeduld verloren haben. Der Nutzer muss die gesamte 62 KB JavaScript-Datei herunterladen, damit irgendetwas auf dem Bildschirm angezeigt wird. Der Lichtstreifen in diesem Szenario ist, dass das zweite, was auf dem Bildschirm zu sehen ist, ebenfalls 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 gzip-JS-Datei heruntergeladen und das DOM generiert wurde, kann der Nutzer unsere App sehen. Die App ist technisch interaktiv. Beim Betrachten des Bildmaterials zeigt sich jedoch eine andere Realität. Die Web-Schriftarten werden noch im Hintergrund geladen und bis sie fertig sind, kann der Nutzer keinen Text sehen. Dieser Status gilt zwar als First Meaningful Paint (FMP), ist aber sicherlich nicht als interaktiv eingestuft, da der Nutzer nicht erkennen kann, worum es bei den Eingaben geht. Bei 3G dauert es noch eine weitere Sekunde, bei 2G dauert es drei Sekunden, bis die App einsatzbereit ist. Insgesamt braucht die App bei 3G 6 Sekunden und bei 2G 11 Sekunden, um interaktiv zu werden.

Wasserfallanalyse

Da wir nun wissen, was der Nutzer sieht, müssen wir herausfinden, warum diese Daten für den Nutzer sichtbar sind. Dazu können wir uns den Wasserfall ansehen und analysieren, warum Ressourcen zu spät geladen werden. In unserem 2G-Trace für PROXX sehen wir zwei wichtige rote Kennzeichen:

  1. Es gibt mehrere mehrfarbige dünne Linien.
  2. JavaScript-Dateien bilden eine Kette. Die zweite Ressource wird beispielsweise erst geladen, wenn die erste abgeschlossen ist. Die dritte Ressource wird erst gestartet, wenn die zweite Ressource fertig ist.
Der Wasserfall gibt Aufschluss darüber, welche Ressourcen wann geladen werden und wie lange sie dauern.

Anzahl der Verbindungen reduzieren

Jede dünne Zeile (dns, connect, ssl) steht für die Herstellung 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. In unserer Vermittlungsabfolge 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: Manifest der Web-App

Die neue Verbindung für index.html lässt sich nicht vermeiden. 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 eine Software wie Minimal Analytics eingefügt wird. Google Analytics blockiert jedoch nicht das Rendering oder die Interaktivität unserer App, sodass es uns nicht wirklich wichtig ist, wie schnell sie geladen wird. Idealerweise sollte Google Analytics bei Inaktivität geladen werden, wenn alles andere bereits geladen ist. Auf diese Weise wird während des anfänglichen Ladevorgangs keine Bandbreite oder Rechenleistung verbraucht. Die neue Verbindung für das Web-App-Manifest wird in der Abrufspezifikation vorgeschrieben, da das Manifest über eine Verbindung ohne Anmeldedaten geladen werden muss. Auch hier verhindert das Web-App-Manifest nicht, dass unsere App gerendert oder interaktiv wird, sodass wir uns nicht allzu sehr kümmern müssen.

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 Schriftarten sind so klein, dass wir sie in den HTML-Code einfügen und eine unnötige Verbindung entfernen. Um die Kosten für das Einrichten der Verbindung für die Schriftartdateien zu vermeiden, können wir sie auf unseren eigenen Server kopieren.

Ladevorgänge parallelisieren

Wenn wir uns den Wasserfall anschauen, sehen wir, dass neue Dateien sofort nach dem Laden der ersten JavaScript-Datei 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 wurden. Hierbei ist es wichtig zu beachten, dass diese Art von Abhängigkeiten zum Zeitpunkt der Build-Erstellung bekannt sind. Mit <link rel="preload">-Tags können wir dafür sorgen, dass alle Abhängigkeiten in dem Moment geladen werden, in dem wir den HTML-Code erhalten.

Ergebnisse

Sehen wir uns an, was unsere Ä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:

Wir verwenden den Filmstreifen von WebPageTest, um zu sehen, was unsere Änderungen gebracht haben.

Durch diese Änderungen wurde unsere TTI von 11 auf 8,5 gesenkt, was ungefähr die 2,5 Sekunden für die Verbindungseinrichtung ist, die wir eigentlich reduzieren wollten. Gute Arbeit!

Pre-Rendering

Wir haben gerade unseren TTI reduziert, haben aber nicht wirklich Auswirkungen auf den ewig langen weißen Bildschirm, den der Nutzer 8,5 Sekunden ausdauern muss. Die größten Verbesserungen für FMP können durch das Senden von Markup mit benutzerdefinierten Stilen in der index.html erzielt werden. Gängige Methoden sind Pre-Rendering und serverseitiges Rendering. Beide sind eng miteinander verbunden und werden im Abschnitt Rendering im Web erläutert. Bei beiden Verfahren wird die Webanwendung in Node ausgeführt und das resultierende DOM wird in HTML archiviert. Beim serverseitigen Rendering erfolgt dies nach Anfrage auf der Serverseite, beim Pre-Rendering erfolgt dies während der Build-Erstellung und speichert die Ausgabe als neue index.html. 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 haben wir uns für Puppeteer entschieden. Damit wird Chrome ohne UI gestartet und ermöglicht die Fernsteuerung der Instanz über eine Node API. Mit diesem Code fügen wir unser Markup und unser JavaScript ein und lesen dann das DOM als HTML-String zurück. Da wir CSS-Module verwenden, erhalten wir kostenlos CSS-Inlines für die 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);

Nach der Einführung dieser Änderungen können wir mit einer Verbesserung unserer FMP rechnen. Wir müssen immer noch die gleiche Menge an JavaScript wie zuvor laden und ausführen, daher sollten wir nicht davon ausgehen, dass sich die TTI viel ändern. Wenn überhaupt, ist unser index.html größer geworden und kann unseren TTI ein wenig verschieben. Es gibt nur eine Möglichkeit, dies herauszufinden, und zwar mit WebPageTest.

Der Filmstreifen zeigt eine deutliche Verbesserung unseres FMP-Messwerts. TTI ist weitgehend nicht betroffen.

Die Wiedergabezeit von „First Meaningful Paint“ wurde von 8,5 Sekunden auf 4,9 Sekunden geändert. Das ist eine erhebliche Verbesserung. Unser TTI findet immer noch nach etwa 8,5 Sekunden statt, sodass er von dieser Änderung weitgehend unberührt ist. Hier haben wir eine wahrnehmbare Änderung vorgenommen. Manche bezeichnen das sogar als Taschenspieler. Durch das Rendern einer Zwischengrafik des Spiels wird die wahrgenommene Ladeleistung zum Besseren verändert.

Innenfutter

Ein weiterer Messwert, den wir sowohl von den Entwicklertools als auch von WebPageTest erhalten, ist Time To First Byte (TTFB). Dies ist die Zeit, die zwischen dem ersten Byte der Anfrage und dem ersten Byte der empfangenen Antwort benötigt wird. Diese Zeit wird auch als Umlaufzeit (Round Trip Time, RTT) bezeichnet, obwohl technisch gesehen ein Unterschied zwischen diesen beiden Zahlen besteht: RTT enthält nicht die Verarbeitungszeit der Anfrage auf der Serverseite. Mit den DevTools und WebPageTest wird TTFB mit einer hellen Farbe innerhalb des Anfrage-/Antwortblocks visualisiert.

Der helle Abschnitt einer Anfrage bedeutet, dass die Anfrage auf das erste Byte der Antwort wartet.

Wenn wir uns unseren Wasserfall ansehen, sehen wir, dass bei allen Anfragen der Großteil der Zeit damit verbracht wird, auf das erste Byte der Antwort zu warten.

Dies war die ursprüngliche Idee von HTTP/2-Push. Der App-Entwickler weiß, dass bestimmte Ressourcen benötigt werden, und er kann sie übertreiben. Wenn der Client feststellt, dass zusätzliche Ressourcen abgerufen werden müssen, befinden sich diese bereits in den Caches des Browsers. HTTP/2-Push erwies sich als zu schwierig, um richtig zu funktionieren. Daher wird davon abgeraten. Dieser Problembereich wird während der Standardisierung von HTTP/3 noch einmal berücksichtigt. Im Moment ist es am einfachsten, alle kritischen Ressourcen einzubetten, allerdings auf Kosten der Caching-Effizienz.

Unser kritisches CSS ist dank CSS-Modulen und unserem Puppeteer-basierten Pre-Renderer bereits integriert. Bei JavaScript müssen unsere kritischen Module und ihre Abhängigkeiten inline eingefügt werden. Diese Aufgabe hat je nach verwendetem Bundler unterschiedliche Schwierigkeitsgrade.

Durch das Inlineing unseres JavaScript haben wir unsere TTI von 8,5 Sekunden auf 7,2 Sekunden gesenkt.

Dadurch haben wir unseren TTI um 1 Sekunde verkürzt. Unser index.html enthält nun alles, was für das anfängliche Rendering und die Interaktion erforderlich ist. Der HTML-Code kann während des Downloads gerendert werden, sodass unsere FMP erstellt wird. Sobald das Parsing und Ausführen des HTML-Codes abgeschlossen ist, ist die Anwendung interaktiv.

Aggressive Codeaufteilung

Ja, in der index.html ist alles enthalten, was für eine Interaktion erforderlich ist. Bei genauerer Betrachtung stellt sich jedoch heraus, dass sie auch alles andere enthält. Unser index.html ist etwa 43 KB groß. Betrachten wir dies im Verhältnis zu den Elementen, mit denen der Nutzer zu Beginn interagieren kann: Wir haben ein Formular zum Konfigurieren des Spiels, das einige Komponenten, eine Startschaltfläche und wahrscheinlich Code enthält, der beibehalten und die Nutzereinstellungen geladen wird. Das war's auch schon. 43 KB scheint viel zu sein.

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

Um zu verstehen, woher die Bundle-Größe stammt, können wir einen Source Map-Explorer oder ein ähnliches Tool verwenden, um aufzuschlüsseln, woraus das Bundle besteht. Wie bereits vorhergesagt, enthält unser Bundle die Spiellogik, das Rendering-Modul, den Gewinnbildschirm, den Verlustbildschirm und eine Reihe von Dienstprogrammen. Für die Landingpage wird nur eine kleine Teilmenge dieser Module benötigt. Wenn Sie alle Elemente, die nicht unbedingt für die Interaktivität erforderlich sind, in ein langsam geladenes Modul verschieben, wird die TTI erheblich reduziert.

Bei der Analyse des Inhalts der Datei „index.html“ von PROXX werden viele nicht benötigte Ressourcen angezeigt. Wichtige Ressourcen sind hervorgehoben.

Was wir tun müssen, ist die Codeaufteilung. Durch die Codeaufteilung wird Ihr monolithisches Bundle in kleinere Teile aufgeteilt, die bei Bedarf per Lazy Loading geladen werden können. Beliebte Bundler wie Webpack, Rollup und Parcel unterstützen die Codeaufteilung mithilfe von dynamischem import(). Der Bundler analysiert Ihren Code und fügt alle Module, die statisch importiert werden, ein. Alle Daten, die Sie dynamisch importieren, werden in einer eigenen Datei gespeichert und erst dann aus dem Netzwerk abgerufen, wenn der import()-Aufruf ausgeführt wurde. Natürlich kostet das Netzwerk Kosten und sollte nur dann geschehen, wenn Sie Zeit haben. Das Mantra ist hier, die Module, die zum Zeitpunkt des Ladevorgangs entscheidend benötigt werden, statisch zu importieren und alles andere dynamisch zu laden. Sie sollten jedoch nicht bis zum letzten Moment mit Lazy Loading für Module warten, die definitiv verwendet werden. Idle Until Urgent von Phil Walton ist ein gutes Muster, um einen gesunden Mittelweg zwischen Lazy Loading und Ea Eager Loading zu finden.

In PROXX haben wir eine lazy.js-Datei erstellt, mit der alles, was wir nicht benötigen, statisch importiert. In unserer Hauptdatei können wir lazy.js dann dynamisch importieren. Einige unserer Preact-Komponenten landeten jedoch in lazy.js, was sich als eine Komplikation erwies, da Preact diese Komponenten nicht standardmäßig verarbeiten kann. Deshalb haben wir einen kleinen deferred-Komponenten-Wrapper erstellt, mit dem ein Platzhalter gerendert werden kann, 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-Objekt einer Komponente in unseren render()-Funktionen verwenden. So wird beispielsweise die Komponente „<Nebula>“, die das animierte Hintergrundbild rendert, durch ein leeres <div> ersetzt, während die Komponente geladen wird. Sobald die Komponente geladen und einsatzbereit ist, wird die <div> durch die eigentliche Komponente ersetzt.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

Jetzt haben wir die index.html auf lediglich 20 KB reduziert, also weniger als die Hälfte der ursprünglichen Größe. Welche Auswirkungen hat das auf FMP und TTI? WebPageTest wird Ihnen dies erzählen!

Der Filmstreifen bestätigt: Unser TTI liegt jetzt bei 5,4 s. Eine drastische Verbesserung im Vergleich zu den 11er-Modellen.

FMP und TTI liegen nur 100 ms auseinander, da nur das Parsen und Ausführen des Inline-JavaScript-Codes erforderlich ist. Schon nach nur 5, 4 Sekunden mit 2G ist die App vollständig interaktiv. Alle anderen, weniger wichtigen Module werden im Hintergrund geladen.

Mehr Taschenspieler

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 starten, wenn wir unsere Rendering-Engine zum Rendern des Spiels haben. Wir könnten die Schaltfläche "Start" deaktivieren, bis unser Rendering-Modul bereit ist, das Spiel zu starten. Unserer Erfahrung nach benötigt der Nutzer jedoch in der Regel lange genug für die Konfiguration der Spieleinstellungen, dass dies nicht erforderlich ist. Meistens sind das Rendering-Modul und die anderen verbleibenden Module fertig, bevor der Nutzer auf „Start“ drückt. Für den seltenen Fall, dass der Nutzer schneller ist als seine Netzwerkverbindung, zeigen wir einen einfachen Ladebildschirm an, der darauf wartet, dass die verbleibenden Module fertig sind.

Fazit

Analysieren ist wichtig. Um zu vermeiden, dass Sie Zeit für Probleme aufwenden, die nicht real sind, empfehlen wir, immer zuerst Messungen durchzuführen, bevor Sie Optimierungen implementieren. Die Messungen sollten außerdem auf echten Geräten mit einer 3G-Verbindung oder auf WebPageTest erfolgen, wenn kein echtes Gerät zur Verfügung steht.

Der Filmstreifen gibt Aufschluss darüber, 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. Mit der folgenden Checkliste können Sie die Ladeleistung verbessern:

  • Stellen Sie so viele Assets wie möglich über eine Verbindung bereit.
  • Ressourcen vorab laden oder sogar Inline-Ressourcen speichern, die für das erste Rendering und die erste Interaktivität erforderlich sind.
  • Rendere deine App vorab, um die wahrgenommene Ladeleistung zu verbessern.
  • Nutzen Sie eine aggressive Codeaufteilung, um den für die Interaktivität erforderlichen Code zu reduzieren.

In Teil 2 wird erläutert, wie Sie die Laufzeitleistung auf stark eingeschränkten Geräten optimieren können.