`Shared Worker` in TypeScript

`Shared Worker` in TypeScript

Dit artikel legt Shared Worker in TypeScript uit.

We leggen in detail uit hoe Shared Workers werken en hoe je ze in de praktijk gebruikt, met TypeScript-codevoorbeelden.

YouTube Video

Shared Worker in TypeScript

Shared Worker is een enkel workerproces dat gedeeld wordt door meerdere tabbladen, vensters en iframes op dezelfde origin. Hiermee kun je gedeelde status en resources over meerdere browsertabbladen beheren.

Je kunt hiermee bijvoorbeeld efficiënt een gedeelde WebSocket-verbinding, cache- en queue-verwerking gesynchroniseerd over tabbladen, en wederzijdse uitsluiting implementeren.

In tegenstelling tot een Dedicated Worker ontvangt een Shared Worker meerdere MessagePorts via de onconnect-gebeurtenis en kan communicatie met meerdere clients multiplexen.

Situaties waarin je voor een Shared Worker moet kiezen

In de volgende gevallen is het verstandig een Shared Worker te gebruiken.

  • Als je gedeelde status of wederzijdse uitsluiting over tabbladen nodig hebt
  • Als je één WebSocket-verbinding of IndexedDB-toegang wilt delen
  • Als je alle tabbladen moet notificeren (broadcast)
  • Als je zware verwerking wilt centraliseren om resources te besparen

Omgekeerd zijn in de volgende gevallen andere benaderingen geschikter.

  • Als je cachebeheer of offline-ondersteuning nodig hebt, kun je een Service Worker gebruiken.
  • Voor zware verwerking die beperkt is tot één tabblad kun je een Dedicated Worker gebruiken.

Implementatiestappen voor een Shared Worker

Hier implementeren we het volgende stap voor stap met TypeScript.

  • Typeveilig berichtenprotocol
  • Promise-gebaseerde request/response (RPC)
  • Broadcast naar alle tabbladen
  • Heartbeat en opschoning van clients

Omgeving instellen

Maak de configuratie om elk bronbestand dat een Shared Worker gebruikt te compileren.

 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}
  • Schakel in tsconfig-src.json de DOM- en Web Worker-typedefinities in zodat de code veilig kan worden gecompileerd.

Het berichtprotocol definiëren

De basis van de communicatie is een getypeerd berichtencontract. Dit vooraf definiëren maakt de daaropvolgende communicatie veilig en eenvoudig uit te breiden.

 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 is een gediscrimineerde unie (getagde unie) die de typen verzoeken vertegenwoordigt die naar de worker worden verzonden en bewerkingen zoals ping, get en broadcast definieert.
1export interface RequestMessage {
2  kind: 'request';
3  id: string;
4  from: string;
5  action: RequestAction;
6}
  • RequestMessage definieert de structuur van verzoekberichten die van de client naar de worker worden gestuurd.
1export interface ResponseMessage {
2  kind: 'response';
3  id: string;
4  ok: boolean;
5  result?: unknown;
6  error?: string;
7}
  • ResponseMessage definieert de structuur van antwoordberichten die van de worker naar de client worden teruggestuurd.
1export interface BroadcastMessage {
2  kind: 'broadcast';
3  channel: string;
4  payload: unknown;
5  from: string;
6}
  • BroadcastMessage definieert de structuur van broadcastberichten die de worker naar andere clients stuurt.
1export type WorkerInMessage =
2  | RequestMessage
3  | { kind: 'heartbeat'; from: string }
4  | { kind: 'bye'; from: string };
  • WorkerInMessage is een type dat alle berichten vertegenwoordigt die de worker ontvangt, zoals verzoeken, heartbeats en meldingen over het verbreken van de verbinding.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
  • WorkerOutMessage is een type dat antwoord- of broadcastberichten vertegenwoordigt die de worker naar de client stuurt.
1export const randomId = () => Math.random().toString(36).slice(2);
  • randomId is een functie die een willekeurige alfanumerieke string genereert voor gebruik als bericht-ID's en dergelijke.

De Shared Worker implementeren

Registreer in shared-worker.ts tabbladen die via de onconnect-gebeurtenis verbinden en verwerk berichten.

1// shared-worker.ts
2/// <reference lib="webworker" />
  • Deze richtlijn geeft TypeScript de opdracht typedefinities voor Web Workers te laden.
1import {
2  WorkerInMessage,
3  WorkerOutMessage,
4  RequestMessage,
5  ResponseMessage,
6} from './worker-protocol.js';
  • Importeert de typedefinities die voor worker-communicatie worden gebruikt.
1export default {};
2declare const self: SharedWorkerGlobalScope;
  • Verklaart expliciet dat self de globale scope van de Shared Worker is.
1type Client = {
2  id: string;
3  port: MessagePort;
4  lastBeat: number;
5};
  • Client is een type dat de identificator, de communicatiepoort en de tijdstempel van de laatste heartbeat van elke client vertegenwoordigt.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
  • Beheert de lijst met verbonden clients, een key-value-opslag, de lockstatus en time-outs.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2  port.postMessage(msg);
3}
  • send is een hulpfunctie die een bericht naar de opgegeven poort stuurt.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2  return { kind: 'response', id: req.id, ok, result, error };
3}
  • respond genereert een antwoordbericht voor een verzoek.
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 stuurt een bericht op een opgegeven kanaal naar alle clients.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
  • handleRequest verwerkt inkomende verzoeken per type en retourneert de resultaten aan de client.
 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 deze code worden, afhankelijk van het type ontvangen verzoek, het verzenden en ontvangen van berichten, het ophalen en opslaan van data en het broadcasten afgehandeld.
 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      }
  • Deze code implementeert het proces waarmee een client een lock voor de opgegeven sleutel kan verkrijgen. Als de lock nog niet bezet is, wordt deze onmiddellijk verkregen; als dezelfde client deze opnieuw aanvraagt, wordt het verzoek ook als geslaagd beschouwd. Als een andere client de lock al in bezit heeft, wordt er elke 25 milliseconden opnieuw geprobeerd totdat de lock wordt vrijgegeven, en als de opgegeven time-out (standaard 5 seconden) wordt overschreden, wordt er met een foutmelding geantwoord.
 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}
  • Deze code geeft de door de client vastgehouden lock vrij en retourneert een foutrespons als de client geen toestemming heeft of de actie onbekend is.
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 parseert berichten die van clients worden ontvangen en handelt verzoeken en heartbeats af.
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 verwijdert losgekoppelde clients uit het register en de lockstatus.
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);
  • Gebruikt setInterval om periodiek de heartbeats van alle clients te controleren en verbindingen die zijn verlopen op te schonen.
 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 wordt aangeroepen wanneer een nieuw tabblad of een nieuwe pagina verbinding maakt met de Shared Worker, waarbij de client wordt geregistreerd en de communicatie start.

  • In dit bestand zijn de fundamentele mechanismen van een Shared Worker geïmplementeerd die gedeeld statusbeheer en communicatie over meerdere browsertabbladen mogelijk maken.

Client-wrapper (RPC)

Maak vervolgens een Promise-gebaseerde RPC-client.

1// shared-worker-client.ts
2import {
3  RequestAction,
4  RequestMessage,
5  WorkerOutMessage,
6  randomId
7} from './worker-protocol.js';
  • Importeert de typedefinities en hulpfuncties die voor worker-communicatie worden gebruikt.
1export type BroadcastHandler = (msg: {
2  channel: string;
3  payload: unknown;
4  from: string
5}) => void;
  • Hier definiëren we het type van de callbackfunctie die wordt uitgevoerd wanneer een broadcastbericht wordt ontvangen.
1export class SharedWorkerClient {
  • SharedWorkerClient is een clientklasse die met een Shared Worker communiceert, verzoeken verstuurt en antwoorden afhandelt.
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  }>();
  • Deze variabelen zijn de worker-instantie, de communicatiepoort met de worker en een map die verzoeken bijhoudt die op antwoorden wachten.
1  private clientId = randomId();
2  private heartbeatTimer?: number;
3  private onBroadcast?: BroadcastHandler;
  • Deze variabelen bevatten de clientidentificator, de timer voor het verzenden van heartbeats en de handler voor het ontvangen van 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;
  • In de constructor initialiseert het de verbinding met de Shared Worker en zet het berichtlisteners en het verzenden van heartbeats op.
 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 ontvangt het berichten van de worker en handelt het antwoorden of broadcasts af.
1    this.heartbeatTimer = window.setInterval(() => {
2      this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3    }, 10_000);
  • Stuurt periodiek heartbeat-berichten om de verbinding in leven te houden.
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  }
  • Stuurt een verbrekingsmelding naar de worker voordat het venster wordt gesloten.
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  }
  • De methode request stuurt de opgegeven actie naar de worker en ontvangt het resultaat als een Promise.
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  }
  • Dit zijn hulpmethoden voor basale communicatietests en het ophalen van de huidige tijd.
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  }
  • Dit zijn methoden voor het opslaan en ophalen van sleutel-waardeparen.
1  broadcast(channel: string, payload: unknown) {
2    return this.request<boolean>({ type: 'broadcast', channel, payload });
3  }
  • Dit is een methode die broadcastberichten via de worker naar andere clients stuurt.
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}
  • Dit zijn methoden die locks verwerven en vrijgeven om wederzijdse uitsluiting over gedeelde hulpbronnen te bereiken.
  • In dit bestand is een client-API geïmplementeerd voor veilige, asynchrone communicatie van elk browsertabblad naar de Shared Worker.

Voorbeeldgebruik

In demo.ts gebruiken we de eerder aangemaakte klasse SharedWorkerClient en verifiëren we het gedrag ervan. Het voert achtereenvolgens een reeks functies uit, waaronder communicatietests, het lezen en schrijven van data, broadcasten en lock-afhandeling.

 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);
  • Deze code is een demo die een Shared Worker gebruikt om data en status over meerdere browsertabbladen te delen en te synchroniseren. Door berichtgebaseerde communicatie te gebruiken, kun je asynchrone berichten veilig en met losse koppeling uitwisselen, wat het beheer van communicatie tussen verschillende contexten vereenvoudigt. Daarnaast abstraheert het met RPC de communicatie met de worker in een intuïtieve, methode-aanroepachtige stijl, wat de onderhoudbaarheid en leesbaarheid verbetert.

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>

Ontwerp- en operationele overwegingen

Bij het ontwerpen en beheren helpt het om de volgende punten in gedachten te houden; zo bouw je een robuuster en beter uitbreidbaar systeem.

  • Je kunt een gediscrimineerde unie (getagde unie) gebruiken waarmee je kunt vertakken op kind of type.
  • Gebruik een correlatie-ID om verzoeken correct aan antwoorden te koppelen.
  • Heartbeats en automatische opschoning kunnen achtergelaten locks voorkomen.
  • Implementeer versiebeheer om toekomstige protocolwijzigingen flexibel op te vangen.
  • Het definiëren van duidelijke foutcodes maakt de afhandeling aan de UI-kant en het debuggen eenvoudiger.

Samenvatting

Een Shared Worker is een kernmechanisme voor het delen van data en status over meerdere browsertabbladen.

De hier geïntroduceerde structuur biedt typeveilige RPC-communicatie, liveness-monitoring via heartbeats en een vergrendelingsmechanisme, waardoor het een robuust ontwerp is dat zo in productie kan worden gebruikt.

Boven op dit mechanisme kun je ook de volgende toepassingen implementeren.

  • IndexedDB-toegang serialiseren
  • Integratie en delen van WebSocket-verbindingen
  • Een taakwachtrij opzetten over meerdere tabbladen
  • Begrenzen en het versturen van voortgangsmeldingen

Zoals je ziet maakt het benutten van een Shared Worker het mogelijk om data en verwerking veilig en efficiënt over meerdere browsertabbladen te delen.

Je kunt het bovenstaande artikel volgen met Visual Studio Code op ons YouTube-kanaal. Bekijk ook het YouTube-kanaal.

YouTube Video