`Shared Worker` i TypeScript

`Shared Worker` i TypeScript

Denne artikel forklarer Shared Worker i TypeScript.

Vi forklarer i detaljer, hvordan Shared Workers fungerer, og hvordan de bruges i praksis, med TypeScript-kodeeksempler.

YouTube Video

Shared Worker i TypeScript

Shared Worker er en en enkelt worker-proces, der deles på tværs af flere faner, vinduer og iframes på samme origin. Ved at bruge dette kan du håndtere delt tilstand og delte ressourcer på tværs af flere browserfaner.

For eksempel kan du effektivt implementere en delt WebSocket-forbindelse, cache- og købehandling synkroniseret på tværs af faner, og gensidig udelukkelse.

I modsætning til en Dedicated Worker modtager en Shared Worker flere MessagePort-porte via onconnect-eventet og kan multiplekse kommunikationen med flere klienter.

Tilfælde hvor du bør vælge en Shared Worker

I følgende tilfælde er det passende at bruge en Shared Worker.

  • Når du har brug for delt tilstand eller gensidig udelukkelse på tværs af faner
  • Når du vil dele en enkelt WebSocket-forbindelse eller adgang til IndexedDB
  • Når du skal give besked til alle faner (broadcast)
  • Når du vil centralisere tung behandling for at spare ressourcer

Omvendt er andre tilgange mere velegnede i følgende tilfælde.

  • Når du har brug for cachekontrol eller offline-understøttelse, kan du bruge en Service Worker.
  • Til tung behandling, der er begrænset til en enkelt fane, kan du bruge en Dedicated Worker.

Trin til implementering af Shared Worker

Her implementerer vi følgende trin for trin med TypeScript.

  • Typesikker beskedprotokol
  • Promise-baseret request/response (RPC)
  • Broadcast til alle faner
  • Heartbeat og oprydning af klienter

Miljøopsætning

Opret konfigurationen til at kompilere hver kildefil, der bruger 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 skal du aktivere DOM- og Web Worker-typedefinitioner, så koden kan kompileres sikkert.

Definering af beskedprotokollen

Grundlaget for kommunikationen er en typesikker beskedkontrakt. At definere dette på forhånd gør den efterfølgende kommunikation sikker og let at udvide.

 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 er en diskrimineret union (tagget union), der repræsenterer typerne af forespørgsler, der sendes til worker'en, og definerer operationer såsom ping, get og broadcast.
1export interface RequestMessage {
2  kind: 'request';
3  id: string;
4  from: string;
5  action: RequestAction;
6}
  • RequestMessage definerer strukturen af anmodningsmeddelelser, der sendes fra klienten til worker'en.
1export interface ResponseMessage {
2  kind: 'response';
3  id: string;
4  ok: boolean;
5  result?: unknown;
6  error?: string;
7}
  • ResponseMessage definerer strukturen af svarmeddelelser, der returneres fra worker'en til klienten.
1export interface BroadcastMessage {
2  kind: 'broadcast';
3  channel: string;
4  payload: unknown;
5  from: string;
6}
  • BroadcastMessage definerer strukturen af broadcast-meddelelser, som worker'en sender til andre klienter.
1export type WorkerInMessage =
2  | RequestMessage
3  | { kind: 'heartbeat'; from: string }
4  | { kind: 'bye'; from: string };
  • WorkerInMessage er en type, der repræsenterer alle meddelelser, som worker'en modtager, såsom anmodninger, heartbeats og afbrudsnotifikationer.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
  • WorkerOutMessage er en type, der repræsenterer svar- eller broadcast-meddelelser, som worker'en sender til klienten.
1export const randomId = () => Math.random().toString(36).slice(2);
  • randomId er en funktion, der genererer en tilfældig alfanumerisk streng til brug for meddelelses-ID'er og lignende.

Implementering af Shared Worker

I shared-worker.ts registrerer du faner, der forbinder via onconnect-eventet, og håndterer beskeder.

1// shared-worker.ts
2/// <reference lib="webworker" />
  • Dette direktiv instruerer TypeScript i at indlæse typedefinitioner for Web Workers.
1import {
2  WorkerInMessage,
3  WorkerOutMessage,
4  RequestMessage,
5  ResponseMessage,
6} from './worker-protocol.js';
  • Importerer typedefinitionerne, der bruges til kommunikation med worker'en.
1export default {};
2declare const self: SharedWorkerGlobalScope;
  • Erklærer eksplicit, at self er det globale scope for Shared Worker.
1type Client = {
2  id: string;
3  port: MessagePort;
4  lastBeat: number;
5};
  • Client er en type, der repræsenterer hver klients identifikator, kommunikationsport og seneste heartbeat-tidsstempel.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
  • Håndterer listen over tilsluttede klienter, et nøgle-værdi-lager, låsestatus og timeout-varigheder.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2  port.postMessage(msg);
3}
  • send er en hjælpefunktion, der sender en meddelelse til den angivne port.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2  return { kind: 'response', id: req.id, ok, result, error };
3}
  • respond genererer en svarmeddelelse for en anmodning.
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 sender en meddelelse på en angiven kanal til alle klienter.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
  • handleRequest behandler indkommende anmodninger efter type og returnerer resultaterne til 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 denne kode håndteres, afhængigt af typen af modtaget anmodning, afsendelse og modtagelse af beskeder, hentning og lagring af data samt broadcasting.
 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      }
  • Denne kode implementerer processen for, at en klient kan erhverve en lås til den angivne nøgle. Hvis låsen ikke allerede er optaget, erhverves den med det samme; hvis den samme klient anmoder om den igen, betragtes anmodningen også som vellykket. Hvis en anden klient allerede har låsen, prøver den igen hvert 25. millisekund, indtil låsen frigives, og hvis den angivne timeout (som standard 5 sekunder) overskrides, svarer den med en fejl.
 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}
  • Denne kode frigiver den lås, som klienten holder, og returnerer et fejlrespons, hvis klienten mangler tilladelse, eller handlingen er ukendt.
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 parser meddelelser modtaget fra klienter og håndterer anmodninger og 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 fjerner afbrudte klienter fra registret og låsestatus.
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);
  • Bruger setInterval til periodisk at tjekke alle klienters heartbeats og rydde op i forbindelser, der er timet ud.
 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 kaldes, når en ny fane eller side forbinder til Shared Worker, registrerer klienten og starter kommunikationen.

  • I denne fil implementeres de grundlæggende mekanismer i en Shared Worker, der muliggør delt tilstandsstyring og kommunikation på tværs af flere browserfaner.

Klient-wrapper (RPC)

Opret derefter en Promise-baseret RPC-klient.

1// shared-worker-client.ts
2import {
3  RequestAction,
4  RequestMessage,
5  WorkerOutMessage,
6  randomId
7} from './worker-protocol.js';
  • Importerer typedefinitionerne og hjælpefunktionerne, der bruges til kommunikation med worker'en.
1export type BroadcastHandler = (msg: {
2  channel: string;
3  payload: unknown;
4  from: string
5}) => void;
  • Her definerer vi typen af callback-funktionen, der kører, når en broadcast-meddelelse modtages.
1export class SharedWorkerClient {
  • SharedWorkerClient er en klientklasse, der kommunikerer med en Shared Worker, sender anmodninger og håndterer 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  }>();
  • Disse variabler er worker-instansen, kommunikationsporten til worker'en og et map, der sporer anmodninger, der afventer svar.
1  private clientId = randomId();
2  private heartbeatTimer?: number;
3  private onBroadcast?: BroadcastHandler;
  • Disse variabler indeholder klientidentifikatoren, timeren til at sende heartbeats og håndtereren til modtagelse af 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;
  • I konstruktøren initialiserer den forbindelsen til Shared Worker og opsætter meddelelseslyttere og afsendelse af heartbeats.
 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();
  • Her modtager den meddelelser fra worker'en og håndterer svar eller broadcasts.
1    this.heartbeatTimer = window.setInterval(() => {
2      this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3    }, 10_000);
  • Sender heartbeat-meddelelser periodisk for at holde forbindelsen i live.
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  }
  • Sender en afbrydelsesnotifikation til worker'en, før vinduet lukkes.
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 sender den angivne handling til worker'en og modtager resultatet som et 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  }
  • Dette er hjælpemetoder til basale kommunikationstests og hentning af den aktuelle 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  }
  • Dette er metoder til lagring og hentning af nøgle-værdi-par.
1  broadcast(channel: string, payload: unknown) {
2    return this.request<boolean>({ type: 'broadcast', channel, payload });
3  }
  • Dette er en metode, der sender broadcast-meddelelser til andre klienter via worker'en.
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}
  • Dette er metoder, der erhverver og frigiver låse for at opnå gensidig udelukkelse over delte ressourcer.
  • I denne fil implementeres et klient-API til sikker, asynkron kommunikation fra hver browserfane til Shared Worker'en.

Brugseksempel

I demo.ts bruger vi den tidligere oprettede klasse SharedWorkerClient og verificerer dens adfærd. Den udfører sekventielt en række funktioner, herunder kommunikationstests, læsning og skrivning af data, broadcasting og låsehåndtering.

 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);
  • Denne kode er en demo, der bruger en Shared Worker til at dele og synkronisere data og tilstand på tværs af flere browserfaner. Ved at bruge beskedbaseret kommunikation kan du udveksle asynkrone beskeder sikkert med løs kobling, hvilket gør det lettere at styre kommunikationen mellem forskellige kontekster. Derudover abstraherer brugen af RPC kommunikationen med worker'en i en intuitiv, metodekaldslignende stil, hvilket forbedrer vedligeholdbarhed og læsbarhed.

Test 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- og driftsmæssige overvejelser

Når du designer og driver systemet, vil det at have følgende punkter i tankerne hjælpe dig med at bygge et mere robust og udvidbart system.

  • Du kan anvende en diskrimineret union (tagget union), der muliggør forgrening baseret på kind eller type.
  • Brug et korrelations-id til korrekt at matche anmodninger med svar.
  • Heartbeats og automatisk oprydning kan forhindre efterladte låse.
  • Implementér versionering for fleksibelt at imødekomme fremtidige ændringer i protokollen.
  • At definere klare fejlkoder gør håndtering og fejlfinding på UI-siden lettere.

Sammendrag

En Shared Worker er en kernemekanisme til at dele data og tilstand på tværs af flere browserfaner.

Den struktur, der er introduceret her, giver typesikker RPC-kommunikation, liveness-overvågning via heartbeats og en låsemekanisme, hvilket gør det til et robust design, der kan bruges som det er i produktion.

Oven på denne mekanisme kan du også implementere følgende applikationer.

  • Serialisering af adgang til IndexedDB
  • Integration og deling af WebSocket-forbindelser
  • Opbygning af en jobkø på tværs af flere faner
  • Begrænsning (throttling) og levering af fremdriftsnotifikationer

Som du kan se, gør udnyttelsen af en Shared Worker det muligt at dele data og behandling sikkert og effektivt på tværs af flere faner.

Du kan følge med i ovenstående artikel ved hjælp af Visual Studio Code på vores YouTube-kanal. Husk også at tjekke YouTube-kanalen.

YouTube Video