Web Worker verwenden, um JavaScript über den Hauptthread des Browsers auszuführen

Eine Architektur außerhalb des Hauptthreads kann die Zuverlässigkeit und Nutzerfreundlichkeit Ihrer Anwendung erheblich verbessern.

Surma
Surma

In den letzten 20 Jahren hat sich das Web stark weiterentwickelt – von statischen Dokumenten mit wenigen Stilen und Bildern zu komplexen, dynamischen Anwendungen. Eine Sache ist jedoch weitgehend unverändert geblieben: Wir haben nur einen Thread pro Browser-Tab (mit einigen Ausnahmen), um unsere Websites zu rendern und unseren JavaScript-Code auszuführen.

Infolgedessen wurde der Hauptthread unglaublich überarbeitet. Mit zunehmender Komplexität von Webanwendungen wird der Hauptthread zu einem erheblichen Leistungsengpass. Außerdem ist die Zeit, die für die Ausführung von Code im Hauptthread eines bestimmten Nutzers erforderlich ist, fast unvorhersehbar, da die Gerätefunktionen einen enormen Einfluss auf die Leistung haben. Diese Unvorhersehbarkeit wird nur noch zunehmen, wenn die Nutzer von immer vielfältigeren Geräten auf das Web zugreifen, von extrem eingeschränkten Feature-Phones bis hin zu leistungsstarken Flagship-Geräten mit hoher Aktualisierungsrate.

Wenn wir möchten, dass komplexe Web-Apps Leistungsrichtlinien wie den Core Web Vitals, der auf empirischen Daten zur menschlichen Wahrnehmung und Psychologie basiert, zuverlässig erfüllt, benötigen wir Möglichkeiten, unseren Code aus dem Hauptthread (OMT) auszuführen.

Vorteile von Web Workern

JavaScript ist standardmäßig eine Single-Threaded-Sprache, die Aufgaben im Hauptthread ausführt. Web Worker bieten jedoch eine Art Ausstieg vom Hauptthread, da Entwickler separate Threads erstellen können, um Aufgaben aus dem Hauptthread zu verarbeiten. Auch wenn der Umfang von Web Workern begrenzt ist und keinen direkten Zugriff auf das DOM bietet, können sie enorm vorteilhaft sein, wenn erhebliche Arbeitsschritte erledigt werden müssen, die andernfalls den Hauptthread überlasten würden.

Wenn es um Core Web Vitals geht, kann es von Vorteil sein, die Arbeit außerhalb des Hauptthreads zu laufen. Insbesondere kann das Auslagern der Arbeit vom Hauptthread auf Web Worker Konflikte beim Hauptthread reduzieren, wodurch wichtige Messwerte für die Reaktionsfähigkeit wie Interaction to Next Paint (INP) und First Input Delay (FID) verbessert werden können. Wenn der Hauptthread weniger Arbeit verarbeitet, kann er schneller auf Nutzerinteraktionen reagieren.

Weniger Hauptthread-Arbeiten – insbesondere während des Starts – bringen auch einen potenziellen Vorteil für den Largest Contentful Paint (LCP) mit sich, da die langen Aufgaben reduziert werden. Das Rendern eines LCP-Elements erfordert die Hauptthreadzeit – entweder für das Rendern von Text oder Bildern, die häufig vorkommen und häufig vorkommen. Durch die allgemeine Reduzierung der Hauptthread-Arbeit können Sie sicherstellen, dass das LCP-Element Ihrer Seite weniger wahrscheinlich durch kostspielige Arbeit blockiert wird, die ein Web Worker verarbeiten könnte.

Threading mit Web Workern

Andere Plattformen unterstützen in der Regel parallele Arbeit, indem sie es Ihnen ermöglichen, einem Thread eine Funktion zuzuweisen, die parallel zum Rest Ihres Programms ausgeführt wird. Sie können von beiden Threads auf dieselben Variablen zugreifen und der Zugriff auf diese gemeinsam genutzten Ressourcen kann mit Mutexen und Semaphoren synchronisiert werden, um Race-Bedingungen zu vermeiden.

In JavaScript erhalten wir etwa ähnliche Funktionen von Web Workern, die seit 2007 verfügbar sind und seit 2012 in allen gängigen Browsern unterstützt werden. Web-Worker werden parallel zum Hauptthread ausgeführt, können aber im Gegensatz zum Betriebssystem-Threading keine Variablen gemeinsam nutzen.

Um einen Web Worker zu erstellen, übergeben Sie eine Datei an den Worker-Konstruktor, der die Datei in einem separaten Thread ausführt:

const worker = new Worker("./worker.js");

Kommunizieren Sie mit dem Web Worker, indem Sie Nachrichten über die postMessage API senden. Übergeben Sie den Nachrichtenwert als Parameter im postMessage-Aufruf und fügen Sie dem Worker dann einen Nachrichtenereignis-Listener hinzu:

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

Wenn Sie eine Nachricht an den Hauptthread zurücksenden möchten, verwenden Sie dieselbe postMessage API im Web Worker und richten Sie einen Event-Listener im Hauptthread ein:

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

Zugegebenermaßen ist dieser Ansatz etwas eingeschränkt. In der Vergangenheit wurden Web Worker hauptsächlich verwendet, um ein einzelnes Arbeitsstück aus dem Hauptthread zu verschieben. Der Versuch, mehrere Vorgänge mit einem einzigen Web Worker abzuwickeln, wird schnell unübersichtlich: Sie müssen nicht nur die Parameter, sondern auch die Operation in der Nachricht codieren und Sie müssen eine Buchhaltung erledigen, um die Antworten mit den Anfragen abzugleichen. Diese Komplexität ist wahrscheinlich der Grund, warum Web Worker bislang nicht in größerem Umfang eingesetzt wurden.

Wenn wir jedoch einige der Schwierigkeiten bei der Kommunikation zwischen dem Hauptthread und den Web Workern beseitigen könnten, könnte dieses Modell für viele Anwendungsfälle gut geeignet sein. Und glücklicherweise gibt es eine Bibliothek, die genau das tut!

Comlink ist eine Bibliothek, die es Ihnen ermöglicht, Web Worker zu verwenden, ohne sich über die Details von postMessage Gedanken machen zu müssen. Mit Comlink können Sie Variablen zwischen Web Workern und dem Haupt-Thread teilen, fast wie bei anderen Programmiersprachen, die Threading unterstützen.

Sie richten Comlink ein, indem Sie es in einen Web Worker importieren und eine Reihe von Funktionen definieren, die dem Hauptthread zur Verfügung gestellt werden. Anschließend importieren Sie Comlink im Hauptthread, verpacken den Worker und erhalten Zugriff auf die freigegebenen Funktionen:

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

Die Variable api im Hauptthread verhält sich genauso wie die Variable im Web Worker, mit der Ausnahme, dass jede Funktion ein Versprechen für einen Wert zurückgibt, nicht den Wert selbst.

Welchen Code sollten Sie in einen Web Worker übertragen?

Web Worker haben keinen Zugriff auf das DOM und viele APIs wie WebUSB, WebRTC oder Web Audio. Daher können Sie keine Teile Ihrer Anwendung, die auf diesen Zugriff angewiesen sind, in einen Worker einbinden. Dennoch braucht jedes kleine Code-Snippet, das in einen Worker verschoben wird, mehr Spielraum im Hauptthread für Dinge, die noch erforderlich sind, wie etwa die Aktualisierung der Benutzeroberfläche.

Ein Problem für Webentwickler besteht darin, dass die meisten Web-Apps auf ein UI-Framework wie Vue oder React angewiesen sind, um alles in der App zu orchestrieren. Alles ist Teil des Frameworks und daher eng mit dem DOM verbunden. Das scheint die Migration zu einer OMT-Architektur zu erschweren.

Wenn wir jedoch zu einem Modell wechseln, bei dem UI-Bedenken von anderen Aspekten wie der Zustandsverwaltung getrennt sind, können Web Worker auch bei Framework-basierten Anwendungen sehr nützlich sein. Genau diesen Ansatz verfolgt PROXX.

PROXX: Eine OMT-Fallstudie

Das Google Chrome-Team hat PROXX als Minesweeper-Klon entwickelt, der die Anforderungen an progressive Web-Apps erfüllt, einschließlich Offline-Arbeiten und einer ansprechenden Nutzererfahrung. Leider liefen frühe Versionen des Spiels auf eingeschränkten Geräten wie Feature-Phones schlecht ab, was dazu führte, dass das Team erkannte, dass der Hauptthread ein Engpass war.

Das Team entschied sich für Web Worker, um den visuellen Zustand des Spiels von seiner Logik zu trennen:

  • Der Hauptthread übernimmt das Rendern von Animationen und Übergängen.
  • Ein Web Worker ist für die rein computergestützte Spiellogik zuständig.

OMT hatte interessante Auswirkungen auf die Feature-Phone-Leistung von PROXX. In der Nicht-OMT-Version ist die Benutzeroberfläche sechs Sekunden lang eingefroren, nachdem der Nutzer damit interagiert hat. Es gibt kein Feedback und der Nutzer muss die vollen sechs Sekunden warten, bevor er etwas anderes tun kann.

UI-Reaktionszeit in der Nicht-OMT-Version von PROXX.

In der OMT-Version dauert das Spiel jedoch zwölf Sekunden, um ein UI-Update durchzuführen. Das mag nach einem Leistungsverlust erscheinen, führt aber tatsächlich zu mehr Feedback an die Nutzer. Die Verlangsamung tritt auf, weil die App mehr Frames als die Nicht-OMT-Version sendet, bei der überhaupt keine Frames ausgeliefert werden. Der Nutzer weiß also, dass etwas passiert, und kann weiterspielen, wenn die Benutzeroberfläche aktualisiert wird. Dadurch fühlt sich das Spiel deutlich besser an.

Reaktionszeit für die Benutzeroberfläche in der OMT-Version von PROXX.

Dies ist ein bewusster Kompromiss: Wir bieten Nutzern mit eingeschränkten Geräten ein besseres Erlebnis, ohne dass Nutzer von High-End-Geräten negative Auswirkungen haben.

Auswirkungen einer OMT-Architektur

Wie das PROXX-Beispiel zeigt, sorgt OMT dafür, dass deine App zuverlässig auf einer größeren Bandbreite von Geräten ausgeführt wird. Die Geschwindigkeit wird dadurch jedoch nicht erhöht:

  • Sie verschieben nur die Arbeit aus dem Hauptthread, ohne den Aufwand zu reduzieren.
  • Der zusätzliche Kommunikationsaufwand zwischen dem Web Worker und dem Hauptthread kann die Abläufe manchmal geringfügig verlangsamen.

Vor- und Nachteile

Da der Hauptthread Nutzerinteraktionen wie Scrollen bei der Ausführung von JavaScript verarbeiten kann, werden weniger Frames ausgelassen, obwohl die Gesamtwartezeit geringfügig länger sein kann. Es ist besser, den Nutzer etwas warten zu lassen, anstatt einen Frame zu löschen, da die Fehlerspanne für verworfene Frames kleiner ist: Das Löschen eines Frames erfolgt in Millisekunden, während es Hunderte von Millisekunden gibt, bevor der Nutzer die Wartezeit wahrnimmt.

Aufgrund der Unvorhersehbarkeit der Leistung auf verschiedenen Geräten besteht das Ziel der OMT-Architektur in erster Linie darin, das Risiko zu reduzieren, d. h. Ihre App angesichts sehr variablen Laufzeitbedingungen robuster zu machen, und nicht auf den Leistungsvorteilen der Parallelisierung. Die höhere Robustheit und die Verbesserungen der UX sind einen kleinen Kompromiss bei der Geschwindigkeit mehr als wert.

Hinweis zu Tools

Web Worker sind noch nicht Mainstream, daher bieten die meisten Modultools wie webpack und Rollup diese nicht standardmäßig. Parcel hingegen schon. Zum Glück gibt es Plug-ins, mit denen Web Worker mit Webpack und Rollup funktionieren können:

Zusammenfassung

Wir möchten dafür sorgen, dass unsere Apps so zuverlässig und zugänglich wie möglich sind – insbesondere in einem zunehmend globalisierten Markt. Daher müssen wir eingeschränkte Geräte unterstützen. Diese sind die Art und Weise, wie die meisten Nutzer weltweit auf das Internet zugreifen. OMT bietet eine vielversprechende Möglichkeit, die Leistung auf solchen Geräten zu steigern, ohne die Nutzer von High-End-Geräten zu beeinträchtigen.

Weitere Vorteile von OMT:

  • Die Kosten für die JavaScript-Ausführung werden in einen separaten Thread verschoben.
  • Dies reduziert die Analysekosten, was bedeutet, dass die Benutzeroberfläche schneller hochfährt. Das kann First Contentful Paint oder sogar Time to Interactive (Zeit bis Interaktivität) reduzieren, was wiederum Ihren Lighthouse-Wert erhöhen kann.

Web Worker müssen nicht beängstigend sein. Tools wie Comlink entlasten Mitarbeiter und machen sie zu einer praktikablen Wahl für eine Vielzahl von Webanwendungen.

Hero-Image aus Unsplash von James Peacock