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 App erheblich verbessern.

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

Infolgedessen wurde der Hauptthread unglaublich überarbeitet. Und da die Komplexität von Webanwendungen zunimmt, wird der Hauptthread zu einem erheblichen Leistungsengpass. Außerdem ist die Zeit, die zum Ausführen von Code im Hauptthread für einen bestimmten Nutzer benötigt wird, fast unvorhersehbar, da die Gerätefunktionen einen enormen Einfluss auf die Leistung haben. Diese Unvorhersehbarkeit wird noch zunehmen, wenn Nutzer von einer immer vielfältigeren Reihe von Geräten aus auf das Internet zugreifen – von stark eingeschränkten Feature-Phones bis hin zu leistungsstarken Flagship-Maschinen mit hoher Aktualisierungsrate.

Wenn wir möchten, dass ausgereifte Web-Apps zuverlässig Leistungsrichtlinien wie die Core Web Vitals erfüllen, die auf empirischen Daten zur menschlichen Wahrnehmung und Psychologie basieren, benötigen wir Möglichkeiten zur Ausführung unseres Codes außerhalb des Hauptthreads (OMT).

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, indem sie es Entwicklern ermöglichen, separate Threads zu erstellen, um Aufgaben außerhalb des Hauptthreads zu erledigen. Auch wenn der Umfang von Web Workern begrenzt ist und keinen direkten Zugriff auf das DOM bietet, können sie enorm hilfreich sein, wenn erhebliche Arbeit zu leisten ist, die andernfalls den Hauptthread überfordern würde.

Bei Core Web Vitals kann es von Vorteil sein, Aufgaben über den Hauptthread hinaus auszuführen. Insbesondere durch die Übertragung der Arbeit vom Hauptthread an Web Worker kann die Konflikte mit dem Hauptthread reduziert werden, wodurch sich der Messwert für die Reaktionsfähigkeit einer Seite Interaction to Next Paint (INP) verbessern kann. Wenn der Hauptthread weniger zu verarbeiten ist, kann er schneller auf Nutzerinteraktionen reagieren.

Weniger Arbeiten am Hauptthread – insbesondere während des Starts – bietet auch einen potenziellen Vorteil für Largest Contentful Paint (LCP), da lange Aufgaben reduziert werden. Das Rendern eines LCP-Elements erfordert Zeit für den Hauptthread – entweder zum Rendern von Text oder Bildern, die häufige und gängige LCP-Elemente sind. Durch die Reduzierung des Hauptthreads insgesamt wird die Wahrscheinlichkeit verringert, dass das LCP-Element Ihrer Seite durch kostspielige Aufgaben blockiert wird, die ein Web Worker stattdessen verarbeiten könnte.

Threading mit Web Workern

Andere Plattformen unterstützen in der Regel parallele Arbeiten, da Sie einem Thread eine Funktion zuweisen können, die parallel zum Rest Ihres Programms ausgeführt wird. Sie können über beide 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 in etwa ähnliche Funktionen von Web Workern, die es seit 2007 gibt und seit 2012 von allen wichtigen Browsern unterstützt wird. Web Worker werden parallel zum Hauptthread ausgeführt, aber im Gegensatz zum Betriebssystem-Threading können sie keine Variablen gemeinsam nutzen.

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

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

Senden Sie Nachrichten über die postMessage API, um mit dem Web Worker zu kommunizieren. Ü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 zurück an den Hauptthread zurücksenden möchten, verwenden Sie dieselbe postMessage API im Web Worker und richten Sie einen Event-Listener für den 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);
});

Zugegeben, dieser Ansatz ist etwas beschränkt. In der Vergangenheit wurden Web Worker hauptsächlich dazu verwendet, eine einzelne schwere Arbeit aus dem Hauptthread zu verschieben. Der Versuch, mehrere Vorgänge mit einem einzelnen Web Worker abzuwickeln, wird schnell unübersichtlich: Sie müssen nicht nur die Parameter, sondern auch den Vorgang in der Nachricht codieren. Außerdem müssen Sie eine Buchführung durchführen, um Antworten auf Anfragen zuzuordnen. Diese Komplexität ist wahrscheinlich der Grund dafür, dass Web Worker immer noch nicht so weit verbreitet sind.

Wenn wir jedoch die Kommunikation zwischen dem Hauptthread und den Web Workern vereinfachen könnten, könnte dieses Modell für viele Anwendungsfälle gut geeignet sein. Zum Glück gibt es eine Bibliothek, in der genau das möglich ist.

Comlink ist eine Bibliothek, die Ihnen die Verwendung von Web Workern ermöglicht, ohne sich mit den Details von postMessage befassen zu müssen. Mit Comlink können Sie Variablen zwischen Web Workern und dem Hauptthread teilen, ähnlich wie andere Programmiersprachen, die Threading unterstützen.

Sie richten Comlink ein, indem Sie ihn 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 in den Hauptthread, verpacken den Worker 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 im Web Worker, mit der Ausnahme, dass jede Funktion ein Promise für einen Wert und nicht den Wert selbst zurückgibt.

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, sodass Sie keine Teile Ihrer App, die auf einen solchen Zugriff angewiesen sind, in einen Worker einbinden können. Dennoch bietet jedes kleine Code-Snippet, das an einen Worker übertragen wird, mehr Spielraum im Hauptthread für Dinge, die da sein müssen, z. B. für das Aktualisieren der Benutzeroberfläche.

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

Wenn wir jedoch zu einem Modell wechseln, bei dem Belange hinsichtlich der Benutzeroberfläche von anderen Belangen getrennt sind, wie z. B. der Statusverwaltung, können Web Worker auch bei auf Frameworks basierenden Anwendungen durchaus nützlich sein. Genau das ist der Ansatz, den wir mit PROXX gewählt haben.

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 schnitten die ersten Versionen des Spiels auf eingeschränkten Geräten wie Feature-Phones schlecht ab. Daher stellte das Team fest, dass der Hauptthread ein Engpass war.

Das Team entschied sich, Web Worker zu verwenden, um den visuellen Zustand des Spiels von seiner 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-Feature-Phones. In der Nicht-OMT-Version wird die UI für sechs Sekunden eingefroren, nachdem der Nutzer damit interagiert hat. Es gibt kein Feedback und der Nutzer muss sechs Sekunden warten, bevor er etwas anderes tun kann.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
. UI-Antwortzeit in der Nicht-OMT-Version von PROXX.

In der OMT-Version dauert das Update der Benutzeroberfläche zwölf Sekunden. Dies wirkt wie ein Leistungsverlust, führt aber tatsächlich zu mehr Feedback an die Nutzenden. Die Verlangsamung tritt auf, weil die App mehr Frames versendet als die Nicht-OMT-Version, die überhaupt keine Frames versendet. Der Nutzer weiß also, dass etwas passiert, und kann weiterspielen, während die Benutzeroberfläche aktualisiert wird, sodass sich das Spiel deutlich besser anfühlt.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
. Reaktionszeit der Benutzeroberfläche in der OMT-Version von PROXX.

Das ist ein bewusster Kompromiss: Wir bieten Nutzern mit eingeschränkten Geräten eine fühlbare Atmosphäre, 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 Ihre App zuverlässig auf einer größeren Anzahl von Geräten ausgeführt wird, macht sie aber nicht schneller:

  • Sie verschieben nur Aufgaben aus dem Hauptthread, verringern aber nicht den Arbeitsaufwand.
  • Der zusätzliche Kommunikationsaufwand zwischen den Web Workern und der Hauptthread verlangsamen können.

Vor- und Nachteile beachten

Da der Hauptthread kostenlos für die Verarbeitung von Nutzerinteraktionen wie dem Scrollen steht, während JavaScript ausgeführt wird, werden weniger Frames verworfen, obwohl die Gesamtwartezeit geringfügig länger sein kann. Es ist besser, den Nutzer etwas warten zu lassen, als einen Frame zu verwerfen, da die Fehlerspanne für ausgelassene Frames kleiner ist: Das Verwerfen eines Frames erfolgt in Millisekunden, während Sie Hunderte von Millisekunden haben, bevor der Nutzer die Wartezeit wahrnimmt.

Aufgrund der Unvorhersehbarkeit der Leistung auf verschiedenen Geräten besteht das Ziel der OMT-Architektur darin, Risiken zu reduzieren und Ihre App angesichts stark variabler Laufzeitbedingungen robuster zu machen – nicht um die Leistungsvorteile der Parallelisierung. Die höhere Resilienz und die verbesserte Nutzererfahrung sind mehr als nur einen kleinen Kompromiss bei der Geschwindigkeit wert.

Hinweis zu Tools

Web Worker sind noch nicht etabliert, daher unterstützen die meisten Modultools wie Webpack und Rollup sie nicht standardmäßig. Parcel hingegen schon. Glücklicherweise gibt es Plug-ins, mit denen Web Worker mit Webpack und Rollup funktionieren können:

Zusammenfassung

Um sicherzustellen, dass unsere Apps vor allem in einem zunehmend globalisierten Markt so zuverlässig und leicht zugänglich wie möglich sind, müssen wir eingeschränkte Geräte unterstützen. So 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 OMT noch weitere Vorteile:

  • Die Kosten für die JavaScript-Ausführung werden in einen separaten Thread verschoben.
  • Dabei werden die Parsing-Kosten verschoben, sodass die Benutzeroberfläche möglicherweise schneller startet. Dadurch könnte First Contentful Paint reduziert werden. oder sogar Time to Interactive, Dies kann wiederum Ihre Lighthouse-Punktzahl.

Web Worker müssen keine Angst haben. Tools wie Comlink erleichtern den Mitarbeitern die Arbeit und machen sie zu einer praktikablen Wahl für eine Vielzahl von Webanwendungen.

Hero-Image von Unsplash von James Peacock