`Shared Worker` in TypeScript
Dieser Artikel erklärt Shared Worker
in TypeScript.
Wir erklären im Detail, wie Shared Worker funktionieren und wie man sie in der Praxis verwendet, mit TypeScript-Codebeispielen.
YouTube Video
Shared Worker
in TypeScript
Shared Worker
ist ein einzelner Worker-Prozess, der über mehrere Tabs, Fenster und Iframes derselben Origin geteilt wird. Damit können Sie gemeinsamen Zustand und Ressourcen über mehrere Browser-Tabs hinweg verwalten.
So lassen sich beispielsweise eine gemeinsame WebSocket-Verbindung, tabübergreifend synchronisierte Cache- und Warteschlangenverarbeitung sowie gegenseitiger Ausschluss (Mutual Exclusion) effizient implementieren.
Im Gegensatz zu einem Dedicated Worker
erhält ein Shared Worker
über das onconnect
-Ereignis mehrere MessagePort
s und kann die Kommunikation mit mehreren Clients multiplexen.
Fälle, in denen Sie einen Shared Worker
wählen sollten
In den folgenden Fällen ist die Verwendung eines Shared Workers
sinnvoll.
- Wenn Sie tabübergreifend gemeinsamen Zustand oder gegenseitigen Ausschluss benötigen
- Wenn Sie eine einzige WebSocket-Verbindung oder den Zugriff auf IndexedDB teilen möchten
- Wenn Sie alle Tabs benachrichtigen müssen (Broadcast)
- Wenn Sie rechenintensive Verarbeitung zentralisieren möchten, um Ressourcen zu sparen
Umgekehrt sind in den folgenden Fällen andere Ansätze besser geeignet.
- Wenn Sie Cache-Steuerung oder Offline-Unterstützung benötigen, können Sie einen
Service Worker
verwenden. - Für aufwändige Verarbeitung, die auf einen einzelnen Tab beschränkt ist, können Sie einen
Dedicated Worker
verwenden.
Implementierungsschritte für einen Shared Worker
Hier implementieren wir Schritt für Schritt Folgendes mit TypeScript.
- Typsicheres Nachrichtenprotokoll
- Promise-basiertes Request/Response (RPC)
- Broadcast an alle Tabs
- Heartbeat und Client-Bereinigung
Einrichtung der Umgebung
Erstellen Sie die Konfiguration, um jede Quelldatei zu kompilieren, die einen Shared Worker
verwendet.
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "module": "ES2020",
5 "lib": ["ES2020", "dom", "WebWorker"],
6 "moduleResolution": "Bundler",
7 "strict": true,
8 "noEmitOnError": true,
9 "outDir": "out",
10 "skipLibCheck": true
11 },
12 "include": ["src"]
13}
- Aktivieren Sie in
tsconfig-src.json
die Typdefinitionen für DOM und Web Worker, damit der Code sicher kompiliert werden kann.
Das Nachrichtenprotokoll definieren
Die Grundlage der Kommunikation ist ein typisierter Nachrichtenkontrakt. Eine frühzeitige Definition macht die anschließende Kommunikation sicher und leicht erweiterbar.
1// worker-protocol.ts
2// Define discriminated unions for structured messages
3
4export type RequestAction =
5 | { type: 'ping' }
6 | { type: 'echo'; payload: string }
7 | { type: 'getTime' }
8 | { type: 'set'; key: string; value: unknown }
9 | { type: 'get'; key: string }
10 | { type: 'broadcast'; channel: string; payload: unknown }
11 | { type: 'lock.acquire'; key: string; timeoutMs?: number }
12 | { type: 'lock.release'; key: string };
RequestAction
ist eine diskriminierte Union (Tagged Union), die die Arten von an den Worker gesendeten Anfragen repräsentiert und Operationen wieping
,get
undbroadcast
definiert.
1export interface RequestMessage {
2 kind: 'request';
3 id: string;
4 from: string;
5 action: RequestAction;
6}
RequestMessage
definiert die Struktur der Anforderungsnachrichten, die vom Client an den Worker gesendet werden.
1export interface ResponseMessage {
2 kind: 'response';
3 id: string;
4 ok: boolean;
5 result?: unknown;
6 error?: string;
7}
ResponseMessage
definiert die Struktur der Antwortnachrichten, die vom Worker an den Client zurückgegeben werden.
1export interface BroadcastMessage {
2 kind: 'broadcast';
3 channel: string;
4 payload: unknown;
5 from: string;
6}
BroadcastMessage
definiert die Struktur der Broadcast-Nachrichten, die der Worker an andere Clients sendet.
1export type WorkerInMessage =
2 | RequestMessage
3 | { kind: 'heartbeat'; from: string }
4 | { kind: 'bye'; from: string };
WorkerInMessage
ist ein Typ, der alle Nachrichten repräsentiert, die der Worker empfängt, z. B. Anfragen, Heartbeats und Trennungsbenachrichtigungen.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
WorkerOutMessage
ist ein Typ, der die Antwort- oder Broadcast-Nachrichten repräsentiert, die der Worker an den Client sendet.
1export const randomId = () => Math.random().toString(36).slice(2);
randomId
ist eine Funktion, die eine zufällige alphanumerische Zeichenkette generiert, die z. B. für Nachrichten-IDs und Ähnliches verwendet wird.
Den Shared Worker implementieren
In shared-worker.ts
werden über das onconnect
-Ereignis verbundene Tabs registriert und Nachrichten verarbeitet.
1// shared-worker.ts
2/// <reference lib="webworker" />
- Diese Direktive weist TypeScript an, Typdefinitionen für Web Worker zu laden.
1import {
2 WorkerInMessage,
3 WorkerOutMessage,
4 RequestMessage,
5 ResponseMessage,
6} from './worker-protocol.js';
- Importiert die für die Kommunikation mit dem Worker verwendeten Typdefinitionen.
1export default {};
2declare const self: SharedWorkerGlobalScope;
- Deklariert explizit, dass
self
der globale Scope desShared Worker
ist.
1type Client = {
2 id: string;
3 port: MessagePort;
4 lastBeat: number;
5};
Client
ist ein Typ, der die Kennung jedes Clients, den Kommunikationsport und den Zeitstempel des letzten Heartbeats repräsentiert.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
- Verwaltet die Liste verbundener Clients, einen Key-Value-Store, den Sperrstatus sowie Timeout-Zeiten.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2 port.postMessage(msg);
3}
send
ist eine Hilfsfunktion, die eine Nachricht an den angegebenen Port sendet.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2 return { kind: 'response', id: req.id, ok, result, error };
3}
respond
erzeugt eine Antwortnachricht für eine Anfrage.
1function broadcast(from: string, channel: string, payload: unknown) {
2 for (const [id, c] of clients) {
3 send(c.port, { kind: 'broadcast', channel, payload, from });
4 }
5}
broadcast
sendet eine Nachricht auf einem angegebenen Kanal an alle Clients.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
handleRequest
verarbeitet eingehende Anfragen nach Typ und gibt die Ergebnisse an den Client zurück.
1 const { action } = req;
2 try {
3 switch (action.type) {
4 case 'ping':
5 send(port, respond(req, true, 'pong'));
6 break;
7 case 'echo':
8 send(port, respond(req, true, action.payload));
9 break;
10 case 'getTime':
11 send(port, respond(req, true, new Date().toISOString()));
12 break;
13 case 'set':
14 kv.set(action.key, action.value);
15 send(port, respond(req, true, true));
16 break;
17 case 'get':
18 send(port, respond(req, true, kv.get(action.key)));
19 break;
20 case 'broadcast':
21 broadcast(clientId, action.channel, action.payload);
22 send(port, respond(req, true, true));
23 break;
- In diesem Code werden je nach Art der empfangenen Anforderung das Senden und Empfangen von Nachrichten, das Abrufen und Speichern von Daten sowie das Broadcasting verarbeitet.
1 case 'lock.acquire': {
2 const owner = locks.get(action.key);
3 if (!owner) {
4 locks.set(action.key, clientId);
5 send(port, respond(req, true, { owner: clientId }));
6 } else if (owner === clientId) {
7 send(port, respond(req, true, { owner }));
8 } else {
9 const start = Date.now();
10 const tryWait = () => {
11 const current = locks.get(action.key);
12 if (!current) {
13 locks.set(action.key, clientId);
14 send(port, respond(req, true, { owner: clientId }));
15 } else if ((action.timeoutMs ?? 5000) < Date.now() - start) {
16 send(port, respond(req, false, undefined, 'lock-timeout'));
17 } else {
18 setTimeout(tryWait, 25);
19 }
20 };
21 tryWait();
22 }
23 break;
24 }
- Dieser Code implementiert den Ablauf, mit dem ein Client für den angegebenen Schlüssel eine Sperre erwirbt. Ist die Sperre noch nicht belegt, wird sie sofort erworben; fordert derselbe Client sie erneut an, wird die Anforderung ebenfalls als erfolgreich gewertet. Hält ein anderer Client die Sperre bereits, wird alle 25 Millisekunden ein neuer Versuch unternommen, bis die Sperre freigegeben wird; wird das angegebene Timeout (standardmäßig 5 Sekunden) überschritten, erfolgt eine Fehlermeldung.
1 case 'lock.release':
2 if (locks.get(action.key) === clientId) {
3 locks.delete(action.key);
4 send(port, respond(req, true, true));
5 } else {
6 send(port, respond(req, false, undefined, 'not-owner'));
7 }
8 break;
9 default:
10 send(port, respond(req, false, undefined, 'unknown-action'));
11 }
12 } catch (e: any) {
13 send(port, respond(req, false, undefined, e?.message ?? 'error'));
14 }
15}
- Dieser Code gibt die vom Client gehaltene Sperre frei und liefert eine Fehlerantwort zurück, wenn dem Client die Berechtigung fehlt oder die Aktion unbekannt ist.
1function handleInMessage(c: Client, msg: WorkerInMessage) {
2 if (msg.kind === 'heartbeat') {
3 c.lastBeat = Date.now();
4 } else if (msg.kind === 'bye') {
5 cleanupClient(msg.from);
6 } else if (msg.kind === 'request') {
7 handleRequest(c.id, c.port, msg);
8 }
9}
handleInMessage
parst Nachrichten, die von Clients empfangen werden, und verarbeitet Anfragen und Heartbeats.
1function cleanupClient(clientId: string) {
2 for (const [key, owner] of locks) {
3 if (owner === clientId) locks.delete(key);
4 }
5 clients.delete(clientId);
6}
cleanupClient
entfernt getrennte Clients aus der Registrierung und dem Sperrstatus.
1setInterval(() => {
2 const now = Date.now();
3 for (const [id, c] of clients) {
4 if (now - c.lastBeat > HEARTBEAT_TIMEOUT) cleanupClient(id);
5 }
6}, 10_000);
- Verwendet
setInterval
, um regelmäßig die Heartbeats aller Clients zu prüfen und abgelaufene Verbindungen aufzuräumen.
1self.onconnect = (e: MessageEvent) => {
2 const port = (e.ports && e.ports[0]) as MessagePort;
3 const clientId = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
4 const client: Client = { id: clientId, port, lastBeat: Date.now() };
5 clients.set(clientId, client);
6
7 port.addEventListener('message', (ev) => handleInMessage(client, ev.data as WorkerInMessage));
8 send(port, { kind: 'broadcast', channel: 'system', payload: { hello: true, clientId }, from: 'worker' });
9 port.start();
10};
-
onconnect
wird aufgerufen, wenn ein neuer Tab oder eine Seite eine Verbindung zumShared Worker
herstellt; der Client wird registriert und die Kommunikation gestartet. -
In dieser Datei werden die grundlegenden Mechanismen eines
Shared Worker
implementiert, die die gemeinsame Zustandsverwaltung und Kommunikation über mehrere Browser-Tabs hinweg ermöglichen.
Client-Wrapper (RPC)
Als Nächstes erstellen wir einen Promise-basierten RPC-Client.
1// shared-worker-client.ts
2import {
3 RequestAction,
4 RequestMessage,
5 WorkerOutMessage,
6 randomId
7} from './worker-protocol.js';
- Importiert die für die Worker-Kommunikation verwendeten Typdefinitionen und Hilfsfunktionen.
1export type BroadcastHandler = (msg: {
2 channel: string;
3 payload: unknown;
4 from: string
5}) => void;
- Hier definieren wir den Typ der Callback-Funktion, die bei Empfang einer Broadcast-Nachricht ausgeführt wird.
1export class SharedWorkerClient {
SharedWorkerClient
ist eine Client-Klasse, die mit einemShared Worker
kommuniziert, Anfragen sendet und Antworten verarbeitet.
1 private worker: SharedWorker;
2 private port: MessagePort;
3 private pending = new Map<string, {
4 resolve: (v: any) => void;
5 reject: (e: any) => void
6 }>();
- Diese Variablen sind die Worker-Instanz, der Kommunikationsport zum Worker und eine Map, die auf Antworten wartende Anfragen nachverfolgt.
1 private clientId = randomId();
2 private heartbeatTimer?: number;
3 private onBroadcast?: BroadcastHandler;
- Diese Variablen halten die Client-Kennung, den Timer zum Senden von Heartbeats und den Handler für den Empfang von Broadcasts.
1 constructor(url: URL, name = 'app-shared', onBroadcast?: BroadcastHandler) {
2 this.worker = new SharedWorker(url, { name, type: 'module' as any });
3 this.port = this.worker.port;
4 this.onBroadcast = onBroadcast;
- Im Konstruktor wird die Verbindung zum
Shared Worker
initialisiert und es werden Nachrichten-Listener sowie das Senden von Heartbeats eingerichtet.
1 this.port.addEventListener('message', (ev) => {
2 const msg = ev.data as WorkerOutMessage;
3 if (msg.kind === 'response') {
4 const p = this.pending.get(msg.id);
5 if (p) {
6 this.pending.delete(msg.id);
7 msg.ok ? p.resolve(msg.result) : p.reject(new Error(msg.error || 'error'));
8 }
9 } else if (msg.kind === 'broadcast') {
10 this.onBroadcast?.(msg);
11 }
12 });
13 this.port.start();
- Hier werden Nachrichten vom Worker empfangen und Antworten oder Broadcasts verarbeitet.
1 this.heartbeatTimer = window.setInterval(() => {
2 this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3 }, 10_000);
- Sendet regelmäßig Heartbeat-Nachrichten, um die Verbindung aufrechtzuerhalten.
1 window.addEventListener('beforeunload', () => {
2 try {
3 this.port.postMessage({ kind: 'bye', from: this.clientId });
4 } catch {}
5 if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
6 });
7 }
- Sendet vor dem Schließen des Fensters eine Trennungsbenachrichtigung an den Worker.
1 request<T = unknown>(action: RequestAction): Promise<T> {
2 const id = randomId();
3 return new Promise<T>((resolve, reject) => {
4 this.pending.set(id, { resolve, reject });
5 this.port.postMessage({ kind: 'request', id, from: this.clientId, action });
6 });
7 }
- Die Methode
request
sendet die angegebene Aktion an den Worker und erhält das Ergebnis alsPromise
.
1 ping() {
2 return this.request<string>({ type: 'ping' });
3 }
4 echo(payload: string) {
5 return this.request<string>({ type: 'echo', payload });
6 }
7 getTime() {
8 return this.request<string>({ type: 'getTime' });
9 }
- Dies sind Hilfsmethoden für grundlegende Kommunikationstests und das Abrufen der aktuellen Zeit.
1 set(key: string, value: unknown) {
2 return this.request<boolean>({ type: 'set', key, value });
3 }
4 get<T = unknown>(key: string) {
5 return this.request<T>({ type: 'get', key });
6 }
- Dies sind Methoden zum Speichern und Abrufen von Schlüssel-Wert-Paaren.
1 broadcast(channel: string, payload: unknown) {
2 return this.request<boolean>({ type: 'broadcast', channel, payload });
3 }
- Dies ist eine Methode, die Broadcast-Nachrichten über den Worker an andere Clients sendet.
1 lockAcquire(key: string, timeoutMs?: number) {
2 return this.request<{ owner: string }>({ type: 'lock.acquire', key, timeoutMs });
3 }
4 lockRelease(key: string) {
5 return this.request<boolean>({ type: 'lock.release', key });
6 }
7}
- Dies sind Methoden, die Sperren erwerben und freigeben, um gegenseitigen Ausschluss über gemeinsam genutzte Ressourcen zu erreichen.
- In dieser Datei wird eine Client-API für sichere, asynchrone Kommunikation von jedem Browser-Tab zum Shared Worker implementiert.
Beispielverwendung
In demo.ts
verwenden wir die zuvor erstellte Klasse SharedWorkerClient
und überprüfen ihr Verhalten. Es führt nacheinander eine Reihe von Funktionen aus, darunter Kommunikationstests, Lesen und Schreiben von Daten, Broadcasting sowie den Umgang mit Sperren.
1// demo.ts
2import { SharedWorkerClient } from './shared-worker-client.js';
3
4const client = new SharedWorkerClient(new URL('./shared-worker.js', import.meta.url), 'app-shared', (b) => {
5 console.log('[BROADCAST]', b.channel, JSON.stringify(b.payload));
6});
7
8async function demo() {
9 console.log('ping:', await client.ping());
10 console.log('echo:', await client.echo('hello'));
11 console.log('time:', await client.getTime());
12
13 // Counter update
14 await client.set('counter', (await client.get<number>('counter')) ?? 0);
15 const c1 = await client.get<number>('counter');
16 await client.set('counter', (c1 ?? 0) + 1);
17 console.log('counter:', await client.get<number>('counter'));
18
19 // Broadcast test
20 await client.broadcast('notify', { msg: 'Counter updated' });
21
22 // Lock test
23 const key = 'critical';
24 console.log(`[lock] Trying to acquire lock: "${key}"`);
25 const lockResult = await client.lockAcquire(key, 2000);
26 console.log(`[lock] Lock acquired for key "${key}":`, lockResult);
27
28 try {
29 console.log(`[lock] Simulating critical section for key "${key}"...`);
30 await new Promise((r) => setTimeout(r, 250));
31 console.log(`[lock] Critical section completed for key "${key}"`);
32 } finally {
33 console.log(`[lock] Releasing lock: "${key}"`);
34 const releaseResult = await client.lockRelease(key);
35 console.log(`[lock] Lock released for key "${key}":`, releaseResult);
36 }
37}
38
39demo().catch(console.error);
- Dieser Code ist eine Demo, die einen
Shared Worker
verwendet, um Daten und Zustand über mehrere Browser-Tabs hinweg zu teilen und zu synchronisieren. Durch nachrichtenbasierte Kommunikation können asynchrone Nachrichten sicher und mit loser Kopplung ausgetauscht werden, was die Verwaltung der Kommunikation zwischen unterschiedlichen Kontexten erleichtert. Zudem abstrahiert die Verwendung von RPC die Kommunikation mit dem Worker in einem intuitiven, methodenaufrufähnlichen Stil und verbessert Wartbarkeit und Lesbarkeit.
Testen in HTML
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Shared Worker Demo</title>
7</head>
8<body>
9 <h1>SharedWorker Demo</h1>
10 <p>Open the browser console to see the output.</p>
11
12 <script type="module">
13 import './demo.js';
14 </script>
15</body>
16</html>
Hinweise zu Design und Betrieb
Bei Design und Betrieb trägt das Beachten der folgenden Punkte dazu bei, ein robusteres und besser erweiterbares System zu erstellen.
- Sie können eine diskriminierte Union (Tagged Union) verwenden, die Verzweigungen anhand von
kind
odertype
ermöglicht. - Verwenden Sie eine Correlation-ID, um Anfragen korrekt mit Antworten abzugleichen.
- Heartbeats und automatische Bereinigung können verwaiste Sperren verhindern.
- Implementieren Sie Versionierung, um zukünftige Protokolländerungen flexibel zu berücksichtigen.
- Die Definition klarer Fehlercodes erleichtert die Behandlung auf der UI-Seite und das Debugging.
Zusammenfassung
Ein Shared Worker
ist ein Kernmechanismus zum Teilen von Daten und Zustand über mehrere Browser-Tabs hinweg.
Die hier vorgestellte Struktur bietet typsichere RPC-Kommunikation, Liveness-Überwachung über Heartbeats und einen Sperrmechanismus und ist damit ein robustes Design, das so in der Produktion eingesetzt werden kann.
Auf Basis dieses Mechanismus können Sie zudem die folgenden Anwendungen implementieren:.
- Serialisierung des IndexedDB-Zugriffs
- Integration und gemeinsame Nutzung von WebSocket-Verbindungen
- Aufbau einer Job-Queue über mehrere Tabs hinweg
- Drosselung und Zustellung von Fortschrittsbenachrichtigungen
Wie Sie sehen, ermöglicht die Nutzung eines Shared Worker
, Daten und Verarbeitung sicher und effizient über mehrere Tabs hinweg zu teilen.
Sie können den obigen Artikel mit Visual Studio Code auf unserem YouTube-Kanal verfolgen. Bitte schauen Sie sich auch den YouTube-Kanal an.