Startseite / Artikel

Die Seite responsiv halten: Wann sollte Rechenarbeit der CPU in einen Web Worker verlagert werden?

Erfahren Sie, warum schwere Schleifen die Benutzeroberfläche des Browsers einfrieren, wie das Message-Pattern der Web Workers diese Arbeit abladen und wie Sie entscheiden können, ob eine Aufgabe überhaupt einen Worker benötigt.

Verlag: REACTAPP.TOP12 Min. Lesezeit2703 Wörter

Quellen: JavaScript Workers: How to Run Code Without Freezing Your UI

Ein Benutzer drückt eine Schaltfläche, der Beschriftung wird „Verarbeitung…“ angezeigt, danach reagiert nichts mehr: Animationen stocken, das Scrollen funktioniert nicht, weitere Klicks werden ignoriert. Die Anwendung ist nicht abgestürzt; ein JavaScript-Teil monopolisiert einfach den Hauptthread. Diese Anleitung erklärt, warum das passiert, zeigt das grundlegende Web Worker-Muster zur Ausführung aufwendiger Berechnungen an einem anderen Ort und gibt Ihnen eine praktische Methode, um zu entscheiden, wann ein Worker die richtige Lösung ist und wann er nur zusätzliche Komplexität mit sich bringt.

Warum eine einzige lange Aufgabe alles einfriert

Der Browser insgesamt leistet weitaus mehr als nur die Ausführung Ihrer Skripte, doch der JavaScript-Code der Seite läuft auf einem Hauptthread. Wenn Sie ein umfassenderes Bild davon erhalten möchten, wie dieser Thread in das mehrprozessige Design des Browsers passt, lesen Sie JavaScripts einziger Thread im Vergleich zum mehrprozessigen Engine des Browsers. Die praktische Folge ist einfach: Dieser Thread kann zu einem Engpass werden.

Nehmen wir eine Funktion, die alle ganzen Zahlen unter einer Milliarde summiert. Damit ist alles in Ordnung, und der Engine wird sie gerne bis zum Ende ausführen:

function calculate() {
  let total = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
      total += i;
    }
    return total;
  }
calculate();

Das Problem liegt darin, was in der Zwischenzeit passiert. Solange die Schleife läuft, kann der Hauptthread nichts anderes bearbeiten, was von ihm abhängt. Klick-Handler, Eingabeverarbeitung sowie der größte Teil der Arbeit zur Aktualisierung des Bildschirmsinhalts warten alle in der Reihe auf denselben Thread. Genau diese Warteschlange ist es, die der Benutzer als eingefrorenes Interface wahrnimmt.

Die Abfolge der Ereignisse sieht so aus:

User clicks button
       ↓
JavaScript starts heavy calculation
       ↓
Main thread stays busy
       ↓
UI work has to wait
       ↓
Page feels frozen

Hier ist nichts kaputt. Ihr Code hält den Thread zu lange auf, und der Browser verhält sich fair, indem er ihm die Zeit gibt, fertig zu werden. Deshalb kann Code, der in der Entwicklung völlig in Ordnung schien, in der Produktion zu einem echten Leistungsproblem werden: Die Funktion selbst hat sich nicht geändert, doch das Datenset oder die Menge an Berechnungen, die sie bewältigen muss, ist gewachsen.

Einen rechenintensiven Auftrag in einen eigenen Ausführungskontext stellen

Ein Web Worker ist ein separater JavaScript-Exekutionskontext, der unabhängig vom Hauptscript der Seite läuft. Anstatt alles auf einen Thread zu stapeln:

MAIN THREAD
     │
     ├── UI
     ├── Events
     ├── JavaScript
     └── Heavy calculation  ← problem

teilt man die Verantwortlichkeiten auf, sodass die Seite die UI, Eingaben und die Seitenlogik beibehält, während der Worker die aufwändigen Berechnungen übernimmt:

MAIN THREAD                  WORKER
     │                         │
     ├── UI                    ├── Heavy calculation
     ├── User input            ├── Data processing
     └── Page logic            └── Result
             │                  │
             └── messages ─────┘

Die Linie, die die beiden Seiten in diesem Diagramm verbindet, ist der entscheidende Punkt. Ein Worker teilt weder die Variablen noch Objekte Ihrer Seite. Die beiden Seiten tauschen Nachrichten aus, und diese Einschränkung bestimmt alles Weitere bei der Arbeit mit Workers.

Der erste Worker, Schritt für Schritt

Hier ist die Summenlaufschleife aus dem Vorherigen, die vom Hauptthread entfernt wurde. Das Seitenscript erstellt den Worker aus einer separaten Datei, sendet ihm die Obergrenze und registriert einen Handler für die Antwort:

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

worker.postMessage(1_000_000_000);

worker.onmessage = (event) => {
  console.log("Result:", event.data);
};

Die Worker-Datei lauscht auf eingehende Nachrichten, führt die Schleife mit der ihr übergebenen Zahl aus und sendet das Ergebnis zurück:

self.onmessage = (event) => {
  let total = 0;
  for (let i = 0; i < event.data; i++) {
    total += i;
  }
  self.postMessage(total);
};

Es hilft, die Hin- und Rückreise Schritt für Schritt nachzuvollziehen. Die Seite übermittelt dem Worker ihre Eingabe mithilfe von postMessage:

worker.postMessage(...)

Innen im Worker bezieht sich self auf den globalen Scope des Workers, und dort befindet sich auch der onmessage-Handler, in dem die Eingabe ankommt:

self.onmessage = (...)

Sobald die Berechnung abgeschlossen ist, antwortet der Worker über seinen eigenen postMessage:

self.postMessage(...)

Schließlich erhält der Handler der Seite am Worker-Objekt diese Antwort über event.data:

worker.onmessage = (...)

Beachten Sie, dass es kein Problem ist, onmessage nach Aufruf von postMessage im Seiten-Script zuzuweisen: Die Antwort kann nur asynchron ankommen, nachdem das aktuelle Script abgeschlossen wurde, sodass der Handler stets rechtzeitig vorhanden ist.

Was ändert sich und was nicht

Eine nützliche Art, dies zu verstehen, ist die Delegation. Der Hauptthread sagt im Grunde: „Hier sind die Daten, kümmern Sie sich darum“, der Worker erledigt die Aufgabe und meldet sich zurück, sobald sie abgeschlossen ist. Ohne Worker verläuft der Ablauf streng sequenziell und die Benutzeroberfläche wartet:

Main thread
     ↓
Heavy calculation
     ↓
UI waits

Mit einem Worker überträgt der Hauptthread die Aufgabe und setzt die Bereitstellung der Seite fort, während die Berechnung parallel stattfindet:

Main thread ──→ Worker
     │             │
     │             └── Heavy calculation
     │
     └── continues handling the page

Seien Sie klar darüber, was Sie gewinnen. Das Verschieben von Aufgaben in einen Worker führt in der Regel nicht dazu, dass diese Arbeit schneller abgeschlossen wird. Die Schleife dauert in etwa genauso lange wie zuvor, und die Nachrichtenübertragung hinzukommt noch etwas Zeit. Der Vorteil besteht darin, dass die Berechnung nicht mehr um Zeit mit dem Hauptthread konkurriert, sodass die Seite während ihrer Ausführung weiterhin interaktiv bleibt.

Kommunikation mit einem Worker: Nachrichten, nicht das DOM

Der größte Denkwechsel besteht darin, dass ein Worker nicht auf die Seite zugreifen kann. Im globalen Scope eines Workers gibt es kein document, weshalb ein solcher Aufruf dort fehlschlagen wird:

document.querySelector("#result");

Alles, was die Grenze überschreitet, erfolgt über Nachrichten. Sie senden Daten mit:

worker.postMessage(data);

und Sie hören auf das, was zurückkommt, mit:

worker.onmessage = (event) => {
  console.log(event.data);
};

Diese Einschränkung erweist sich als Vorteil. Da der Worker nur Eingaben erhalten und Ausgaben senden kann, ist der Datenfluss zwischen den beiden Seiten stets explizit:

Main Thread
     │
     │ postMessage()
     ▼
  Worker
     │
     │ postMessage()
     ▼
Main Thread

Für die meisten Anwendungen ist eine enge, auf Nachrichten basierende Trennung zwischen „dem Teil, der darstellt“ und „dem Teil, der berechnet“ genau das Design, das man ohnehin haben möchte. Der Worker berührt niemals den DOM, und die Seite entscheidet, was mit jedem Ergebnis gemacht werden soll.

Refactoring eines blockierenden Klick-Handlers

Der typische Ausgangspunkt ist ein Klick-Handler, der eine große Datensammlung synchron verarbeitet und die Ergebnisse sofort darstellt:

button.addEventListener("click", () => {
  const result = processHugeDataset(data);
  renderResult(result);
});

Falls processHugeDataset() eine beträchtliche Zeit lang läuft, ist der Hauptthread die ganze Zeit damit beschäftigt, und die Seite reagiert nicht mehr, bis die Funktion zurückkehrt.

Die auf Workers basierende Version teilt den Handler in zwei Teile auf. Der Klick sendet lediglich die Daten; die Darstellung erfolgt später, sobald die Ergebnisnachricht ankommt:

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

button.addEventListener("click", () => {
  worker.postMessage(data);
});

worker.onmessage = (event) => {
  renderResult(event.data);
};

Die aufwändige Verarbeitung findet nun innerhalb des Workers statt, wodurch der Hauptthread frei bleibt, weiterhin die Seite zu verarbeiten, während er wartet. Bei dieser Struktur gibt es zwei Dinge, die man berücksichtigen sollte. Erstens wird der Worker nur einmal, außerhalb des Handlers, erstellt, sodass wiederholte Klicks ihn wiederverwenden und nicht jedes Mal einen neuen erstellen. Zweitens können bei schnellen mehrfachen Klicks die Ergebnisse erst nachdem der Benutzer weitergegangen ist eintreffen; in echtem Code markiert man in der Regel jede Nachricht mit einer ID oder ignoriert veraltete Antworten, damit ein altes Ergebnis kein neueres überschreibt.

Entscheiden, was in einen Worker gehört

Es ist leicht, Begeisterung zu entwickeln und alle Funktionen in einen Worker zu verlagern. Das ist selten eine gute Idee. Ein Worker findet seine Berechtigung dann, wenn die Aufgabe so rechenintensiv ist, dass sie um Zeit mit dem Hauptthread konkurrieren muss. Typische Beispiele sind:

  • Verarbeitung oder Aggregation großer Datensätze
  • Komplexe numerische oder algorithmische Berechnungen
  • Große JSON-Transformationen
  • Parsing großer Dateien
  • Bildbearbeitung
  • Audio- oder Videobearbeitung
  • Komprimierung und andere Datentransformationen
  • Spezifische kryptografische oder mathematische Verfahren
  • Syntaxhervorhebung und andere aufwändige Textverarbeitungen

Das wiederkehrende Muster ist immer dasselbe: viele Rechenoperationen und kaum Bedarf, direkt auf den DOM zuzugreifen.

Wann ein Worker überflüssig ist

Für etwas So Einfaches Wie Die Multiplikation Zwei Zahlen Ist Ein Worker Reine Overhead-Kosten:

const total = price * quantity;

Das Starten Eines Workers Hat Kosten, Jede Nachricht Bringt Kommunikationsoverhead Mit Sich, Und Man Besitzt Jetzt Noch Eine Datei Mit Ihrem Eigenen Lebenszyklus. Für Kleine Operationen Ist Einfaches JavaScript Auf Der Hauptthread Die Bessere Wahl. Statt Zu Überlegen, Ob Man Einen Worker Können Verwenden, Sollte Man Prüfen, Ob Die Arbeit So Aufwendig Ist, Dass Ihr Verbleib Auf Dem Hauptthread Tatsächlich Die Benutzererfahrung Beeinträchtigt. Diese Frage Filtert Die Mehrheit Der Fälle Aus, In Denen Ein Worker Nur Komplexität Hinzufügen Würde.

Der architektonische Nebeneffekt: Eine klare Trennung

Workers Sind Nicht Nur Ein Leistungswerkzeug. Sie Setzen Auch Eine Trennung Der Aufgaben Durch. Ihr UI-Code Kann Sich Auf Die Umwandlung Von Eingaben In Zustände Und Von Zuständen In Anzeigen Konzentrieren:

Input
↓
State
↓
Display

Während sich der Worker darauf konzentriert, Eingabedaten in Ausgabedaten umzuwandeln:

Input data
↓
Processing
↓
Output data

Durch die Isolierung aufwändiger Verarbeitungsschritte wird es einfacher, sie zu testen und zu verstehen, da der Worker wie eine reine Funktion hinter einer Nachrichtenschnittstelle agiert.

Doch es gibt einen Kompromiss: Man muss nun den asynchronen Nachrichtenfluss verwalten, den Start und Stopp der Workers, wie Fehler auftreten und wie Daten über die Grenzen hinweg übertragen werden. Das ist ein angemessener Preis, wenn die Arbeit aufwendig ist. Gute Ingenieurskunst besteht nicht darin, überall Komplexität zu beseitigen, sondern sie dort einzusetzen, wo sie sich lohnt.

Dedizierte, geteilte und Service Workers sind unterschiedliche Werkzeuge

„Web Worker“ wird oft so verwendet, als bezeichne er eine einzige API, doch es existieren mehrere workerbezogene APIs, die unterschiedliche Zwecke erfüllen:

  • Dedicated Worker:führt JavaScript im Auftrag einer bestimmten Seite oder eines Kontexts aus. Dies ist die Art, die in diesem Leitfaden verwendet wird.
  • Shared Worker:ein einziger Worker, mit dem mehrere Browserkontexte (zum Beispiel mehrere Tabellen derselben Herkunft) verbunden werden können und miteinander kommunizieren.
  • Service Worker:steht zwischen der Seite und dem Netzwerk, um Funktionen wie Anfragen abfangen, Caching sowie Offline-Unterstützung zu ermöglichen.

Für die Abwicklung rechenintensiver Aufgaben sollte man sich zunächst mit dem Dedicated Worker vertraut machen. Ein Service Worker ist kein direkter Ersatz dafür; beide lösen unterschiedliche Probleme. Für einen ausführlicheren Vergleich der drei Arten siehe Dedicated, Shared und Service Workers: die richtige Browser-Thread-Wahl.

Wichtige Details in der Produktion

Das minimale Beispiel ist absichtlich klein. Reale Anwendungen erfordern mehr Sorgfalt bei Fehlern, der Bereinigung und der Datengröße.

Auf Fehler achten

Eine nicht gefangene Ausnahme innerhalb des Workers tritt auf der Seite als error-Ereignis zutage. Fügen Sie einen Handler hinzu, damit Fehler protokolliert werden und die Benutzeroberfläche nicht stumm endlos auf ein Ergebnis wartet:

worker.onerror = (error) => {
  console.error("Worker error:", error);
};

Arbeiter beenden, die Sie nicht mehr benötigen

Ein Worker läuft weiter, bis er gestoppt wird. Wenn ein Worker nur für eine einmalige Aufgabe existiert, schalten Sie ihn von der Seite aus ab, sobald die Arbeit erledigt ist:

worker.terminate();

Das ist besonders wichtig für kurzlebige Arbeiter; ein lang lebender Worker, der die gesamte Anwendung bedient, kann einfach weiterhin aktiv bleiben.

Datentransfer ist nicht kostenlos

Jede Nachricht beinhaltet den Datentransfer zwischen Kontexten. Für die meisten JavaScript-Werte verwendet der Browser den strukturierten Klon-Algorithmus, der auf der Empfängerseite eine Kopie erstellt. Bei einigen großen binären Datenmengen, wie z. B. ArrayBuffer-Instanzen, können diese als Übertragbare Objekte übergeben werden, wodurch das Eigentum an die andere Seite übergeht anstelle einer Kopie; der Sender kann den übertragenen Buffer danach nicht mehr verwenden. Sie benötigen nicht sofort alle Details dazu, aber behalten Sie folgende Regel im Hinterkopf: Das Entfernen von Rechenarbeiten vom Hauptthread macht den Datentransfer dorthin nicht kostenlos. Das Klonen eines sehr großen Objekts kann selbst erhebliche Zeit auf dem Hauptthread in Anspruch nehmen.

Workers und React

React ändert nicht, wie der Browser JavaScript ausführt. Wenn ein React-Event-Handler vor dem Aktualisieren des Zustands eine aufwändige synchrone Berechnung durchführt, wird diese Berechnung weiterhin im Hauptausführungskontext der Seite ausgeführt – genauso wie jede andere Schriftstelle:

function handleClick() {
  const result = expensiveCalculation();
  setResult(result);
}

Kein Framework kann JavaScript mit hohem CPU-Aufwand verschwinden lassen. Was Sie tun können, ist, einen Worker als Rechenschicht hinter Ihren Komponenten zu verwenden: Die Benutzeroberfläche sendet Daten heraus, der Worker verarbeitet sie, und das Ergebnis kehrt zurück, um im Zustand gespeichert und angezeigt zu werden:

React UI
   │
   │ send data
   ▼
Web Worker
   │
   │ process data
   ▼
React UI

Dies bringt besonders in Schnittstellen Vorteile, in denen Benutzer mit großen Datensätzen, Editoren, Visualisierungen oder anderen rechenintensiven Ansichten arbeiten. Das Framework ist nicht das Wichtigste; entscheidend ist es, zu wissen welcher Thread die aufwändige Arbeit ausführt. In einer Komponente bedeutet das in der Regel, den Worker in einem Effect zu erstellen, setResult über seinen Nachrichtenhandler aufzurufen und ihn im Cleanup des Effects zu beenden, damit deinstallierte Komponenten keine laufenden Workers zurücklassen.

Worker sind kein allgemeiner Geschwindigkeitsvorteil

Der häufigste Fehler besteht darin, Workers als universelle Lösung für Leistungsprobleme zu verwenden. Wenn eine Anwendung aufgrund unnötiger Neuzeichnungen, zu vieler Netzwerkanfragen, zu großer Dateien, ineffizienter React-Updates oder verwickelten Codes langsam ist, wird ein Worker dieses Problem nicht beheben. Erst sollte der eigentliche Engpass ermittelt werden – in der Regel mithilfe des Leistungsprofilers des Browsers – und erst danach entschieden werden, ob Workers die richtige Lösung sind.

Eine kurze Checkliste hilft dabei:

  1. Ist die Aufgabe tatsächlich rechenintensiv?
  2. Läuft sie lange genug, um die Reaktionsfähigkeit zu beeinträchtigen?
  3. Braucht sie direkten Zugriff auf das DOM?
  4. Können Eingaben und Ausgaben als Nachrichten übertragen werden?
  5. Lohnt sich die zusätzliche Komplexität?

Falls die Antworten in diese Richtung deuten (rechenintensiv, lange Laufzeit, kein DOM-Zugriff, geeignet für Nachrichtenübertragung, lohnenswert), eignen sich Workers wahrscheinlich gut.

Da hilft es auch, zwei Probleme voneinander zu trennen, die leicht verwechselt werden können. Im ersten Fall erledigt JavaScript mehr Arbeit, als nötig ist, und die Lösung besteht darin, einen besseren Algorithmus zu verwenden oder insgesamt weniger Arbeit zu leisten. Im zweiten Fall ist die Ausführung der Aufgabe tatsächlich aufwendig, hat aber keinen Grund, die Benutzeroberfläche zu blockieren; genau da kommt ein Worker zum Tragen. Ein Worker verwandelt ineffizienten Code nicht in effizienten Code – er gibt kostspieligem Code einfach einen anderen Ort zur Ausführung.

Kernpunkte

Ihr müsst sich nicht das gesamte Worker-API auswendig merken. Das wichtige Modell lässt sich in einem Bild zusammenfassen:

MAIN THREAD
     │
     │ "Here is the work."
     ▼
  WORKER
     │
     │ "Here is the result."
     ▼
MAIN THREAD
  • Der Hauptthread besitzt die Seite; jede lange, synchrone Aufgabe darauf verzögert die Verarbeitung von Eingaben und die Aktualisierung des Bildschirms.
  • Ein Dedicated Worker führt isolierten JavaScript-Code aus und kommuniziert nur über postMessage sowie onmessage; er hat keinen Zugriff auf das DOM.
  • Durch das Auslagern bleibt die Benutzeroberfläche reaktiv, beschleunigt aber in der Regel nicht die eigentliche Berechnung, und das Übertragen großer Daten hat weiterhin Kosten.
  • Behandeln Sie onerror, rufen Sie bei temporären Workers terminate() auf und betrachten Sie Transferable-Objekte für große binäre Datenmengen.
  • Analysieren Sie zunächst das Verhalten. Optimieren Sie den Algorithmus, wenn die Ausführung überflüssig ist; wenden Sie einen Worker an, wenn die Arbeit notwendig ist, aber nicht auf dem Hauptthread ausgeführt werden sollte.
  • Nächstes Mal, wenn eine Seite während einer aufwendigen Berechnung blockiert, fragen Sie sich nicht nur, wie die Funktion schneller gemacht werden kann, sondern auch, ob sie überhaupt auf dem Hauptthread ausgeführt werden muss.