`Shared Worker` i TypeScript

`Shared Worker` i TypeScript

Denne artikkelen forklarer Shared Worker i TypeScript.

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

YouTube Video

Shared Worker i TypeScript

Shared Worker er en én enkelt worker-prosess som deles på tvers av flere faner, vinduer og iframes på samme origin. Ved å bruke dette kan du håndtere delt tilstand og ressurser på tvers av flere nettleserfaner.

For eksempel kan du effektivt implementere en delt WebSocket-tilkobling, cache- og købehandling synkronisert på tvers av faner, og gjensidig utelukkelse.

I motsetning til en Dedicated Worker mottar en Shared Worker flere MessagePort-er via onconnect-hendelsen og kan multiplekse kommunikasjonen med flere klienter.

Tilfeller der du bør velge en Shared Worker

I følgende tilfeller er det hensiktsmessig å bruke en Shared Worker.

  • Når du trenger delt tilstand eller gjensidig utelukkelse på tvers av faner
  • Når du vil dele én enkelt WebSocket-tilkobling eller tilgang til IndexedDB
  • Når du må varsle alle faner (kringkasting)
  • Når du vil sentralisere tung prosessering for å spare ressurser

Omvendt er andre tilnærminger mer egnet i følgende tilfeller.

  • Når du trenger cache-kontroll eller offline-støtte, kan du bruke en Service Worker.
  • Ved tung behandling som er begrenset til én enkelt fane, kan du bruke en Dedicated Worker.

Implementeringstrinn for Shared Worker

Her implementerer vi følgende trinnvis med TypeScript.

  • Typesikker meldingsprotokoll
  • Promise-basert forespørsel/svar (RPC)
  • Kringkasting til alle faner
  • Heartbeat og opprydding av klienter

Miljøoppsett

Lag konfigurasjonen for å kompilere hver kildefil som bruker 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 aktiverer du DOM- og Web Worker-typedefinisjoner slik at koden kan kompileres trygt.

Definere meldingsprotokollen

Grunnlaget for kommunikasjon er en typesatt meldingskontrakt. Å definere dette på forhånd gjør videre kommunikasjon trygg og lett å utvide.

 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 diskriminert union (tagget union) som representerer typene forespørsler som sendes til workeren og definerer operasjoner som ping, get og broadcast.
1export interface RequestMessage {
2  kind: 'request';
3  id: string;
4  from: string;
5  action: RequestAction;
6}
  • RequestMessage definerer strukturen til forespørselsmeldinger som sendes fra klienten til workeren.
1export interface ResponseMessage {
2  kind: 'response';
3  id: string;
4  ok: boolean;
5  result?: unknown;
6  error?: string;
7}
  • ResponseMessage definerer strukturen til svarmeldinger som returneres fra workeren til klienten.
1export interface BroadcastMessage {
2  kind: 'broadcast';
3  channel: string;
4  payload: unknown;
5  from: string;
6}
  • BroadcastMessage definerer strukturen til broadcast-meldinger som workeren sender til andre klienter.
1export type WorkerInMessage =
2  | RequestMessage
3  | { kind: 'heartbeat'; from: string }
4  | { kind: 'bye'; from: string };
  • WorkerInMessage er en type som representerer alle meldinger workeren mottar, som forespørsler, hjerteslag og frakoblingsvarsler.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
  • WorkerOutMessage er en type som representerer svar- eller broadcast-meldinger som workeren sender til klienten.
1export const randomId = () => Math.random().toString(36).slice(2);
  • randomId er en funksjon som genererer en tilfeldig alfanumerisk streng som kan brukes til meldings-ID-er og lignende.

Implementere Shared Worker

I shared-worker.ts registrerer du faner som kobler til via onconnect-hendelsen og håndterer meldinger.

1// shared-worker.ts
2/// <reference lib="webworker" />
  • Dette direktivet instruerer TypeScript om å laste inn typedefinisjoner for Web Workers.
1import {
2  WorkerInMessage,
3  WorkerOutMessage,
4  RequestMessage,
5  ResponseMessage,
6} from './worker-protocol.js';
  • Importerer typedefinisjonene som brukes for kommunikasjon med workeren.
1export default {};
2declare const self: SharedWorkerGlobalScope;
  • Erklærer eksplisitt at self er det globale scope-et til Shared Worker.
1type Client = {
2  id: string;
3  port: MessagePort;
4  lastBeat: number;
5};
  • Client er en type som representerer hver klients identifikator, kommunikasjonsport og tidsstempel for siste hjerteslag.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
  • Administrerer listen over tilkoblede klienter, et nøkkel-verdi-lager, låsestatus og tidsavbruddsintervaller.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2  port.postMessage(msg);
3}
  • send er en hjelpefunksjon som sender en melding til angitt 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 svarmelding for en forespørsel.
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 melding på en angitt kanal til alle klienter.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
  • handleRequest behandler innkommende forespørsler etter type og returnerer resultatene 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 koden håndteres, avhengig av typen forespørsel som mottas, sending og mottak av meldinger, henting og lagring av data, samt kringkasting.
 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 koden implementerer prosessen for at en klient skal ta en lås for den angitte nøkkelen. Hvis låsen ikke allerede holdes, tas den umiddelbart; hvis samme klient ber om den igjen, behandles forespørselen også som vellykket. Hvis en annen klient allerede holder låsen, forsøkes det på nytt hvert 25. millisekund til låsen frigjøres, og hvis den angitte tidsgrensen (standard 5 sekunder) overskrides, returneres en feil.
 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 koden frigjør låsen som klienten holder, og returnerer et feilsvar dersom klienten mangler tillatelse eller handlingen er ukjent.
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 meldinger mottatt fra klienter og håndterer forespørsler og hjerteslag.
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 frakoblede klienter fra registeret og låsestatusen.
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);
  • Bruker setInterval til å sjekke alle klienters hjerteslag med jevne mellomrom og rydde opp i tilkoblinger som har fått tidsavbrudd.
 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 kalles når en ny fane eller side kobler seg til Shared Worker, registrerer klienten og starter kommunikasjonen.

  • I denne filen implementeres de grunnleggende mekanismene i en Shared Worker som muliggjør delt tilstandshåndtering og kommunikasjon på tvers av flere nettleserfaner.

Klient-innpakning (RPC)

Deretter oppretter du en Promise-basert RPC-klient.

1// shared-worker-client.ts
2import {
3  RequestAction,
4  RequestMessage,
5  WorkerOutMessage,
6  randomId
7} from './worker-protocol.js';
  • Importerer typedefinisjonene og hjelpefunksjonene som brukes for kommunikasjon med workeren.
1export type BroadcastHandler = (msg: {
2  channel: string;
3  payload: unknown;
4  from: string
5}) => void;
  • Her definerer vi typen for tilbakekallsfunksjonen som kjøres når en broadcast-melding mottas.
1export class SharedWorkerClient {
  • SharedWorkerClient er en klientklasse som kommuniserer med en Shared Worker, sender forespørsler 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 variablene er worker-instansen, kommunikasjonsporten mot workeren og en Map som sporer forespørsler som venter på svar.
1  private clientId = randomId();
2  private heartbeatTimer?: number;
3  private onBroadcast?: BroadcastHandler;
  • Disse variablene holder klientidentifikatoren, timeren for å sende hjerteslag og handleren for mottak 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 konstruktøren initialiseres tilkoblingen til Shared Worker, og det settes opp meldingslyttere og sending av hjerteslag.
 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 mottar den meldinger fra workeren og håndterer svar eller broadcast.
1    this.heartbeatTimer = window.setInterval(() => {
2      this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3    }, 10_000);
  • Sender hjerteslag jevnlig for å holde tilkoblingen 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 et frakoblingsvarsel til workeren 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 angitte handlingen til workeren og mottar 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 hjelpemetoder for grunnleggende kommunikasjonstester og for å hente gjeldende 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 for å lagre og hente nøkkel-verdi-par.
1  broadcast(channel: string, payload: unknown) {
2    return this.request<boolean>({ type: 'broadcast', channel, payload });
3  }
  • Dette er en metode som sender broadcast-meldinger til andre klienter via workeren.
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 som tar og frigir låser for å oppnå gjensidig utelukkelse over delte ressurser.
  • I denne filen implementeres et klient-API for sikker, asynkron kommunikasjon fra hver nettleserfane til Shared Worker-en.

Eksempel på bruk

I demo.ts bruker vi klassen SharedWorkerClient som ble opprettet tidligere og verifiserer oppførselen dens. Den kjører sekvensielt en rekke funksjoner, inkludert kommunikasjonstester, lesing og skriving av data, kringkasting og låshå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 koden er en demo som bruker en Shared Worker til å dele og synkronisere data og tilstand på tvers av flere nettleserfaner. Ved å bruke meldingsbasert kommunikasjon kan du utveksle asynkrone meldinger trygt med løs kobling, noe som gjør det enklere å håndtere kommunikasjon mellom ulike kontekster. I tillegg abstraherer RPC kommunikasjonen med workeren i en intuitiv, metodekall-lignende stil, noe som forbedrer vedlikeholdbarhet og lesbarhet.

Testing 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 driftsmessige hensyn

Ved design og drift vil det å ha følgende punkter i bakhodet hjelpe deg å bygge et mer robust og utvidbart system.

  • Du kan bruke en diskriminert union (tagget union) som lar deg forgrene basert på kind eller type.
  • Bruk en correlation ID for å matche forespørsler og svar korrekt.
  • Hjerteslag og automatisk opprydding kan forhindre etterlatte låser.
  • Implementer versjonering for fleksibelt å imøtekomme fremtidige protokollendringer.
  • Å definere tydelige feilkoder gjør håndtering og feilsøking på UI-siden enklere.

Sammendrag

En Shared Worker er en kjernemekanisme for å dele data og tilstand på tvers av flere nettleserfaner.

Strukturen som er introdusert her, gir typesikker RPC-kommunikasjon, liveness-overvåking via hjerteslag og en låsemekanisme, noe som gjør det til et robust design som kan brukes som det er i produksjon.

Med dette som grunnlag kan du også implementere følgende applikasjoner.

  • Serialisering av tilgang til IndexedDB
  • Integrasjon og deling av WebSocket-tilkoblinger
  • Bygging av en jobbkø på tvers av flere faner
  • Struping og levering av fremdriftsvarsler

Som du ser, gjør bruk av en Shared Worker det mulig å dele data og behandling sikkert og effektivt på tvers av flere faner.

Du kan følge med på artikkelen ovenfor ved å bruke Visual Studio Code på vår YouTube-kanal. Vennligst sjekk ut YouTube-kanalen.

YouTube Video