Threading im Web mit Modul-Workern

Mit JavaScript-Modulen in Webworkern ist es jetzt einfacher, aufwendige Aufgaben in Hintergrundthreads zu verschieben.

JavaScript ist ein einzelner Thread, d. h., es kann jeweils nur einen Vorgang ausführen. Das ist intuitiv und funktioniert in vielen Fällen im Web gut, kann aber problematisch werden, wenn wir anspruchsvolle Aufgaben wie Datenverarbeitung, ‑parsieren, ‑berechnung oder ‑analyse ausführen müssen. Da immer mehr komplexe Anwendungen im Web bereitgestellt werden, steigt der Bedarf an einer mehrstufigen Verarbeitung.

Auf der Webplattform ist die Web Workers API die wichtigste Primitive für Threading und Parallelität. Worker sind eine einfache Abstraktion auf Betriebssystemthreads, die eine API für die Nachrichtenweitergabe für die Kommunikation zwischen Threads bereitstellen. Dies kann bei kostspieligen Berechnungen oder bei großen Datasets äußerst nützlich sein, da der Hauptthread reibungslos ausgeführt werden kann, während die teuren Vorgänge auf einem oder mehreren Hintergrundthreads ausgeführt werden.

Hier ein typisches Beispiel für die Worker-Nutzung, bei dem ein Worker-Skript Nachrichten aus dem Hauptthread abhört und darauf antwortet, indem es eigene Nachrichten zurücksendet:

page.js:

const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
  console.log(e.data);
});
worker.postMessage('hello');

worker.js:

addEventListener('message', e => {
  if (e.data === 'hello') {
    postMessage('world');
  }
});

Die Web Worker API ist seit über zehn Jahren in den meisten Browsern verfügbar. Das bedeutet, dass Worker eine hervorragende Browserunterstützung bieten und gut optimiert sind. Es bedeutet aber auch, dass sie schon lange JavaScript-Module sind. Da es beim Entwerfen von Workern kein Modulsystem gab, ähnelt die API zum Laden von Code in einen Worker und zum Erstellen von Scripts den synchronen Script-Lademethoden, die 2009 üblich waren.

Verlauf: klassische Worker

Der Worker-Konstruktor nimmt eine klassische Script-URL an, die relativ zur Dokument-URL ist. Es gibt sofort einen Verweis auf die neue Worker-Instanz zurück, die eine Messaging-Schnittstelle sowie eine terminate()-Methode bereitstellt, mit der der Worker sofort angehalten und zerstört wird.

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

In Webworkern ist eine importScripts()-Funktion zum Laden zusätzlichen Codes verfügbar. Dabei wird die Ausführung des Workers pausiert, um jedes Script abzurufen und zu bewerten. Außerdem werden Scripts wie bei einem klassischen <script>-Tag im globalen Umfang ausgeführt. Das bedeutet, dass die Variablen in einem Script von den Variablen in einem anderen überschrieben werden können.

worker.js:

importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

// global to the whole worker
function sayHello() {
  return 'world';
}

Aus diesem Grund haben Web Worker in der Vergangenheit erhebliche Auswirkungen auf die Architektur einer Anwendung. Entwickler mussten clevere Tools und Problemumgehungen entwickeln, um Webworker nutzen zu können, ohne moderne Entwicklungspraktiken aufzugeben. Beispielsweise betten Bundler wie webpack eine kleine Modul-Ladeimplementierung in den generierten Code ein, die importScripts() zum Laden von Code verwendet, aber Module in Funktionen verpackt, um Variablenkollisionen zu vermeiden und Abhängigkeitsimporte und ‑exporte zu simulieren.

Modul-Worker eingeben

In Chrome 80 wird ein neuer Modus für Web Worker mit den Ergonomie- und Leistungsvorteilen der JavaScript-Module – Modul-Worker – ausgeliefert. Der Worker-Konstruktor akzeptiert jetzt eine neue {type:"module"}-Option, die das Laden und Ausführen des Skripts so ändert, dass sie <script type="module"> entsprechen.

const worker = new Worker('worker.js', {
  type: 'module'
});

Da Modul-Worker standardmäßige JavaScript-Module sind, können sie Import- und Exportanweisungen verwenden. Wie bei allen JavaScript-Modulen werden Abhängigkeiten nur einmal in einem bestimmten Kontext (Hauptthread, Worker usw.) ausgeführt. Alle zukünftigen Importe verweisen auf die bereits ausgeführte Modulinstanz. Auch das Laden und Ausführen von JavaScript-Modulen wird von Browsern optimiert. Die Abhängigkeiten eines Moduls können vor der Ausführung des Moduls geladen werden, sodass ganze Modulbäume parallel geladen werden können. Beim Laden des Moduls wird auch geparster Code im Cache gespeichert. Module, die im Hauptthread und in einem Worker verwendet werden, müssen also nur einmal geparst werden.

Wenn Sie zu JavaScript-Modulen wechseln, können Sie auch dynamischen Import für das Lazy-Loading von Code verwenden, ohne die Ausführung des Workers zu blockieren. Der dynamische Import ist viel expliziter als die Verwendung von importScripts() zum Laden von Abhängigkeiten, da die Exporte des importierten Moduls zurückgegeben werden, anstatt sich auf globale Variablen zu verlassen.

worker.js:

import { sayHello } from './greet.js';
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

import greetings from './data.js';
export function sayHello() {
  return greetings.hello;
}

Um eine hohe Leistung zu gewährleisten, ist die alte importScripts()-Methode in Modulworkern nicht verfügbar. Wenn Sie Worker auf die Verwendung von JavaScript-Modulen umstellen, wird der gesamte Code im strikten Modus geladen. Eine weitere wichtige Änderung ist, dass der Wert von this im obersten Gültigkeitsbereich eines JavaScript-Moduls undefined ist, während er bei klassischen Workern der globale Gültigkeitsbereich des Workers ist. Glücklicherweise gab es schon immer einen globalen self, der einen Verweis auf den globalen Geltungsbereich bietet. Sie ist in allen Arten von Workern, einschließlich Service Workern, sowie im DOM verfügbar.

Worker mit modulepreload vorab laden

Eine erhebliche Leistungsverbesserung, die mit Modul-Workern einhergeht, ist die Möglichkeit, Worker und ihre Abhängigkeiten vorab zu laden. Bei Modul-Workern werden Scripts als standardmäßige JavaScript-Module geladen und ausgeführt. Das bedeutet, dass sie mit modulepreload vorab geladen und sogar vorab geparst werden können:

<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">

<script>
  addEventListener('load', () => {
    // our worker code is likely already parsed and ready to execute!
    const worker = new Worker('worker.js', { type: 'module' });
  });
</script>

Vorab geladene Module können sowohl vom Haupt-Thread als auch von Modul-Workern verwendet werden. Das ist nützlich für Module, die in beiden Kontexten importiert werden, oder in Fällen, in denen nicht im Voraus bekannt ist, ob ein Modul im Haupt- oder in einem Worker-Thread verwendet wird.

Bisher waren die Optionen zum Vorabladen von Web Worker-Skripts begrenzt und nicht unbedingt zuverlässig. Klassische Worker hatten einen eigenen Ressourcentyp „worker“ für das Vorladen, aber keine Browser haben <link rel="preload" as="worker"> implementiert. Daher war die primäre Methode zum Vorladen von Webworkern die Verwendung von <link rel="prefetch">, die vollständig auf den HTTP-Cache angewiesen war. In Kombination mit den richtigen Caching-Headern konnte so verhindert werden, dass die Worker-Instanziierung mit dem Herunterladen des Worker-Skripts warten muss. Im Gegensatz zu modulepreload unterstützte diese Methode jedoch weder das Vorladen von Abhängigkeiten noch das Vorab-Parsen.

Wie sieht es mit Mitarbeitern aus, die für mehrere Unternehmen tätig sind?

Freigegebene Worker wurden mit Unterstützung für JavaScript-Module ab Chrome 83 aktualisiert. Wie dedizierte Worker wird beim Erstellen eines freigegebenen Workers mit der Option {type:"module"} das Worker-Skript jetzt als Modul und nicht als klassisches Script geladen:

const worker = new SharedWorker('/worker.js', {
  type: 'module'
});

Vor der Unterstützung von JavaScript-Modulen wurde beim Konstruktor von SharedWorker() nur eine URL und ein optionales name-Argument erwartet. Das funktioniert weiterhin für die klassische Verwendung freigegebener Worker. Für die Erstellung freigegebener Worker für Module muss jedoch das neue Argument options verwendet werden. Die verfügbaren Optionen sind dieselben wie für einen dedizierten Worker, einschließlich der Option name, die das vorherige Argument name ersetzt.

Was ist mit Service Worker?

Die Service Worker-Spezifikation wurde bereits aktualisiert, um ein JavaScript-Modul als Einstiegspunkt zu akzeptieren. Dabei wird dieselbe {type:"module"}-Option wie bei Modul-Workern verwendet. Diese Änderung muss jedoch noch in Browsern implementiert werden. Danach können Sie einen Dienst-Worker mit dem folgenden Code über ein JavaScript-Modul instanziieren:

navigator.serviceWorker.register('/sw.js', {
  type: 'module'
});

Nachdem die Spezifikation aktualisiert wurde, beginnen Browser, das neue Verhalten zu implementieren. Das dauert, weil das Einbinden von JavaScript-Modulen in Service Worker einige zusätzliche Komplikationen mit sich bringt. Bei der Registrierung von Dienstleistern müssen importierte Scripts mit ihren vorherigen im Cache gespeicherten Versionen verglichen werden, um zu ermitteln, ob ein Update ausgelöst werden soll. Dies muss für JavaScript-Module implementiert werden, wenn sie für Dienstleister verwendet werden. Außerdem müssen Service Worker in bestimmten Fällen beim Prüfen auf Updates den Cache für Scripts umgehen können.

Weitere Ressourcen und weiterführende Literatur