`Shared Worker` w TypeScript

`Shared Worker` w TypeScript

Ten artykuł wyjaśnia Shared Worker w TypeScript.

Szczegółowo wyjaśnimy, jak działają Shared Workery i jak używać ich w praktyce, z przykładami kodu w TypeScript.

YouTube Video

Shared Worker w TypeScript

Shared Worker to pojedynczy proces workera współdzielony między wieloma kartami, oknami i iframe’ami w tym samym originie. Dzięki temu możesz obsługiwać współdzielony stan i zasoby między wieloma kartami przeglądarki.

Na przykład możesz efektywnie zaimplementować współdzielone połączenie WebSocket, cache i przetwarzanie kolejek zsynchronizowane między kartami oraz wzajemne wykluczanie.

W odróżnieniu od Dedicated Worker, Shared Worker otrzymuje wiele MessagePortów poprzez zdarzenie onconnect i może multipleksować komunikację z wieloma klientami.

Przypadki, w których warto wybrać Shared Worker

W poniższych sytuacjach użycie Shared Worker jest odpowiednie.

  • Gdy potrzebujesz współdzielonego stanu lub wzajemnego wykluczania między kartami
  • Gdy chcesz współdzielić jedno połączenie WebSocket lub dostęp do IndexedDB
  • Gdy musisz powiadomić wszystkie karty (broadcast)
  • Gdy chcesz scentralizować ciężkie przetwarzanie, aby oszczędzać zasoby

Z kolei w poniższych przypadkach bardziej odpowiednie są inne podejścia.

  • Gdy potrzebujesz kontroli pamięci podręcznej lub wsparcia trybu offline, możesz użyć Service Worker.
  • Do ciężkich obliczeń ograniczonych do jednej karty możesz użyć Dedicated Worker.

Kroki implementacji dla Shared Worker

Tutaj, krok po kroku, zaimplementujemy poniższe z użyciem TypeScriptu.

  • Protokół komunikatów z bezpieczeństwem typów
  • Model żądanie/odpowiedź (RPC) oparty na obietnicach (Promise)
  • Rozgłaszanie do wszystkich kart
  • Heartbeat i czyszczenie klientów

Konfiguracja środowiska

Utwórz konfigurację do kompilacji każdego pliku źródłowego, który używa 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}
  • W tsconfig-src.json włącz definicje typów DOM i Web Workera, aby kod mógł być bezpiecznie kompilowany.

Definiowanie protokołu komunikatów

Podstawą komunikacji jest typowany kontrakt komunikatów. Zdefiniowanie tego z góry sprawia, że dalsza komunikacja jest bezpieczna i łatwa do rozszerzania.

 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 jest unią dyskryminowaną (tagged union), która reprezentuje typy żądań wysyłanych do workera i definiuje operacje, takie jak ping, get i broadcast.
1export interface RequestMessage {
2  kind: 'request';
3  id: string;
4  from: string;
5  action: RequestAction;
6}
  • RequestMessage definiuje strukturę wiadomości żądania wysyłanych od klienta do workera.
1export interface ResponseMessage {
2  kind: 'response';
3  id: string;
4  ok: boolean;
5  result?: unknown;
6  error?: string;
7}
  • ResponseMessage definiuje strukturę wiadomości odpowiedzi zwracanych przez workera do klienta.
1export interface BroadcastMessage {
2  kind: 'broadcast';
3  channel: string;
4  payload: unknown;
5  from: string;
6}
  • BroadcastMessage definiuje strukturę wiadomości rozgłoszeniowych, które worker wysyła do innych klientów.
1export type WorkerInMessage =
2  | RequestMessage
3  | { kind: 'heartbeat'; from: string }
4  | { kind: 'bye'; from: string };
  • WorkerInMessage to typ reprezentujący wszystkie wiadomości, które otrzymuje worker, takie jak żądania, sygnały heartbeat oraz powiadomienia o rozłączeniu.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
  • WorkerOutMessage to typ reprezentujący wiadomości odpowiedzi lub rozgłoszeniowe, które worker wysyła do klienta.
1export const randomId = () => Math.random().toString(36).slice(2);
  • randomId to funkcja generująca losowy ciąg alfanumeryczny do użycia jako identyfikatory wiadomości itp.

Implementacja Shared Workera

W shared-worker.ts rejestruj karty łączące się przez zdarzenie onconnect i obsługuj komunikaty.

1// shared-worker.ts
2/// <reference lib="webworker" />
  • Ta dyrektywa nakazuje TypeScriptowi załadować definicje typów dla Web Workerów.
1import {
2  WorkerInMessage,
3  WorkerOutMessage,
4  RequestMessage,
5  ResponseMessage,
6} from './worker-protocol.js';
  • Importuje definicje typów używane do komunikacji z workerem.
1export default {};
2declare const self: SharedWorkerGlobalScope;
  • Jawnie deklaruje, że self jest globalnym zakresem Shared Worker.
1type Client = {
2  id: string;
3  port: MessagePort;
4  lastBeat: number;
5};
  • Client to typ reprezentujący identyfikator klienta, port komunikacyjny oraz znacznik czasu ostatniego heartbeat.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
  • Zarządza listą podłączonych klientów, magazynem klucz-wartość, stanem blokady oraz czasami przekroczenia czasu (timeoutami).
1function send(port: MessagePort, msg: WorkerOutMessage) {
2  port.postMessage(msg);
3}
  • send to funkcja pomocnicza, która wysyła wiadomość na wskazany port.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2  return { kind: 'response', id: req.id, ok, result, error };
3}
  • respond generuje wiadomość odpowiedzi na żądanie.
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 wysyła wiadomość na wskazanym kanale do wszystkich klientów.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
  • handleRequest przetwarza przychodzące żądania według typu i zwraca wyniki klientowi.
 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;
  • Kod ten, w zależności od rodzaju otrzymanego żądania, obsługuje wysyłanie i odbieranie wiadomości, pobieranie i zapisywanie danych oraz rozgłaszanie.
 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      }
  • Ten kod implementuje proces uzyskania przez klienta blokady dla określonego klucza. Jeśli blokada nie jest aktualnie utrzymywana, zostaje przejęta natychmiast; jeśli ten sam klient poprosi o nią ponownie, żądanie również zostanie uznane za pomyślne. Jeśli blokadę trzyma już inny klient, próba jest ponawiana co 25 milisekund aż do zwolnienia blokady, a w przypadku przekroczenia określonego limitu czasu (domyślnie 5 sekund) zwracany jest błąd.
 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}
  • Ten kod zwalnia blokadę utrzymywaną przez klienta i zwraca odpowiedź z błędem, jeśli klient nie ma uprawnień lub działanie jest nieznane.
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 parsuje wiadomości odebrane od klientów i obsługuje żądania oraz sygnały heartbeat.
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 usuwa rozłączonych klientów z rejestru i stanu blokad.
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);
  • Używa setInterval, aby okresowo sprawdzać sygnały heartbeat wszystkich klientów i czyścić połączenia, które przekroczyły limit czasu.
 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 jest wywoływane, gdy nowa karta lub strona łączy się z Shared Worker, rejestrując klienta i rozpoczynając komunikację.

  • W tym pliku zaimplementowano podstawowe mechanizmy Shared Worker, które umożliwiają współdzielone zarządzanie stanem i komunikację pomiędzy wieloma kartami przeglądarki.

Wrapper klienta (RPC)

Następnie utwórz klienta RPC opartego na obietnicach (Promise).

1// shared-worker-client.ts
2import {
3  RequestAction,
4  RequestMessage,
5  WorkerOutMessage,
6  randomId
7} from './worker-protocol.js';
  • Importuje definicje typów i funkcje pomocnicze używane do komunikacji z workerem.
1export type BroadcastHandler = (msg: {
2  channel: string;
3  payload: unknown;
4  from: string
5}) => void;
  • Tutaj definiujemy typ funkcji zwrotnej wywoływanej po odebraniu wiadomości rozgłoszeniowej.
1export class SharedWorkerClient {
  • SharedWorkerClient to klasa klienta komunikująca się z Shared Worker, wysyłająca żądania i obsługująca odpowiedzi.
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  }>();
  • Te zmienne to instancja workera, port komunikacyjny z workerem oraz mapa śledząca żądania oczekujące na odpowiedzi.
1  private clientId = randomId();
2  private heartbeatTimer?: number;
3  private onBroadcast?: BroadcastHandler;
  • Te zmienne przechowują identyfikator klienta, timer wysyłania heartbeat oraz obsługę odbioru wiadomości rozgłoszeniowych.
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;
  • W konstruktorze inicjalizuje połączenie z Shared Worker oraz konfiguruje nasłuchiwanie wiadomości i wysyłanie heartbeat.
 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();
  • Tutaj odbiera wiadomości od workera i obsługuje odpowiedzi lub rozgłoszenia.
1    this.heartbeatTimer = window.setInterval(() => {
2      this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3    }, 10_000);
  • Okresowo wysyła wiadomości heartbeat, aby utrzymać połączenie przy życiu.
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  }
  • Wysyła do workera powiadomienie o rozłączeniu przed zamknięciem okna.
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  }
  • Metoda request wysyła do workera określoną akcję i otrzymuje wynik jako 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  }
  • Są to metody pomocnicze do podstawowych testów komunikacji oraz pobierania bieżącego czasu.
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  }
  • Są to metody do zapisywania i odczytywania par klucz-wartość.
1  broadcast(channel: string, payload: unknown) {
2    return this.request<boolean>({ type: 'broadcast', channel, payload });
3  }
  • To metoda wysyłająca poprzez workera wiadomości rozgłoszeniowe do innych klientów.
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}
  • Są to metody, które uzyskują i zwalniają blokady, aby zapewnić wzajemne wykluczanie dostępu do współdzielonych zasobów.
  • W tym pliku zaimplementowano klienckie API do bezpiecznej, asynchronicznej komunikacji z każdej karty przeglądarki do Shared Workera.

Przykład użycia

W demo.ts używamy wcześniej utworzonej klasy SharedWorkerClient i weryfikujemy jej działanie. Wykonuje sekwencyjnie szereg funkcji, w tym testy komunikacji, odczyt i zapis danych, rozgłaszanie oraz obsługę blokad.

 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);
  • Ten kod to demo wykorzystujące Shared Worker do współdzielenia i synchronizacji danych oraz stanu pomiędzy wieloma kartami przeglądarki. Używając komunikacji opartej na wiadomościach, możesz bezpiecznie wymieniać asynchroniczne wiadomości przy luźnym powiązaniu, co ułatwia zarządzanie komunikacją między różnymi kontekstami. Dodatkowo, dzięki użyciu RPC, komunikacja z workerem jest abstrahowana w intuicyjnym, przypominającym wywołania metod stylu, co poprawia łatwość utrzymania i czytelność.

Testowanie w 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>

Uwagi projektowe i operacyjne

Podczas projektowania i eksploatacji pamiętanie o poniższych punktach pomoże zbudować bardziej niezawodny i rozszerzalny system.

  • Możesz zastosować unię dyskryminowaną (tagged union), która pozwala rozgałęziać się na podstawie pola kind lub type.
  • Używaj correlation ID, aby poprawnie dopasowywać żądania do odpowiedzi.
  • Heartbeaty i automatyczne czyszczenie mogą zapobiegać pozostawionym blokadom.
  • Zaimplementuj wersjonowanie, aby elastycznie uwzględniać przyszłe zmiany protokołu.
  • Zdefiniowanie jasnych kodów błędów ułatwia obsługę po stronie interfejsu użytkownika oraz debugowanie.

Podsumowanie

Shared Worker to kluczowy mechanizm do współdzielenia danych i stanu pomiędzy wieloma kartami przeglądarki.

Przedstawiona tutaj struktura zapewnia bezpieczną pod względem typów komunikację RPC, monitorowanie żywotności poprzez heartbeaty oraz mechanizm blokad, co czyni ją solidnym projektem, który można używać w produkcji bez zmian.

Na bazie tego mechanizmu możesz również zaimplementować następujące zastosowania.

  • Serializowanie dostępu do IndexedDB
  • Integrację i współdzielenie połączeń WebSocket
  • Budowanie kolejki zadań w wielu kartach
  • Ograniczanie przepustowości (throttling) i dostarczanie powiadomień o postępie

Jak widać, wykorzystanie Shared Worker umożliwia bezpieczne i wydajne współdzielenie danych oraz przetwarzania między wieloma kartami.

Możesz śledzić ten artykuł, korzystając z Visual Studio Code na naszym kanale YouTube. Proszę również sprawdzić nasz kanał YouTube.

YouTube Video