`Shared Worker` i TypeScript

`Shared Worker` i TypeScript

Den här artikeln förklarar Shared Worker i TypeScript.

Vi förklarar i detalj hur Shared Workers fungerar och hur man använder dem i praktiken, med TypeScript-exempel.

YouTube Video

Shared Worker i TypeScript

Shared Worker är en enda worker-process som delas mellan flera flikar, fönster och iframes på samma ursprung. Med detta kan du hantera delat tillstånd och resurser över flera webbläsarflikar.

Till exempel kan du effektivt implementera en delad WebSocket-anslutning, cache- och köhantering synkroniserad mellan flikar samt ömsesidig uteslutning (mutex).

Till skillnad från en Dedicated Worker tar en Shared Worker emot flera MessagePort via onconnect-händelsen och kan multiplexa kommunikationen med flera klienter.

Fall där du bör välja en Shared Worker

I följande fall är det lämpligt att använda en Shared Worker.

  • När du behöver delat tillstånd eller ömsesidig uteslutning mellan flikar
  • När du vill dela en enda WebSocket-anslutning eller åtkomst till IndexedDB
  • När du behöver notifiera alla flikar (broadcast)
  • När du vill centralisera tung bearbetning för att spara resurser

Omvänt är andra angreppssätt mer lämpliga i följande fall.

  • När du behöver cachekontroll eller offline-stöd kan du använda en Service Worker.
  • För tung bearbetning som är begränsad till en enda flik kan du använda en Dedicated Worker.

Implementeringssteg för Shared Worker

Här implementerar vi följande steg för steg med TypeScript.

  • Typsäkert meddelandeprotokoll
  • Promise-baserad begäran/svar (RPC)
  • Utsändning till alla flikar
  • Hjärtslag och rensning av klienter

Konfigurera miljön

Skapa konfigurationen för att kompilera varje källfil som använder en Shared Worker.

 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}
  • I tsconfig-src.json, aktivera DOM- och Web Worker-typdefinitioner så att koden kan kompileras säkert.

Definiera meddelandeprotokollet

Grunden för kommunikationen är ett typat meddelandekontrakt. Om du definierar detta i förväg blir efterföljande kommunikation säker och lätt att utöka.

 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 är en diskriminerad union (taggad union) som representerar typer av förfrågningar som skickas till workern och definierar operationer som ping, get och broadcast.
1export interface RequestMessage {
2  kind: 'request';
3  id: string;
4  from: string;
5  action: RequestAction;
6}
  • RequestMessage definierar strukturen för begärandemeddelanden som skickas från klienten till workern.
1export interface ResponseMessage {
2  kind: 'response';
3  id: string;
4  ok: boolean;
5  result?: unknown;
6  error?: string;
7}
  • ResponseMessage definierar strukturen för svarsmeddelanden som returneras från workern till klienten.
1export interface BroadcastMessage {
2  kind: 'broadcast';
3  channel: string;
4  payload: unknown;
5  from: string;
6}
  • BroadcastMessage definierar strukturen för broadcast-meddelanden som workern skickar till andra klienter.
1export type WorkerInMessage =
2  | RequestMessage
3  | { kind: 'heartbeat'; from: string }
4  | { kind: 'bye'; from: string };
  • WorkerInMessage är en typ som representerar alla meddelanden som workern tar emot, såsom begäranden, heartbeat-signaler och frånkopplingsnotiser.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
  • WorkerOutMessage är en typ som representerar svars- eller broadcast-meddelanden som workern skickar till klienten.
1export const randomId = () => Math.random().toString(36).slice(2);
  • randomId är en funktion som genererar en slumpmässig alfanumerisk sträng för meddelande-ID och liknande.

Implementera Shared Worker

I shared-worker.ts registrerar du flikar som ansluter via onconnect-händelsen och hanterar meddelanden.

1// shared-worker.ts
2/// <reference lib="webworker" />
  • Detta direktiv instruerar TypeScript att läsa in typdefinitioner för Web Workers.
1import {
2  WorkerInMessage,
3  WorkerOutMessage,
4  RequestMessage,
5  ResponseMessage,
6} from './worker-protocol.js';
  • Importerar typdefinitionerna som används för kommunikation med workern.
1export default {};
2declare const self: SharedWorkerGlobalScope;
  • Deklarerar uttryckligen att self är det globala omfånget för Shared Worker.
1type Client = {
2  id: string;
3  port: MessagePort;
4  lastBeat: number;
5};
  • Client är en typ som representerar varje klients identifierare, kommunikationsport och senaste heartbeat-tidsstämpel.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
  • Hanterar listan över anslutna klienter, ett nyckel-värde-lager, låstillstånd och timeout-tider.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2  port.postMessage(msg);
3}
  • send är en hjälpfunktion som skickar ett meddelande till den angivna porten.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2  return { kind: 'response', id: req.id, ok, result, error };
3}
  • respond genererar ett svarsmeddelande för en begäran.
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 skickar ett meddelande på en angiven kanal till alla klienter.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
  • handleRequest bearbetar inkommande begäranden efter typ och returnerar resultaten till klienten.
 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;
  • I den här koden hanteras, beroende på vilken typ av begäran som tas emot, skickande och mottagande av meddelanden, hämtning och sparande av data samt utsändning (broadcast).
 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      }
  • Den här koden implementerar processen för att en klient ska kunna ta ett lås för den angivna nyckeln. Om låset inte redan är taget tas det omedelbart; om samma klient begär det igen betraktas begäran också som lyckad. Om en annan klient redan har låset görs nya försök var 25:e millisekund tills låset släpps, och om den angivna tidsgränsen (standard 5 sekunder) överskrids returneras ett fel.
 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}
  • Den här koden frigör låset som hålls av klienten och returnerar ett felsvar om klienten saknar behörighet eller åtgärden är okänd.
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 tolkar meddelanden som tas emot från klienter och hanterar begäranden och heartbeat-signaler.
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 tar bort frånkopplade klienter från registret och låstillståndet.
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);
  • Använder setInterval för att periodiskt kontrollera alla klienters heartbeat-signaler och städa upp anslutningar som har gått i timeout.
 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 anropas när en ny flik eller sida ansluter till Shared Worker, registrerar klienten och startar kommunikationen.

  • I den här filen implementeras de grundläggande mekanismerna i en Shared Worker som möjliggör delad tillståndshantering och kommunikation mellan flera webbläsarflikar.

Klientomslag (RPC)

Skapa sedan en Promise-baserad RPC-klient.

1// shared-worker-client.ts
2import {
3  RequestAction,
4  RequestMessage,
5  WorkerOutMessage,
6  randomId
7} from './worker-protocol.js';
  • Importerar typdefinitionerna och hjälpfunktionerna som används för kommunikation med workern.
1export type BroadcastHandler = (msg: {
2  channel: string;
3  payload: unknown;
4  from: string
5}) => void;
  • Här definierar vi typen för återanropsfunktionen som körs när ett broadcast-meddelande tas emot.
1export class SharedWorkerClient {
  • SharedWorkerClient är en klientklass som kommunicerar med en Shared Worker, skickar begäranden och hanterar svar.
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  }>();
  • Dessa variabler är worker-instansen, kommunikationsporten mot workern och en map som spårar begäranden som väntar på svar.
1  private clientId = randomId();
2  private heartbeatTimer?: number;
3  private onBroadcast?: BroadcastHandler;
  • Dessa variabler håller klientens identifierare, timern för att skicka heartbeats och hanteraren för mottagning av broadcast.
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;
  • I konstruktorn initierar den anslutningen till Shared Worker och sätter upp meddelandelyssnare och heartbeat-sändning.
 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();
  • Här tar den emot meddelanden från workern och hanterar svar eller broadcast-meddelanden.
1    this.heartbeatTimer = window.setInterval(() => {
2      this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3    }, 10_000);
  • Skickar heartbeat-meddelanden periodiskt för att hålla anslutningen vid liv.
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  }
  • Skickar en frånkopplingsnotis till workern innan fönstret stängs.
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  }
  • Metoden request skickar den angivna åtgärden till workern och tar emot resultatet som ett 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  }
  • Detta är hjälpmetoder för grundläggande kommunikationstester och hämtning av aktuell tid.
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  }
  • Detta är metoder för att lagra och hämta nyckel-värde-par.
1  broadcast(channel: string, payload: unknown) {
2    return this.request<boolean>({ type: 'broadcast', channel, payload });
3  }
  • Detta är en metod som skickar broadcast-meddelanden till andra klienter via workern.
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}
  • Detta är metoder som tar och släpper lås för att uppnå ömsesidig uteslutning över delade resurser.
  • I den här filen implementeras ett klient-API för säker, asynkron kommunikation från varje webbläsarflik till Shared Workern.

Exempel på användning

I demo.ts använder vi klassen SharedWorkerClient som skapades tidigare och verifierar dess beteende. Den kör sekventiellt en serie funktioner, inklusive kommunikationstester, läsning och skrivning av data, utsändning (broadcast) samt låshantering.

 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);
  • Den här koden är en demo som använder en Shared Worker för att dela och synkronisera data och tillstånd mellan flera webbläsarflikar. Genom att använda meddelandebaserad kommunikation kan du på ett säkert sätt utbyta asynkrona meddelanden med lös koppling, vilket gör det lättare att hantera kommunikationen mellan olika kontexter. Dessutom abstraherar användningen av RPC kommunikationen med workern på ett intuitivt, metodanropsliknande sätt, vilket förbättrar underhållbarhet och läsbarhet.

Testa i 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>

Design- och driftöverväganden

Vid utformning och drift hjälper följande punkter dig att bygga ett mer robust och utökningsbart system.

  • Du kan använda en diskriminerad union (taggad union) som gör att du kan grena på kind eller type.
  • Använd ett korrelations-ID för att korrekt matcha begäranden med svar.
  • Heartbeats och automatisk upprensning kan förhindra övergivna lås.
  • Implementera versionshantering för att flexibelt kunna hantera framtida protokolländringar.
  • Att definiera tydliga felkoder gör hantering och felsökning på UI-sidan enklare.

Sammanfattning

En Shared Worker är en kärnmekanism för att dela data och tillstånd mellan flera webbläsarflikar.

Strukturen som introduceras här ger typsäker RPC-kommunikation, övervakning av aktivitet via heartbeats och en låsmekanism, vilket gör den till en robust design som kan användas som den är i produktion.

Ovanpå denna mekanism kan du också implementera följande tillämpningar.

  • Serialisera åtkomst till IndexedDB
  • Integrering och delning av WebSocket-anslutningar
  • Att bygga en jobbkö över flera flikar
  • Throttling och leverans av förloppsnotiser

Som du ser gör användningen av en Shared Worker det möjligt att dela data och bearbetning säkert och effektivt över flera flikar.

Du kan följa med i artikeln ovan med hjälp av Visual Studio Code på vår YouTube-kanal. Vänligen kolla även in YouTube-kanalen.

YouTube Video