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

So haben wir Codeaufteilung, Code-Inlining und serverseitiges Rendering in PROXX verwendet.

Bei der Google I/O 2019 haben Mariko, Jake und ich PROXX verliehen, 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. Für Feature-Phones gibt es mehrere Einschränkungen:

  • 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 sind Feature-Phones in Schwellenländern immer beliebter. Ihr Preispunkt ermöglicht einer völlig neuen Zielgruppe, die es sich zuvor nicht leisten konnte, online zu gehen und das moderne Web zu 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 wie 2G in Schwellenländern der Normalbereich. Wie konnten wir dafür sorgen, dass PROXX auch unter Feature-Phone-Bedingungen gut funktioniert?

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> PROXX-Gameplay.

Die Leistung ist wichtig – dazu gehören sowohl die Ladeleistung 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 viel mehr Daten und Informationen darüber, 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.

Den Status quo einfangen

Es ist wichtig, die Ladeleistung auf einem realen Gerät zu testen. Wenn Sie kein echtes Gerät zur Hand haben, empfehlen wir WebPageTest, insbesondere das "einfache" einrichten. WPT führt einen Akku für Ladetests auf einem realen Gerät mit emulierter 3G-Verbindung aus.

Die Messung der 3G-Geschwindigkeit ist gut. Auch wenn Sie 4G, LTE oder bald sogar 5G kennen, sieht die Realität des mobilen Internets ganz anders aus. Vielleicht sind Sie im Zug, auf einer Konferenz, bei einem Konzert oder auf einem Flug. 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 WebPageTest den Test ausgeführt hat, erhalten Sie einen Wasserfall (ähnlich dem, was Sie in den Entwicklertools sehen) sowie einen Filmstreifen am oberen Rand. Der Filmstreifen zeigt, was der Nutzer sieht, während Ihre App geladen wird. Bei 2G funktioniert die nicht optimierte PROXX-Version ziemlich schlecht:

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 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 4 Sekunden lang Leerlauf. Über 2G sieht der Nutzer länger als 8 Sekunden nichts. Wenn Sie den Artikel Warum Leistung wichtig ist lesen, wissen Sie, dass uns nun ein großer Teil unserer potenziellen Nutzer aufgrund von Ungeduld verloren gegangen ist. 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?

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> [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 an gzip-d-JS heruntergeladen und das DOM generiert wurden, kann der Nutzer unsere App sehen. Die App ist technisch interaktiv. Ein Blick auf das Bild zeigt jedoch eine andere Realität. Die Web-Schriftarten werden noch im Hintergrund geladen. Bis sie bereit sind, sehen die Nutzer keinen Text. Dieser Status gilt zwar als First Meaningful Paint (FMP), ist aber sicherlich nicht als interaktiv eingestuft, da der Nutzer nicht wissen kann, worum es bei den Eingaben geht. 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.

Wasserfallanalyse

Nachdem wir nun wissen, was der Nutzer sieht, müssen wir herausfinden, warum das der Fall ist. 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. Beispielsweise beginnt das Laden der zweiten Ressource erst, wenn die erste Ressource abgeschlossen ist, und die dritte Ressource wird erst gestartet, wenn die zweite Ressource abgeschlossen ist.
<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 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. Das Einrichten einer neuen Verbindung ist kostspielig, da es bei 3G etwa 1 Sekunde und bei 2G etwa 2,5 Sekunden dauert. In unserem Wasserfall 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 Nr. 14: Web-App-Manifest

Die neue Verbindung für index.html ist unvermeidbar. 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 während der Inaktivität geladen werden, wenn alles andere bereits geladen ist. So wird während des anfänglichen Ladevorgangs keine Bandbreite oder Rechenleistung in Anspruch genommen. 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 hindert das Manifest der Web-App unsere App nicht daran, zu rendern oder interaktiv zu werden, sodass wir uns nicht so sehr darum kümmern müssen.

Die beiden Schriftarten und ihre Stile stellen jedoch ein Problem dar, 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 unseren HTML-Code eingebunden 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. Dies 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. Das Wichtigste dabei ist, dass diese Art von Abhängigkeiten bei 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 mit unseren Änderungen erreicht wurde. Es ist wichtig, keine anderen Variablen in unserer Testumgebung zu ändern, die die Ergebnisse verzerren könnten. Daher verwenden wir für den Rest dieses Artikels die einfache Einrichtung von WebPageTest und schauen uns den Filmstrip an:

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Wir verwenden den Filmstrip von WebPageTest, um zu sehen, was wir mit unseren Änderungen erreicht haben.

Durch diese Änderungen verringerte sich der TTI von 11 auf 8,5, was ungefähr der Zeit für die Verbindungseinrichtung entspricht, die wir entfernen wollten. Gut gemacht.

Pre-Rendering

Wir haben zwar gerade unsere TTI reduziert, haben aber nicht wirklich den ewigen weißen Bildschirm verändert, den der Nutzer 8,5 Sekunden lang aushalten muss. Die wahrscheinlich größten Verbesserungen bei FMP lassen sich durch das Senden von Markup mit benutzerdefinierten Stilen in index.html erzielen. Gängige Methoden hierfür sind das Pre-Rendering und das serverseitige Rendering. Beide sind eng miteinander verwandt und werden im Artikel Rendering im Web erläutert. Beide Techniken führen die Webanwendung in Node aus und serialisieren das resultierende DOM in HTML. Beim serverseitigen Rendern erfolgt dies pro Anfrage auf der Serverseite, während das beim Pre-Rendering zum Zeitpunkt der Erstellung erfolgt 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 haben wir uns für Puppeteer entschieden, das Chrome ohne Benutzeroberfläche startet und es Ihnen ermöglicht, diese Instanz mit einer Node API fernzusteuern. 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 CSS-Inline-Elemente zu den benötigten Stilen.

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

Damit können wir eine Verbesserung für unsere FMP erwarten. Wir müssen immer noch die gleiche Menge an JavaScript wie zuvor laden und ausführen, daher sollte sich die TTI nicht stark ändern. Falls überhaupt, ist unser index.html größer geworden, was sich möglicherweise ein wenig auf unseren TTI verlangsamt. Es gibt nur eine Möglichkeit, dies herauszufinden: WebPageTest.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Der Filmstrip zeigt eine deutliche Verbesserung unseres FMP-Messwerts. TTI ist weitgehend nicht betroffen.

Unser First Meaningful Paint hat sich von 8,5 auf 4,9 Sekunden verschoben – eine enorme Verbesserung. Unser TTI findet nach etwa 8,5 Sekunden statt und ist von dieser Änderung weitgehend unberührt. Hier haben wir eine Wahrnehmung geändert. Manche bezeichnen es sogar als Taschentuch. Durch das Rendern einer Zwischengrafik des Spiels ändern wir die wahrgenommene Ladeleistung zum Besseren.

Inliner

Ein weiterer Messwert, den sowohl die Entwicklertools als auch WebPageTest liefern, ist Time To First Byte (TTFB). Dies ist die Zeit, die vom ersten Byte der Anfrage bis zum ersten Byte der empfangenen Antwort benötigt wird. Diese Zeit wird oft auch als Umlaufzeit (Round Trip Time, RTT) bezeichnet, obwohl es technisch gesehen einen Unterschied zwischen diesen beiden Zahlen gibt: RTT beinhaltet nicht die Verarbeitungszeit der Anfrage auf der Serverseite. DevTools und WebPageTest visualisieren TTFB im Anfrage-/Antwortblock mit einer hellen Farbe.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Der Light-Abschnitt einer Anfrage zeigt an, dass die Anfrage darauf wartet, das erste Byte der Antwort zu erhalten.

Wenn wir uns die Vermittlungsabfolge ansehen, erkennen wir, dass alle Anfragen den Großteil ihrer Zeit auf das erste Byte der Antwort warten.

Dieses Problem war ursprünglich gedacht, als HTTP/2 Push gedacht war. Der App-Entwickler weiß, dass bestimmte Ressourcen benötigt werden, und kann sie verschieben. Wenn der Client feststellt, dass er zusätzliche Ressourcen abrufen muss, befinden sich diese bereits in den Caches 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 berücksichtigt. Im Moment ist es die einfachste Lösung, alle kritischen Ressourcen einzubetten, was zulasten der Caching-Effizienz geht.

Unser kritisches CSS wurde dank der CSS-Module und unseres Puppeteer-basierten Pre-Renderings bereits eingebunden. Bei JavaScript müssen wir unsere kritischen Module und ihre Abhängigkeiten inline einfügen. Diese Aufgabe hat je nach verwendetem Bundler unterschiedliche Schwierigkeitsgrade.

<ph type="x-smartling-placeholder">
</ph>
. Durch die Einbindung unseres JavaScript-Codes haben wir die TTI von 8,5 s auf 7,2 s reduziert.

Dadurch verkürzte sich unser TTI um eine Sekunde. Wir sind jetzt an dem Punkt, an dem index.html alles enthält, was für das erste Rendering und eine Interaktivität erforderlich ist. Der HTML-Code kann während des Downloads gerendert werden, wodurch unsere FMP erstellt wird. Sobald das Parsen und die Ausführung des HTML-Codes abgeschlossen ist, ist die Anwendung interaktiv.

Aggressive Codeaufteilung

Ja, die index.html enthält alles, was für die Interaktion 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 scheint eine Menge zu sein.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Die Landingpage von PROXX. Hier werden nur kritische Komponenten verwendet.

Um zu ermitteln, woher die Bundle-Größe stammt, können wir einen Source Map Explorer oder ein ähnliches Tool verwenden, um die Bestandteile des Bundles aufzuschlüsseln. Wie vorhergesagt, enthält unser Paket die Spiellogik, die Rendering-Engine, den Gewinnbildschirm, den Verlustbildschirm und eine Reihe von Dienstprogrammen. Nur ein kleiner Teil dieser Module wird für die Landingpage benötigt. Wenn alles, was für Interaktivität nicht unbedingt erforderlich ist, in ein langsam geladenes Modul verschoben wird, wird der TTI erheblich reduziert.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Die Analyse des Inhalts von `index.html` von PROXX zeigt eine Vielzahl nicht benötigter Ressourcen. Kritische Ressourcen sind hervorgehoben.

Dafür müssen wir den Code aufteilen. Beim Code-Splitting 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 Codeaufteilung mithilfe des dynamischen import(). Der Bundler analysiert den Code und inline alle Module ein, die statisch importiert werden. Alles, was Sie dynamisch importieren, wird in einer eigenen Datei gespeichert und erst dann aus dem Netzwerk abgerufen, wenn der import()-Aufruf ausgeführt wird. Natürlich ist der Zugriff auf das Netzwerk Kosten und sollte nur erfolgen, wenn Sie genügend Zeit haben. Das Mantra hier ist, die Module, die zur Ladezeit wichtig sind, statisch zu importieren und alles andere dynamisch zu laden. Sie sollten jedoch nicht bis zum letzten Moment warten, bis Sie Lazy Loading für die Module haben, die auf jeden Fall eingesetzt werden. Idle Until Urgent von Phil Walton ist ein großartiges Muster für einen gesunden Mittelweg zwischen Lazy Loading und Eager Loading.

In PROXX haben wir eine lazy.js-Datei erstellt, die alles statisch importiert, was wir nicht benötigen. In unserer Hauptdatei kann dann lazy.js dynamisch importiert werden. Einige unserer Preact-Komponenten sind jedoch in lazy.js enthalten, was sich jedoch als eine Komplikation herausgestellt hat, da Preact keine langsam geladenen Komponenten von vornherein verarbeiten kann. Aus diesem Grund haben wir einen kleinen deferred-Komponenten-Wrapper geschrieben, mit dem wir einen Platzhalter so lange 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, durch ein leeres <div> ersetzt, während die Komponente geladen wird. Sobald die Komponente geladen und einsatzbereit ist, wird <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. Wie wirkt sich das auf FMP und TTI aus? WebPageTest liefert die Antwort.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Der Filmstreifen bestätigt: Unser TTI liegt jetzt bei 5,4 s. Eine drastische Verbesserung im Vergleich zu unseren ursprünglichen 11 Pixeln.

Unser FMP und TTI sind nur 100 ms voneinander entfernt, da nur der Inline-JavaScript-Code geparst und ausgeführt werden muss. Schon nach nur 5, 4 Sekunden mit 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 starten, wenn unsere Rendering-Engine das Spiel rendert. Wir könnten die Schaltfläche „Starten“ , bis das Rendering-Modul zum Starten des Spiels bereit ist. Unserer Erfahrung nach dauert es jedoch in der Regel lange genug, um die Spieleinstellungen zu konfigurieren, sodass dies nicht erforderlich ist. Meistens ist der Ladevorgang des Rendering-Moduls und der übrigen Module abgeschlossen, bis der Nutzer auf "Start" drückt. In dem seltenen Fall, dass der Nutzer schneller ist als die Netzwerkverbindung, wird ein einfacher Ladebildschirm angezeigt, der wartet, bis die verbleibenden Module abgeschlossen 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 die Messungen auf realen Geräten mit einer 3G-Verbindung oder auf WebPageTest durchgeführt werden, wenn kein echtes Gerät zur Verfügung steht.

Der Filmstreifen kann Aufschluss darüber geben, wie sich das Laden Ihrer App für den 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 Möglichkeiten, wie Sie die Ladeleistung verbessern können:

  • Stellen Sie so viele Assets wie möglich über eine Verbindung bereit.
  • Ressourcen vorab laden oder sogar Inline-Ressourcen laden, 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 geht es um die Optimierung der Laufzeitleistung auf stark eingeschränkten Geräten.