`Shared Worker` ב-TypeScript

`Shared Worker` ב-TypeScript

מאמר זה מסביר את Shared Worker ב-TypeScript.

נפרט כיצד Shared Workers פועלים וכיצד להשתמש בהם בפועל, עם דוגמאות קוד ב-TypeScript.

YouTube Video

Shared Worker ב-TypeScript

Shared Worker הוא תהליך עובד יחיד המשותף למספר לשוניות, חלונות ו-iframes באותו מקור (origin). באמצעותו ניתן לטפל במצב ומשאבים משותפים בין מספר לשוניות בדפדפן.

לדוגמה, ניתן לממש ביעילות חיבור WebSocket משותף, מטמון ותור עם עיבוד מסונכרן בין לשוניות, ו-הדרה הדדית (mutual exclusion).

בשונה מ-Dedicated Worker, Shared Worker מקבל מספר MessagePorts דרך האירוע onconnect ויכול לבצע ריבוב (multiplex) של תקשורת עם מספר לקוחות.

מקרים שבהם כדאי לבחור ב-Shared Worker

במקרים הבאים שימוש ב-Shared Worker הוא מתאים.

  • כאשר נדרש מצב משותף או הדרה הדדית בין לשוניות
  • כאשר רוצים לשתף חיבור WebSocket יחיד או גישה ל-IndexedDB
  • כאשר צריך להודיע לכל הלשוניות (שידור)
  • כאשר רוצים לרכז עיבוד כבד כדי לחסוך במשאבים

לעומת זאת, במקרים הבאים גישות אחרות מתאימות יותר.

  • כאשר נדרשת בקרת מטמון או תמיכה בעבודה לא מקוונת, ניתן להשתמש ב-Service Worker.
  • לעיבוד כבד שמוגבל ללשונית אחת, ניתן להשתמש ב-Dedicated Worker.

שלבי מימוש עבור Shared Worker

כאן נממש את הדברים הבאים צעד-אחר-צעד באמצעות TypeScript.

  • פרוטוקול הודעות עם בטיחות טיפוסים (Type-safe)
  • בקשה/תגובה (RPC) המבוססת על Promise
  • שידור לכל הלשוניות
  • Heartbeat וניקוי לקוחות

הגדרת סביבת העבודה

צרו את התצורה לקימפול כל קובץ מקור שמשתמש ב-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}
  • ב-tsconfig-src.json, הפעילו הגדרות טיפוסים (DOM ו-Web Worker) כדי שהקוד ייקומפל בבטחה.

הגדרת פרוטוקול ההודעות

הבסיס לתקשורת הוא חוזה הודעות בעל טיפוסים (typed). הגדרה מוקדמת של זה הופכת את התקשורת בהמשך לבטוחה וקלת-הרחבה.

 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 הוא איחוד מובחן (איחוד מתויג) שמייצג את סוגי הבקשות הנשלחות אל ה-worker ומגדיר פעולות כגון ping, get ו-broadcast.
1export interface RequestMessage {
2  kind: 'request';
3  id: string;
4  from: string;
5  action: RequestAction;
6}
  • RequestMessage מגדיר את המבנה של הודעות בקשה שנשלחות מהלקוח אל העובד.
1export interface ResponseMessage {
2  kind: 'response';
3  id: string;
4  ok: boolean;
5  result?: unknown;
6  error?: string;
7}
  • ResponseMessage מגדיר את המבנה של הודעות תגובה שהעובד מחזיר אל הלקוח.
1export interface BroadcastMessage {
2  kind: 'broadcast';
3  channel: string;
4  payload: unknown;
5  from: string;
6}
  • BroadcastMessage מגדיר את המבנה של הודעות שידור שהעובד שולח ללקוחות אחרים.
1export type WorkerInMessage =
2  | RequestMessage
3  | { kind: 'heartbeat'; from: string }
4  | { kind: 'bye'; from: string };
  • WorkerInMessage הוא טיפוס שמייצג את כל ההודעות שהעובד מקבל, כגון בקשות, פעימות לב והודעות ניתוק.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
  • WorkerOutMessage הוא טיפוס שמייצג הודעות תגובה או שידור שהעובד שולח ללקוח.
1export const randomId = () => Math.random().toString(36).slice(2);
  • randomId היא פונקציה שיוצרת מחרוזת אלפאנומרית אקראית לשימוש במזהי הודעות וכדומה.

מימוש ה-Shared Worker

ב-shared-worker.ts, רשמו לשוניות שמתחברות דרך האירוע onconnect וטפלו בהודעות.

1// shared-worker.ts
2/// <reference lib="webworker" />
  • הנחיה זו מורה ל-TypeScript לטעון הגדרות טיפוסים עבור Web Workers.
1import {
2  WorkerInMessage,
3  WorkerOutMessage,
4  RequestMessage,
5  ResponseMessage,
6} from './worker-protocol.js';
  • מייבא את הגדרות הטיפוסים המשמשות לתקשורת עם העובד.
1export default {};
2declare const self: SharedWorkerGlobalScope;
  • מכריז במפורש כי self הוא המרחב הגלובלי של ה-Shared Worker.
1type Client = {
2  id: string;
3  port: MessagePort;
4  lastBeat: number;
5};
  • Client הוא טיפוס שמייצג את מזהה הלקוח, פורט התקשורת, וחותמת הזמן של פעימת הלב האחרונה.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
  • מנהל את רשימת הלקוחות המחוברים, מאגר מפתח-ערך, מצב הנעילה וזמני פקיעה.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2  port.postMessage(msg);
3}
  • send היא פונקציית עזר ששולחת הודעה לפורט שצוין.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2  return { kind: 'response', id: req.id, ok, result, error };
3}
  • respond יוצרת הודעת תגובה לבקשה.
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 משדרת הודעה בערוץ מסוים לכל הלקוחות.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
  • handleRequest מעבדת בקשות נכנסות לפי סוג ומחזירה את התוצאות ללקוח.
 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;
  • בקוד זה, בהתאם לסוג הבקשה שמתקבלת, הקוד מטפל בשליחה וקבלה של הודעות, בשליפה ושמירה של נתונים, ובשידור.
 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      }
  • קוד זה מממש את התהליך שבו לקוח רוכש נעילה עבור המפתח שצוין. אם הנעילה אינה מוחזקת כבר, היא נרכשת מיד; אם אותו לקוח מבקש אותה שוב, גם בקשה זו תיחשב כמוצלחת. אם לקוח אחר כבר מחזיק בנעילה, נעשה ניסיון חוזר כל 25 מילישניות עד לשחרור הנעילה, ואם הזמן הקצוב שצוין (ברירת המחדל: 5 שניות) נחצה, מוחזרת שגיאה.
 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}
  • קוד זה משחרר את הנעילה המוחזקת על ידי הלקוח ומחזיר תגובת שגיאה אם ללקוח אין הרשאה או שהפעולה אינה מוכרת.
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 מפענחת הודעות שמתקבלות מלקוחות ומטפלת בבקשות ובפעימות לב.
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 מסירה לקוחות מנותקים מהרישום וממצב הנעילה.
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);
  • משתמשת ב-setInterval כדי לבדוק מעת לעת את פעימות הלב של כל הלקוחות ולנקות חיבורים שפג תוקפם.
 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 נקראת כאשר לשונית או דף חדשים מתחברים אל ה-Shared Worker, רושמת את הלקוח ומתחילה את התקשורת.

  • בכל הקובץ מיושמים המנגנונים הבסיסיים של Shared Worker המאפשרים ניהול מצב משותף ותקשורת בין מספר לשוניות דפדפן.

מעטפת לקוח (RPC)

לאחר מכן, צרו לקוח RPC המבוסס על Promise.

1// shared-worker-client.ts
2import {
3  RequestAction,
4  RequestMessage,
5  WorkerOutMessage,
6  randomId
7} from './worker-protocol.js';
  • מייבא את הגדרות הטיפוסים ופונקציות העזר המשמשות לתקשורת עם העובד.
1export type BroadcastHandler = (msg: {
2  channel: string;
3  payload: unknown;
4  from: string
5}) => void;
  • כאן אנו מגדירים את טיפוס פונקציית ה-callback שרצה כאשר מתקבלת הודעת שידור.
1export class SharedWorkerClient {
  • SharedWorkerClient היא מחלקת לקוח שמתקשרת עם Shared Worker, שולחת בקשות ומטפלת בתגובות.
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  }>();
  • משתנים אלה הם מופע העובד, פורט התקשורת עם העובד, ומפה שעוקבת אחר בקשות הממתינות לתגובות.
1  private clientId = randomId();
2  private heartbeatTimer?: number;
3  private onBroadcast?: BroadcastHandler;
  • משתנים אלה מחזיקים את מזהה הלקוח, את המתזמן לשליחת פעימות לב, ואת המטפל בקבלת שידורים.
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;
  • בבנאי הוא מאתחל את החיבור ל-Shared Worker ומגדיר מאזינים להודעות ושליחת פעימות לב.
 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();
  • כאן מתקבלות הודעות מהעובד ומטופלות תגובות או שידורים.
1    this.heartbeatTimer = window.setInterval(() => {
2      this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3    }, 10_000);
  • שולח הודעות פעימת לב באופן מחזורי כדי לשמור על חיות החיבור.
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  }
  • שולח התראת ניתוק לעובד לפני סגירת החלון.
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  }
  • השיטה request שולחת את הפעולה המבוקשת לעובד ומקבלת את התוצאה כ-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  }
  • אלו שיטות עזר לבדיקות תקשורת בסיסיות ולקבלת הזמן הנוכחי.
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  }
  • אלו שיטות לאחסון ושליפה של זוגות מפתח-ערך.
1  broadcast(channel: string, payload: unknown) {
2    return this.request<boolean>({ type: 'broadcast', channel, payload });
3  }
  • זו שיטה ששולחת הודעות שידור ללקוחות אחרים דרך העובד.
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}
  • אלו שיטות שרוכשות ומשחררות נעילות להשגת הדרה הדדית על משאבים משותפים.
  • בכל הקובץ ממומש API לקוח לתקשורת בטוחה וא-סינכרונית מכל לשונית בדפדפן אל ה-Shared Worker.

דוגמת שימוש

ב-demo.ts אנו משתמשים במחלקה SharedWorkerClient שנוצרה קודם לכן ומאמתים את התנהגותה. הוא מבצע ברצף סדרה של פונקציות, כולל בדיקות תקשורת, קריאה וכתיבה של נתונים, שידור וטיפול בנעילה.

 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);
  • קוד זה הוא הדגמה המשתמשת ב-Shared Worker לשיתוף וסנכרון נתונים ומצב בין מספר לשוניות דפדפן. באמצעות תקשורת מבוססת הודעות ניתן להחליף הודעות א-סינכרוניות בבטחה ובזיקה רופפת, מה שמקל על ניהול התקשורת בין הקשרים שונים. בנוסף, באמצעות RPC, התקשורת עם העובד מופשטת לסגנון אינטואיטיבי הדומה לקריאות מתודה, מה שמשפר תחזוקתיות וקריאות.

בדיקה ב-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>

שיקולי תכנון ותפעול

בעת תכנון והפעלה, שמירה על הנקודות הבאות תסייע לכם לבנות מערכת יציבה וניתנת להרחבה.

  • באפשרותך לאמץ איחוד מובחן (איחוד מתויג) המאפשר הסתעפות לפי kind או type.
  • השתמשו במזהה מתאם כדי להתאים כראוי בין בקשות לתגובות.
  • פעימות לב וניקוי אוטומטי יכולים למנוע נעילות נטושות.
  • הטמיעו ניהול גרסאות כדי לאפשר גמישות לשינויים עתידיים בפרוטוקול.
  • הגדרה של קודי שגיאה ברורים מקלה על הטיפול בצד ה-UI ועל ניפוי שגיאות.

סיכום

Shared Worker הוא מנגנון מרכזי לשיתוף נתונים ומצב בין מספר לשוניות בדפדפן.

המבנה שהוצג כאן מספק תקשורת RPC עם בטיחות טיפוסים, ניטור חיות באמצעות פעימות לב ומנגנון נעילה, מה שהופך אותו לתכנון חזק שניתן להשתמש בו כפי שהוא בסביבת ייצור.

על גבי מנגנון זה ניתן ליישם גם את היישומים הבאים.

  • סיריאליזציה של הגישה ל-IndexedDB
  • אינטגרציה ושיתוף של חיבורי WebSocket
  • בניית תור משימות בין מספר לשוניות
  • הגבלת קצב ומסירת התראות התקדמות

כפי שניתן לראות, שימוש ב-Shared Worker מאפשר לשתף נתונים ועיבוד בצורה בטוחה ויעילה בין מספר לשוניות.

תוכלו לעקוב אחר המאמר שלמעלה באמצעות Visual Studio Code בערוץ היוטיוב שלנו. נא לבדוק גם את ערוץ היוטיוב.

YouTube Video