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

Eine Architektur, die nicht auf dem Hauptthread basiert, kann die Zuverlässigkeit und Nutzerfreundlichkeit Ihrer App erheblich verbessern.

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

Infolgedessen ist der Hauptthread extrem überlastet. Da Webanwendungen immer komplexer werden, 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 die Gerätefunktionen einen enormen Einfluss auf die Leistung haben. Diese Unvorhersehbarkeit wird nur noch zunehmen, da Nutzer über immer vielfältigere Geräte auf das Web zugreifen, von stark eingeschränkten Feature-Phones bis hin zu leistungsstarken Flaggschiff-Geräten mit hoher Aktualisierungsrate.

Wenn wir möchten, dass komplexe Web-Apps die Leistungsrichtlinien wie die Core Web Vitals zuverlässig erfüllen, 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 Web-Worker?

JavaScript ist standardmäßig eine Single-Thread-Sprache, die Aufgaben im Hauptthread ausführt. Web-Worker bieten jedoch eine Art Ausweg aus dem Hauptthread, da Entwickler separate Threads erstellen können, um Aufgaben außerhalb des Hauptthreads zu erledigen. Der Umfang von Web Workern ist zwar begrenzt und sie bieten keinen direkten Zugriff auf das DOM, aber sie können sehr nützlich sein, wenn umfangreiche Aufgaben anfallen, die den Hauptthread sonst überlasten würden.

In Bezug auf die Core Web Vitals kann es von Vorteil sein, Aufgaben außerhalb des Hauptthreads auszuführen. Insbesondere kann die Auslagerung von Arbeit vom Hauptthread auf Web-Worker die Konkurrenz um den Hauptthread verringern, was den Messwert für die Reaktionsfähigkeit einer Seite, Interaction to Next Paint (INP), verbessern kann. Wenn der Hauptthread weniger Arbeit zu verarbeiten hat, kann er schneller auf Nutzerinteraktionen reagieren.

Weniger Arbeit im Hauptthread, insbesondere beim Start, kann auch den Largest Contentful Paint (LCP) verbessern, da lange Aufgaben reduziert werden. Das Rendern eines LCP-Elements erfordert Zeit im Hauptthread – entweder für das Rendern von Text oder Bildern, die häufige und gängige LCP-Elemente sind. Wenn Sie die Arbeit im Hauptthread insgesamt reduzieren, können Sie dafür sorgen, dass das LCP-Element Ihrer Seite weniger wahrscheinlich durch aufwendige Arbeit blockiert wird, die stattdessen von einem Web-Worker erledigt werden könnte.

Threading mit Web Workern

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

In JavaScript können wir eine ähnliche Funktionalität mit Web Workern erreichen, die es seit 2007 gibt und die seit 2012 von allen gängigen Browsern unterstützt werden. Web-Worker werden parallel zum Hauptthread ausgeführt, können aber im Gegensatz zu Betriebssystem-Threads keine Variablen gemeinsam nutzen.

Wenn Sie einen Web-Worker erstellen möchten, übergeben Sie eine Datei an den Worker-Konstruktor, der diese 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 Nachrichten-Event-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 eine einzelne rechenintensive Aufgabe vom Hauptthread zu verlagern. Der Versuch, mehrere Vorgänge mit einem einzigen Web-Worker zu verarbeiten, 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 Web-Worker nicht häufiger eingesetzt werden.

Wenn wir jedoch die Kommunikation zwischen dem Hauptthread und den Web Workern vereinfachen könnten, wäre dieses Modell für viele Anwendungsfälle sehr gut geeignet. Und zum Glück gibt es eine Bibliothek, die genau das tut.

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

Sie richten Comlink ein, indem Sie es in einen Web-Worker importieren und eine Reihe von Funktionen definieren, die für den Hauptthread verfügbar gemacht werden sollen. Anschließend importieren Sie Comlink im Hauptthread, binden den Worker ein und erhalten Zugriff auf die bereitgestellten 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 Web-Worker, mit dem Unterschied, dass jede Funktion ein Promise für einen Wert zurückgibt, nicht den Wert selbst.

Welchen Code sollten Sie in einen Web-Worker verschieben?

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

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

Wenn wir jedoch zu einem Modell wechseln, bei dem UI-Belange von anderen Belangen wie der Zustandsverwaltung getrennt sind, können Web-Worker auch bei frameworkbasierten Apps sehr nützlich sein. Genau das ist der Ansatz, der bei PROXX verfolgt wird.

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 der Offline-Funktion und einer ansprechenden Nutzererfahrung. Leider liefen frühe Versionen des Spiels auf Geräten mit eingeschränkter Leistung wie Feature-Phones schlecht. Das Team erkannte, dass der Hauptthread ein Engpass war.

Das Team entschied sich, Web Worker zu verwenden, um den visuellen Zustand des Spiels von der Logik zu trennen:

  • Der Hauptthread übernimmt das Rendern von Animationen und Übergängen.
  • Ein Web-Worker verarbeitet die Spiellogik, die rein rechnerisch ist.

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

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

In der OMT-Version dauert es jedoch zwölf Sekunden, bis die Benutzeroberfläche aktualisiert wird. Das mag wie ein Leistungsverlust klingen, führt aber tatsächlich zu mehr Feedback für den Nutzer. Die Verlangsamung tritt auf, weil die App mehr Frames sendet als die Nicht-OMT-Version, die überhaupt keine Frames sendet. Der Nutzer weiß also, dass etwas passiert, und kann weiterspielen, während die Benutzeroberfläche aktualisiert wird. Das Spiel fühlt sich dadurch deutlich besser an.

UI-Antwortzeit in der OMT-Version von PROXX.

Das ist ein bewusster Kompromiss: Wir bieten Nutzern von Geräten mit eingeschränkter Leistung eine Erfahrung, die sich besser anfühlt, ohne Nutzer von High-End-Geräten zu benachteiligen.

Auswirkungen einer OMT-Architektur

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

  • Sie verlagern die Arbeit nur vom Hauptthread, reduzieren sie aber nicht.
  • Der zusätzliche Kommunikationsaufwand zwischen dem Web Worker und dem Hauptthread kann die Dinge manchmal etwas verlangsamen.

Mögliche Nachteile

Da der Hauptthread während der Ausführung von JavaScript Nutzerinteraktionen wie Scrollen verarbeiten kann, werden weniger Frames ausgelassen, auch wenn die Gesamtwartezeit geringfügig länger sein kann. Es ist besser, den Nutzer etwas warten zu lassen, als einen Frame zu überspringen, da die Fehlermarge bei übersprungenen Frames kleiner ist: Das Überspringen eines Frames erfolgt in Millisekunden, während es Hunderte von Millisekunden dauert, bis ein Nutzer die Wartezeit wahrnimmt.

Da die Leistung auf verschiedenen Geräten unvorhersehbar ist, geht es bei der OMT-Architektur vor allem darum, das Risiko zu reduzieren und Ihre App robuster gegenüber stark variierenden Laufzeitbedingungen zu machen, und nicht um die Leistungsvorteile der Parallelisierung. Die höhere Ausfallsicherheit und die Verbesserungen der UX sind die geringen Einbußen bei der Geschwindigkeit mehr als wert.

Hinweis zu Tools

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

Zusammenfassung

Damit unsere Apps so zuverlässig und zugänglich wie möglich sind, insbesondere in einem zunehmend globalisierten Markt, müssen wir Geräte mit eingeschränkter Leistung unterstützen. Über diese Geräte 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.

OMT hat auch sekundäre Vorteile:

  • Die Kosten für die JavaScript-Ausführung werden in einen separaten Thread verschoben.
  • Die Kosten für das Parsen werden verschoben, was bedeutet, dass die Benutzeroberfläche möglicherweise schneller startet. Dadurch können sich der First Contentful Paint oder sogar die Time to Interactive verringern, was wiederum Ihren Lighthouse-Wert erhöhen kann.

Web-Worker müssen nicht beängstigend sein. Tools wie Comlink nehmen Ihnen die Arbeit mit Web Workern ab und machen sie zu einer praktikablen Option für eine Vielzahl von Webanwendungen.