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 sehr nützlich sein, wenn Sie aufwendige Berechnungen ausführen oder mit großen Datenmengen arbeiten. So kann der Hauptthread reibungslos ausgeführt werden, während die aufwendigen Vorgänge in einem oder mehreren Hintergrundthreads ausgeführt werden.

Hier ein typisches Beispiel für die Verwendung von Workern: Ein Worker-Script wartet auf Nachrichten vom Haupt-Thread und 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 zwar, dass sie eine hervorragende Browserunterstützung haben und gut optimiert sind, aber auch, dass sie schon lange vor JavaScript-Modulen entwickelt wurden. 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. Sie 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 Webworker in der Vergangenheit einen übergroßen Einfluss auf die Architektur einer Anwendung ausgeübt. 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.

Modulmitarbeiter eingeben

In Chrome 80 wird ein neuer Modus für Webworker eingeführt, der die Ergonomie und Leistung von JavaScript-Modulen bietet. Er wird als Modul-Worker bezeichnet. Der Konstruktor von Worker akzeptiert jetzt eine neue {type:"module"}-Option, mit der das Laden und Ausführen des Scripts an <script type="module"> angepasst wird.

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. Das Laden und Ausführen von JavaScript-Modulen wird ebenfalls 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 von Modulen wird auch geparserter Code im Cache gespeichert. Das bedeutet, dass Module, die im Haupt-Thread und in einem Worker verwendet werden, nur einmal geparst werden müssen.

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 strengen 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 ein globales self, das eine Referenz auf den globalen Gültigkeitsbereich liefert. 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 Vorladen von Webworker-Scripts 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 Instanziierung des Workers warten musste, bis das Worker-Script heruntergeladen wurde. 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?

Gemeinsam genutzte Worker wurden in Chrome 83 um die Unterstützung für JavaScript-Module erweitert. Wie bei dedizierten Workern wird beim Erstellen eines freigegebenen Workers mit der Option {type:"module"} das Worker-Script 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 für den SharedWorker()-Konstruktor 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