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 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 noch zunehmen, wenn die Nutzer von immer vielfältigeren Geräten aus auf das Internet zugreifen – von stark eingeschränkten Feature-Phones bis hin zu leistungsstarken Flagship-Maschinen mit hoher Aktualisierungsrate.
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 Single-Threaded-Sprache, die Aufgaben im Hauptthread ausführt. 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 durch die Auslagerung der Arbeit vom Hauptthread an Web Worker kann die Konflikte mit dem Hauptthread reduziert werden, wodurch sich die Reaktionsfähigkeit des Messwerts 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 ü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 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 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);
});
Dieser Ansatz ist zwar etwas eingeschränkt. In der Vergangenheit wurden Web Worker hauptsächlich dazu verwendet, eine einzelne schwere Arbeit aus dem Hauptthread zu verschieben. Der Umgang mit mehreren Vorgängen mit einem einzigen Webworker wird schnell unübersichtlich: Sie müssen nicht nur die Parameter, sondern auch den Vorgang in der Nachricht codieren und Buchhaltung betreiben, um Antworten mit Anfragen abzugleichen. Diese Komplexität ist wahrscheinlich der Grund dafür, dass Webworker nicht weiter 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. Glücklicherweise gibt es eine Bibliothek, die genau das tut.
Comlink: Weniger Arbeitsaufwand für Web-Worker
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 ihn in einen Web Worker importieren und eine Reihe von Funktionen definieren, die für den Hauptthread verfügbar gemacht 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 Offlinebetrieb und ansprechender Nutzerfreundlichkeit. 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 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 ist die Benutzeroberfläche nach der Nutzerinteraktion sechs Sekunden lang eingefroren. Es gibt kein Feedback und der Nutzer muss sechs Sekunden warten, bevor er etwas anderes tun kann.
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ß daher, dass etwas passiert, und kann weiterspielen, während die Benutzeroberfläche aktualisiert wird, sodass sich das Spiel erheblich besser anfühlt.
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 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 dem Web Worker und dem Hauptthread kann die Ausführung manchmal etwas 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 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 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:
- worker-plugin für webpack
- rollup-plugin-off-main-thread für Rollup
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 steigern 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.