`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 jakping
,get
ibroadcast
.
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 zakresemShared 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ę zShared 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ę zShared 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 jakoPromise
.
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
lubtype
. - 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.