Threading im Web mit Modul-Workern

Mit JavaScript-Modulen in Web Workern ist es jetzt einfacher, aufwendige Arbeit in Hintergrundthreads zu verlagern.

JavaScript ist Single-Threaded, es kann also jeweils nur ein Vorgang ausgeführt werden. Dies ist intuitiv und funktioniert gut in vielen Fällen im Web, kann jedoch problematisch werden, wenn wir komplexe Aufgaben wie die Datenverarbeitung, das Parsen, die Berechnung oder die Analyse erledigen. Da immer komplexere Anwendungen im Web bereitgestellt werden, steigt der Bedarf für die Multithread-Verarbeitung.

Auf der Webplattform ist die Web Workers API die wichtigste Primitive für Threading und Parallelität. Worker sind eine einfache Abstraktion auf Betriebssystem-Threads, die eine API zur Nachrichtenweitergabe für die Kommunikation zwischen Threads zur Verfügung stellen. Dies kann bei der Durchführung kostspieliger Berechnungen oder beim Betrieb mit großen Datasets äußerst nützlich sein, da der Hauptthread reibungslos läuft, während die kostspieligen Vorgänge auf einem oder mehreren Hintergrundthreads ausgeführt werden.

Hier sehen Sie ein typisches Beispiel für die Worker-Nutzung, bei dem ein Worker-Skript Nachrichten aus dem Hauptthread abhört und mit eigenen Nachrichten antwortet:

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 in den meisten Browsern seit über zehn Jahren verfügbar. Das bedeutet, dass Worker eine hervorragende Browserunterstützung haben und gut optimiert sind. Es bedeutet aber auch, dass sie schon lange älter sind als JavaScript-Module. Da es bei der Entwicklung von Workern kein Modulsystem gab, ist die API zum Laden von Code in einen Worker und zum Erstellen von Skripts den synchronen Ladevorgängen von 2009 ähnlich geblieben.

Geschichte: klassische Worker

Der Worker-Konstruktor verwendet die URL eines klassischen Skripts, die sich auf die Dokument-URL bezieht. Es gibt sofort einen Verweis auf die neue Worker-Instanz zurück, die eine Messaging-Schnittstelle sowie eine terminate()-Methode verfügbar macht, die den Worker sofort anhält und löscht.

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

In den Web-Workern steht eine importScripts()-Funktion zum Laden von zusätzlichem Code zur Verfügung. Sie pausiert jedoch die Ausführung des Workers, um jedes Skript abzurufen und auszuwerten. Außerdem werden Skripts globaler Ebene wie ein klassisches <script>-Tag ausgeführt. Das bedeutet, dass die Variablen in einem Skript 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 einen enormen Einfluss auf die Architektur einer Anwendung. Die Entwickler mussten ausgeklügelte Tools und Problemumgehungen entwickeln, um Web Worker nutzen zu können, ohne auf moderne Entwicklungspraktiken zu verzichten. Bundler wie Webpack betten beispielsweise eine kleine Modulladeimplementierung in generierten Code ein, der importScripts() zum Laden von Code verwendet, packt aber Module in Funktionen ein, 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 von JavaScript-Modulen veröffentlicht. Er wird als Modul-Worker bezeichnet. Der Konstruktor Worker akzeptiert jetzt eine neue {type:"module"}-Option, die das Laden und Ausführen des Skripts so ändert, dass sie mit <script type="module"> übereinstimmen.

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

Da Modul-Worker Standard-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 auch durch Browser optimiert. Die Abhängigkeiten eines Moduls können vor der Ausführung des Moduls geladen werden. Dadurch können ganze Modulbäume parallel geladen werden. Beim Laden des Moduls wird auch geparster Code im Cache gespeichert. Das bedeutet, dass Module, die im Hauptthread und in einem Worker verwendet werden, nur einmal geparst werden müssen.

Durch den Wechsel zu JavaScript-Modulen kann auch der dynamische Import für Lazy Loading von Code verwendet werden, ohne die Ausführung des Workers zu blockieren. Der dynamische Import ist wesentlich expliziter als importScripts() zum Laden von Abhängigkeiten, da die Exporte des importierten Moduls zurückgegeben werden und nicht auf globalen Variablen basieren.

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 hervorragende Leistung zu gewährleisten, ist die alte Methode importScripts() in Modul-Workern nicht verfügbar. Wenn die Worker auf JavaScript-Module umgestellt werden, wird der gesamte Code im strengen Modus geladen. Eine weitere bemerkenswerte Änderung besteht darin, dass der Wert von this im übergeordneten Bereich eines JavaScript-Moduls undefined ist. Bei klassischen Workern bezieht sich der Wert auf den globalen Geltungsbereich des Workers. Glücklicherweise gibt es immer einen globalen self-Bereich, der auf den globalen Geltungsbereich verweist. Sie ist in allen Arten von Workern, einschließlich Service Workern, sowie im DOM verfügbar.

Worker mit modulepreload vorab laden

Eine wesentliche Leistungsverbesserung von Modul-Workern ist die Möglichkeit, Worker und ihre Abhängigkeiten vorab zu laden. Mit Modul-Workern werden Skripts als Standard-JavaScript-Module geladen und ausgeführt. Sie können also vorab geladen und mit modulepreload sogar vorab geparst werden:

<!-- 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>

Vorinstallierte Module können auch vom Hauptthread und vom Modul-Worker verwendet werden. Dies ist nützlich für Module, die in beiden Kontexten importiert werden, oder in Fällen, in denen nicht im Voraus festgestellt werden kann, ob ein Modul im Hauptthread oder in einem Worker verwendet wird.

Bisher waren die Optionen zum Vorabladen von Web Worker-Skripts begrenzt und nicht unbedingt zuverlässig. Klassische Worker hatten zum Vorabladen einen eigenen Ressourcentyp "Worker", aber <link rel="preload" as="worker"> wurde in keinem Browser implementiert. Daher war die primäre Methode zum Vorabladen von Web Workern die Verwendung von <link rel="prefetch">, die vollständig auf dem HTTP-Cache basierte. Durch die Verwendung in Kombination mit den richtigen Caching-Headern wurde verhindert, dass die Worker-Instanziierung mit dem Herunterladen des Worker-Skripts warten muss. Im Gegensatz zu modulepreload wurde bei dieser Technik jedoch das Vorabladen von Abhängigkeiten oder das Vorbereiten der Analyse nicht unterstützt.

Was ist mit gemeinsam genutzten Mitarbeitern?

Gemeinsam genutzte Worker wurden ab Chrome 83 mit Unterstützung für JavaScript-Module 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 Skript geladen:

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

Vor der Unterstützung von JavaScript-Modulen hat der SharedWorker()-Konstruktor nur eine URL und ein optionales name-Argument erwartet. Dies funktioniert weiterhin für klassische gemeinsam genutzte Worker. Zum Erstellen von gemeinsam genutzten Workern für Module muss jedoch das neue Argument options verwendet werden. Die verfügbaren Optionen sind dieselben wie die für einen dedizierten Worker, einschließlich der Option name, die das vorherige name-Argument ersetzt.

Was ist mit Service Worker?

Die Service Worker-Spezifikation wurde bereits aktualisiert, um ein JavaScript-Modul als Einstiegspunkt zu unterstützen und dieselbe {type:"module"}-Option wie bei den Modul-Workern zu verwenden. Diese Änderung muss jedoch noch in Browsern implementiert werden. Danach kann ein Service Worker mithilfe eines JavaScript-Moduls mithilfe des folgenden Codes instanziiert werden:

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

Nach der Aktualisierung der Spezifikation beginnen Browser mit der Implementierung des neuen Verhaltens. Dies dauert einige Zeit, da mit der Bereitstellung von JavaScript-Modulen an den Service Worker einige zusätzliche Komplikationen verbunden sind. Bei der Service Worker-Registrierung müssen importierte Skripts mit den vorherigen im Cache gespeicherten Versionen verglichen werden, wenn ermittelt werden soll, ob ein Update ausgelöst werden soll. Dies muss für JavaScript-Module implementiert werden, wenn sie für Service Worker verwendet werden. Außerdem müssen Service Worker bei der Suche nach Updates in bestimmten Fällen den Cache für Skripts umgehen können.

Zusätzliche Ressourcen und weiterführende Informationen