Threading im Web mit Modul-Workern

Das Verschieben schwerer Arbeiten in Hintergrund-Threads ist jetzt mit JavaScript-Modulen in Web Workern einfacher.

JavaScript ist Single-Threaded, das heißt, es kann jeweils nur eine Operation ausführen. Dies ist intuitiv und funktioniert gut für viele Fälle im Web, kann jedoch zu Problemen werden, wenn wir aufwendige Aufgaben wie Datenverarbeitung, Parsen, Berechnung oder Analyse ausführen müssen. Da im Web immer komplexere Anwendungen bereitgestellt werden, steigt der Bedarf an Multithread-Verarbeitung.

Auf der Webplattform ist die Web Workers API die Hauptprimitive für Threading und Parallelität. Worker sind eine einfache Abstraktion auf Betriebssystemthreads, die eine API zur Nachrichtenweitergabe für die Kommunikation zwischen Threads verfügbar machen. 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 ist 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 bei der Entwicklung von Workern kein Modulsystem gab, ähnelte die API zum Laden von Code in einem Worker und zum Erstellen von Skripts den 2009 gängigen Ansätzen zum synchronen Laden von Skripts.

Verlauf: klassische Worker

Der Worker-Konstruktor verwendet eine klassische Skript-URL, die sich auf die Dokument-URL bezieht. Sie gibt sofort eine Referenz an die neue Worker-Instanz zurück, die eine Messaging-Oberfläche sowie eine terminate()-Methode verfügbar macht, die den Worker sofort beendet und zerstört.

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

In den Web-Workern ist eine importScripts()-Funktion zum Laden von zusätzlichem Code verfügbar. Sie unterbricht jedoch die Ausführung des Workers, um jedes Skript abzurufen und auszuwerten. Außerdem werden Skripts global 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 erhebliche Auswirkungen auf die Architektur einer Anwendung. Entwickler mussten intelligente Tools und Behelfslösungen entwickeln, um Web Worker nutzen zu können, ohne auf moderne Entwicklungsmethoden verzichten zu müssen. Bundler wie Webpack betten beispielsweise eine kleine Implementierung des Modulladeprogramms in generierten Code ein, der importScripts() zum Laden des Codes verwendet. Module werden aber in Funktionen eingebettet, um Variablenkonflikte 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 es sich bei Modul-Workern um Standard-JavaScript-Module handelt, 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 und 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. Dadurch können ganze Modulstrukturen parallel geladen werden. 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.

Durch den Wechsel zu JavaScript-Modulen kann auch ein dynamischer Import für Lazy Loading von Code verwendet werden, ohne die Ausführung des Workers zu blockieren. Der dynamische Import ist wesentlich 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;
}

Die alte Methode importScripts() ist in Modul-Workern nicht verfügbar, um eine hervorragende Leistung zu gewährleisten. Wenn Sie Worker auf die Verwendung von JavaScript-Modulen umstellen, wird der gesamte Code im strikten Modus geladen. Eine weitere wichtige Änderung besteht darin, dass der Wert von this im obersten Bereich eines JavaScript-Moduls undefined ist, während der Wert bei klassischen Workern der globale Geltungsbereich des Workers ist. Glücklicherweise gab es schon immer ein globales self, das 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 wesentliche Leistungsverbesserung, die mit Modul-Workern einhergeht, ist die Möglichkeit, Worker und deren Abhängigkeiten vorab zu laden. Mit Modul-Workern werden Skripts als Standard-JavaScript-Module geladen und ausgeführt. Das bedeutet, dass sie vorab geladen und mit modulepreload 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 auch von den Hauptthread- und Modul-Workern verwendet werden. Dies ist nützlich für Module, die in beiden Kontexten importiert werden, oder wenn es nicht im Voraus möglich ist, ob ein Modul im Hauptthread oder in einem Worker verwendet wird.

Bisher waren die Optionen zum Vorabladen von Web Worker-Skripts eingeschränkt und nicht unbedingt zuverlässig. Klassische Worker hatten für das Vorabladen einen eigenen Worker-Ressourcentyp, 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">, das vollständig auf dem HTTP-Cache beruhte. 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ützt dieses Verfahren jedoch weder das Vorabladen von Abhängigkeiten noch die Vorbereitung.

Was ist mit gemeinsamen Workern?

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 erwartete der SharedWorker()-Konstruktor nur eine URL und ein optionales name-Argument. Bei klassischen freigegebenen Workern funktioniert dies weiterhin. Zum Erstellen von freigegebenen Workern für Modul 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.

Wie sieht es mit dem Service Worker aus?

Die Service Worker-Spezifikation wurde bereits aktualisiert, um die Annahme eines JavaScript-Moduls als Einstiegspunkt zu unterstützen, wobei dieselbe {type:"module"}-Option wie für Modul-Worker verwendet wird. Diese Änderung muss jedoch noch in Browsern implementiert werden. Anschließend kann ein Service Worker mithilfe eines JavaScript-Moduls mit folgendem Code instanziiert werden:

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

Nachdem die Spezifikation aktualisiert wurde, beginnen die Browser damit, das neue Verhalten zu implementieren. Dies nimmt Zeit in Anspruch, da die Bereitstellung von JavaScript-Modulen an Service Worker mit einigen zusätzlichen Komplikationen verbunden ist. Die Service Worker-Registrierung muss importierte Skripts mit den vorherigen im Cache gespeicherten Versionen vergleichen, um festzustellen, 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