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

Eine Off-Main-Thread-Architektur kann die Zuverlässigkeit und Nutzerfreundlichkeit Ihrer App erheblich verbessern.

In den letzten 20 Jahren hat sich das Web dramatisch von statischen Dokumenten mit wenigen Stilen und Bildern zu komplexen, dynamischen Anwendungen entwickelt. Eines hat sich jedoch weitgehend nicht verändert: Wir haben (mit einigen Ausnahmen) nur einen Thread pro Browsertab, der das Rendern unserer Websites und das Ausführen unseres JavaScript übernimmt.

Dadurch ist der Hauptthread extrem überlastet. Mit zunehmender Komplexität von Webanwendungen wird der Hauptthread zu einem erheblichen Leistungsengpass. Erschwerend kommt hinzu, dass die Zeit, die für die Ausführung von Code im Hauptthread für einen bestimmten Nutzer benötigt wird, fast völlig unvorhersehbar ist, da sich die Gerätefunktionen massiv auf die Leistung auswirken. Diese Unvorhersehbarkeit wird sich noch verstärken, da Nutzer immer mehr unterschiedliche Geräte zum Surfen im Web verwenden, von stark eingeschränkten Feature-Phones bis hin zu leistungsstarken Flagship-Geräten mit hoher Bildwiederholrate.

Wenn anspruchsvolle Web-Apps zuverlässig Leistungsrichtlinien wie die Core Web Vitals erfüllen sollen, die auf empirischen Daten zur menschlichen Wahrnehmung und Psychologie basieren, benötigen wir Möglichkeiten, unseren Code außerhalb des Hauptthreads (OMT) auszuführen.

Warum Webworker?

JavaScript ist standardmäßig eine Sprache mit einem einzigen Thread, in der Aufgaben im Hauptthread ausgeführt werden. Webworker bieten jedoch eine Art Notausstieg aus dem Hauptthread, da Entwickler damit separate Threads erstellen können, um Aufgaben außerhalb des Hauptthreads zu verarbeiten. Webworker sind zwar eingeschränkt und bieten keinen direkten Zugriff auf das DOM, sie können aber sehr nützlich sein, wenn viel Arbeit anfällt, die den Hauptthread sonst überlasten würde.

Was die Core Web Vitals angeht, kann es von Vorteil sein, Aufgaben außerhalb des Hauptthreads auszuführen. Insbesondere kann durch das Auslagern von Arbeit vom Haupt-Thread an Webworker die Konkurrenz um den Haupt-Thread reduziert werden, was den Reaktionsmesswert Interaction to Next Paint (INP) einer Seite verbessern kann. Wenn der Haupt-Thread weniger Arbeit zu verarbeiten hat, kann er schneller auf Nutzerinteraktionen reagieren.

Weniger Arbeit im Hauptthread – insbesondere beim Starten – kann auch einen potenziellen Vorteil für den Largest Contentful Paint (LCP) haben, da lange Aufgaben reduziert werden. Das Rendern eines LCP-Elements erfordert Zeit für den Hauptthread – entweder für das Rendern von Text oder Bildern, die häufige und gängige LCP-Elemente sind. Wenn Sie die Arbeit des Hauptthreads insgesamt reduzieren, ist das LCP-Element Ihrer Seite mit geringerer Wahrscheinlichkeit durch teure Aufgaben blockiert, die stattdessen von einem Webworker ausgeführt werden könnten.

Threads mit Web-Workern

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

In JavaScript können wir mit Webworkern, die seit 2007 verfügbar sind und seit 2012 in allen gängigen Browsern unterstützt werden, ungefähr dieselben Funktionen nutzen. Webworker werden parallel zum Hauptthread ausgeführt, können aber im Gegensatz zum Betriebssystem-Threading keine Variablen gemeinsam nutzen.

Wenn Sie einen Webworker erstellen möchten, übergeben Sie dem Worker-Konstruktor eine Datei, die dann in einem separaten Thread ausgeführt wird:

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

Kommuniziere mit dem Webworker, indem du Nachrichten über die postMessage API sendest. Ü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 Webworker und richten Sie einen Ereignis-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);
});

Dieser Ansatz ist zwar etwas eingeschränkt. Bisher wurden Webworker hauptsächlich verwendet, um eine einzelne intensive Aufgabe aus dem Haupt-Thread zu verschieben. Der Umgang mit mehreren Vorgängen mit einem einzelnen Webworker wird schnell unübersichtlich: Sie müssen nicht nur die Parameter, sondern auch den Vorgang in der Nachricht codieren und die Antworten den Anfragen zuordnen. Diese Komplexität ist wahrscheinlich der Grund, warum Webworker nicht weiter verbreitet sind.

Wenn wir jedoch einige der Schwierigkeiten bei der Kommunikation zwischen dem Haupt-Thread und den Webworkern beseitigen könnten, wäre dieses Modell für viele Anwendungsfälle geeignet. Glücklicherweise gibt es eine Bibliothek, die genau das tut.

Comlink ist eine Bibliothek, mit der Sie Webworker verwenden können, ohne sich um die Details von postMessage kümmern zu müssen. Mit Comlink können Sie Variablen fast wie in anderen Programmiersprachen, die Threading unterstützen, zwischen Webworkern und dem Hauptthread freigeben.

Sie richten Comlink ein, indem Sie es in einen Webworker importieren und eine Reihe von Funktionen definieren, die für den Hauptthread freigegeben werden sollen. Sie importieren dann Comlink in den 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 im Webworker, mit der Ausnahme, dass jede Funktion ein Versprechen für einen Wert zurückgibt, anstatt den Wert selbst.

Welchen Code sollten Sie in einen Webworker verschieben?

Webworker haben keinen Zugriff auf das DOM und viele APIs wie WebUSB, WebRTC oder Web Audio. Daher können Sie Teile Ihrer App, die auf solchen Zugriffen basieren, nicht in einen Worker einfügen. Dennoch schafft jedes kleine Code-Snippet, das in einen Worker verschoben wird, mehr Spielraum im Hauptthread für Dinge, die dabei sein müssen, z. B. die Aktualisierung der Benutzeroberfläche.

Ein Problem für Webentwickler besteht darin, dass die meisten Web-Apps ein UI-Framework wie Vue oder React verwenden, um alles in der App zu orchestrieren. Alles ist eine Komponente des Frameworks und daher inhärent mit dem DOM verbunden. Das würde die Migration zu einer OMT-Architektur erschweren.

Wenn wir jedoch zu einem Modell übergehen, in dem UI-Aspekte von anderen Aspekten wie der Zustandsverwaltung getrennt werden, können Webworker auch bei frameworkbasierten Apps sehr nützlich sein. Genau das ist der Ansatz bei PROXX.

PROXX: eine Fallstudie zur Organisationsentwicklung

Das Google Chrome-Team hat PROXX als Minesweeper-Klon entwickelt, der die Anforderungen einer progressiven Webanwendung erfüllt, einschließlich Offlinefunktionen und einer ansprechenden Nutzererfahrung. Leider liefen die ersten Versionen des Spiels auf eingeschränkten Geräten wie Feature Phones nicht gut. Das Team erkannte, dass der Hauptthread ein Engpass war.

Das Team entschied sich, Webworker zu verwenden, um den visuellen Status des Spiels von der Logik zu trennen:

  • Der Hauptthread übernimmt das Rendern von Animationen und Übergängen.
  • Ein Webworker kümmert sich um die Spielelogik, die rein rechnerisch ist.

OMT hatte interessante Auswirkungen auf die Leistung von PROXX auf Feature Phones. In der Version ohne OMT ist die Benutzeroberfläche nach der Nutzerinteraktion sechs Sekunden lang eingefroren. Es gibt kein Feedback und der Nutzer muss die vollen sechs Sekunden warten, bevor er etwas anderes tun kann.

Antwortzeit der Benutzeroberfläche in der nicht OMT-Version von PROXX.

In der OMT-Version dauert es jedoch zwölf Sekunden, bis die Benutzeroberfläche aktualisiert ist. Das mag zwar wie ein Leistungsverlust erscheinen, führt aber tatsächlich zu mehr Feedback für den Nutzer. Die Verzögerung tritt auf, weil die App mehr Frames sendet als die Version ohne OMT, die gar keine Frames sendet. Der Nutzer weiß also, dass etwas passiert, und kann weiterspielen, während die Benutzeroberfläche aktualisiert wird. Das Spiel wirkt dadurch wesentlich besser.

Antwortzeit der Benutzeroberfläche in der OMT-Version von PROXX.

Das ist eine bewusste Abwägung: Wir bieten Nutzern mit eingeschränkten Geräten eine bessere Erfahrung, ohne Nutzer von High-End-Geräten zu benachteiligen.

Auswirkungen einer OMT-Architektur

Wie das Beispiel PROXX zeigt, sorgt OMT dafür, dass Ihre App auf einer größeren Anzahl von Geräten zuverlässig ausgeführt wird, sie wird dadurch aber nicht schneller:

  • Sie verlagern die Arbeit nur aus dem Haupt-Thread, reduzieren sie aber nicht.
  • Der zusätzliche Kommunikationsaufwand zwischen dem Webworker und dem Hauptthread kann die Ausführung manchmal geringfügig verlangsamen.

Vor- und Nachteile berücksichtigen

Da der Hauptthread während der Ausführung von JavaScript kostenlos ist, Nutzerinteraktionen wie Scrollen zu verarbeiten, kommt es zu weniger Frame-Ausfällen, auch wenn die Gesamtwartezeit möglicherweise etwas länger ist. Es ist besser, den Nutzer etwas warten zu lassen, als einen Frame zu verwerfen, da die Fehlertoleranz bei verworfenen Frames geringer ist: Das Verwerfen eines Frames erfolgt in Millisekunden, während Sie hunderte Millisekunden Zeit haben, bevor ein Nutzer eine Wartezeit wahrnimmt.

Aufgrund der unvorhersehbaren Leistung auf verschiedenen Geräten besteht das Ziel der OMT-Architektur darin, Risiken zu reduzieren, also Ihre App robuster gegenüber stark variierenden Laufzeitbedingungen zu machen. Es geht nicht um die Leistungsvorteile der Parallelisierung. Die erhöhte Ausfallsicherheit und die Verbesserungen der UX sind jeden kleinen Geschwindigkeitsverlust wert.

Hinweis zu Tools

Webworker sind noch nicht weit verbreitet, daher werden sie von den meisten Modultools wie webpack und Rollup nicht standardmäßig unterstützt. Parcel tut das aber. Zum Glück gibt es Plug-ins, mit denen Webworker mit Webpack und Rollup funktionieren:

Zusammenfassung

Damit unsere Apps möglichst zuverlässig und barrierefrei sind, müssen wir auch Geräte mit eingeschränkten Ressourcen unterstützen. Auf diesen Geräten greifen die meisten Nutzer weltweit auf das Web zu. 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.

Außerdem hat die OMT sekundäre Vorteile:

  • Die Kosten für die JavaScript-Ausführung werden in einen separaten Thread verschoben.
  • Dadurch werden die Parsekosten verlagert, was bedeutet, dass die Benutzeroberfläche möglicherweise schneller gestartet wird. Dadurch kann der Wert für First Contentful Paint oder sogar für Time to Interactive sinken, was wiederum den Lighthouse-Wert verbessern kann.

Webworker müssen nicht beängstigend sein. Tools wie Comlink entlasten die Mitarbeiter und machen sie für eine breite Palette von Webanwendungen zu einer praktikablen Lösung.