Mit JavaScript-Modulen in Web Workern ist es jetzt einfacher, rechenintensive Aufgaben in Hintergrundthreads zu verschieben.
JavaScript ist Single-Threaded, d. h., es kann jeweils nur ein Vorgang ausgeführt werden. Das ist intuitiv und funktioniert in vielen Fällen im Web gut, kann aber problematisch werden, wenn wir rechenintensive Aufgaben wie Datenverarbeitung, Parsing, Berechnung oder Analyse ausführen müssen. Da immer komplexere Anwendungen im Web bereitgestellt werden, steigt der Bedarf an Multithreading-Verarbeitung.
Auf der Webplattform ist die Web Workers API das wichtigste Primitiv für Threading und Parallelität. Worker sind eine einfache Abstraktion über Betriebssystem-Threads, die eine API zur Nachrichtenweitergabe für die Kommunikation zwischen Threads bereitstellen. Das kann bei rechenintensiven Berechnungen oder großen Datasets sehr nützlich sein, da der Hauptthread reibungslos ausgeführt werden kann, während die rechenintensiven Vorgänge in einem oder mehreren Hintergrundthreads ausgeführt werden.
Hier ist ein typisches Beispiel für die Verwendung eines Workers, bei dem ein Worker-Script auf Nachrichten vom Hauptthread wartet 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 zwar, dass Worker eine hervorragende Browserunterstützung haben und gut optimiert sind, aber auch, dass sie lange vor JavaScript-Modulen eingeführt wurden. Da es bei der Entwicklung von Workern noch kein Modulsystem gab, ist die API zum Laden von Code in einen Worker und zum Erstellen von Skripts ähnlich wie die synchronen Skriptladeansätze, die 2009 üblich waren.
Verlauf: Klassische Worker
Der Worker-Konstruktor verwendet eine klassische Script-URL, die relativ zur Dokument-URL ist. Es wird sofort ein Verweis auf die neue Worker-Instanz zurückgegeben, die eine Messaging-Schnittstelle sowie eine terminate()-Methode bereitstellt, mit der der Worker sofort beendet und gelöscht wird.
const worker = new Worker('worker.js');
In Web Workern ist eine importScripts()-Funktion zum Laden von zusätzlichem Code verfügbar. Die Ausführung des Workers wird jedoch pausiert, um jedes Script abzurufen und auszuwerten. Außerdem werden Scripts im globalen Bereich wie ein klassisches <script>-Tag ausgeführt. Das bedeutet, dass die Variablen in einem Script durch die Variablen in einem anderen Script ü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 hatten Web-Worker in der Vergangenheit einen überproportionalen Einfluss auf die Architektur einer Anwendung. Entwickler mussten clevere Tools und Problemumgehungen entwickeln, um Web-Worker verwenden zu können, ohne auf moderne Entwicklungsmethoden zu verzichten. Beispielsweise betten Bundler wie webpack eine kleine Modullader-Implementierung in generierten Code ein, der importScripts() zum Laden von Code verwendet, aber Module in Funktionen verpackt, 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 ergonomischen und Leistungsvorteilen von JavaScript-Modulen eingeführt, die sogenannten Modul-Worker. Der Worker-Konstruktor akzeptiert jetzt eine neue {type:"module"}-Option, mit der das Laden und Ausführen von Skripts an <script type="module"> angepasst wird.
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 in einem bestimmten Kontext (Hauptthread, Worker usw.) nur einmal ausgeführt. Alle zukünftigen Importe verweisen auf die bereits ausgeführte Modulinstanz. Das Laden und Ausführen von JavaScript-Modulen wird auch 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 geparster Code im Cache gespeichert. Das bedeutet, dass Module, die im Hauptthread und in einem Worker verwendet werden, nur einmal geparst werden müssen.
Die Umstellung auf JavaScript-Module ermöglicht auch die Verwendung von dynamischem Import für Lazy Loading von Code, 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 gute Leistung zu gewährleisten, ist die alte importScripts()-Methode in Modul-Workern nicht verfügbar. Wenn Worker auf JavaScript-Module umgestellt werden, wird der gesamte Code im strikten Modus geladen. Eine weitere wichtige Änderung ist, dass der Wert von this im übergeordneten Bereich eines JavaScript-Moduls undefined ist, während der Wert in klassischen Workern der globale Bereich des Workers ist. Glücklicherweise gab es schon immer eine globale self-Variable, die einen Verweis auf den globalen Bereich bereitstellt. Sie ist in allen Arten von Workern verfügbar, einschließlich Service Workern, sowie im DOM.
Worker mit modulepreload vorab laden
Eine wesentliche Leistungsverbesserung, die mit Modul-Workern einhergeht, ist die Möglichkeit, Worker und ihre Abhängigkeiten vorab zu laden. Mit Modul-Workern werden Scripts als Standard-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 auch sowohl vom Hauptthread als auch von Modul-Workern verwendet werden. Das ist nützlich für Module, die in beiden Kontexten importiert werden, oder wenn nicht im Voraus bekannt ist, 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 einen eigenen „worker“-Ressourcentyp für das Preloading, aber kein Browser hat <link rel="preload" as="worker"> implementiert. Daher war die primäre Methode zum Vorabladen von Web Workern die Verwendung von <link rel="prefetch">, was vollständig vom HTTP-Cache abhing. In Kombination mit den richtigen Caching-Headern konnte so vermieden werden, dass die Worker-Instanziierung auf den Download des Worker-Skripts warten musste. Im Gegensatz zu modulepreload wurden bei dieser Technik jedoch weder das Vorabladen von Abhängigkeiten noch das Vorab-Parsen unterstützt.
Wie sieht es mit freigegebenen Workern aus?
Shared Worker wurden ab 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-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 erwartete der SharedWorker()-Konstruktor nur eine URL und ein optionales name-Argument. Dies funktioniert weiterhin für die klassische Nutzung von Shared Workern. Zum Erstellen von Shared Workern 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.
Wie sieht es mit Service Workern aus?
Die Service Worker-Spezifikation wurde bereits aktualisiert, um die Annahme eines JavaScript-Moduls als Einstiegspunkt zu unterstützen. Dabei wird dieselbe Option {type:"module"} wie bei Modul-Workern verwendet. Diese Änderung muss jedoch noch in Browsern implementiert werden. Sobald dies der Fall ist, kann ein Service Worker mit einem JavaScript-Modul und dem folgenden Code instanziiert werden:
navigator.serviceWorker.register('/sw.js', {
type: 'module'
});
Nachdem die Spezifikation aktualisiert wurde, beginnen Browser mit der Implementierung des neuen Verhaltens. Das dauert etwas, da es einige zusätzliche Komplikationen gibt, die mit der Bereitstellung von JavaScript-Modulen für Service Worker verbunden sind. Bei der Registrierung von Service Workern müssen importierte Skripts mit ihren vorherigen im Cache gespeicherten Versionen verglichen werden, 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 in bestimmten Fällen beim Suchen nach Updates den Cache für Scripts umgehen können.