`Shared Worker` trong TypeScript
Bài viết này giải thích về Shared Worker
trong TypeScript.
Chúng tôi sẽ giải thích chi tiết cách các Shared Worker hoạt động và cách sử dụng chúng trong thực tế, kèm ví dụ mã TypeScript.
YouTube Video
Shared Worker
trong TypeScript
Shared Worker
là một tiến trình worker đơn lẻ được chia sẻ giữa nhiều tab, cửa sổ và iframe cùng nguồn gốc (same origin). Bằng cách sử dụng nó, bạn có thể xử lý trạng thái và tài nguyên dùng chung giữa nhiều tab trình duyệt.
Ví dụ, bạn có thể triển khai hiệu quả kết nối WebSocket dùng chung, bộ nhớ đệm và xử lý hàng đợi được đồng bộ giữa các tab, và cơ chế loại trừ lẫn nhau.
Không giống Dedicated Worker
, Shared Worker
nhận nhiều MessagePort
thông qua sự kiện onconnect
và có thể ghép kênh giao tiếp với nhiều client.
Các trường hợp bạn nên chọn Shared Worker
Trong các trường hợp sau, sử dụng Shared Worker
là phù hợp.
- Khi bạn cần trạng thái dùng chung hoặc cơ chế loại trừ lẫn nhau giữa các tab
- Khi bạn muốn dùng chung một kết nối WebSocket hoặc quyền truy cập IndexedDB
- Khi bạn cần thông báo tới tất cả các tab (broadcast)
- Khi bạn muốn tập trung xử lý nặng để tiết kiệm tài nguyên
Ngược lại, trong các trường hợp sau, những cách tiếp cận khác sẽ phù hợp hơn.
- Khi cần kiểm soát bộ nhớ đệm hoặc hỗ trợ ngoại tuyến, bạn có thể dùng
Service Worker
. - Đối với xử lý nặng được giới hạn trong một tab, bạn có thể dùng
Dedicated Worker
.
Các bước triển khai cho Shared Worker
Tại đây, chúng ta sẽ triển khai từng bước các nội dung sau bằng TypeScript.
- Giao thức thông điệp an toàn kiểu (type-safe)
- Yêu cầu/Phản hồi (RPC) dựa trên Promise
- Phát (broadcast) tới tất cả các tab
- Heartbeat và dọn dẹp client
Thiết lập môi trường
Tạo cấu hình để biên dịch từng tệp nguồn có sử dụng 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}
- Trong
tsconfig-src.json
, bật các định nghĩa kiểu cho DOM và Web Worker để mã có thể được biên dịch an toàn.
Định nghĩa giao thức thông điệp
Nền tảng của giao tiếp là hợp đồng thông điệp có kiểu. Định nghĩa trước điều này giúp giao tiếp về sau an toàn và dễ mở rộng.
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
là một union phân biệt (tagged union) đại diện cho các loại yêu cầu được gửi tới worker và định nghĩa các thao tác nhưping
,get
, vàbroadcast
.
1export interface RequestMessage {
2 kind: 'request';
3 id: string;
4 from: string;
5 action: RequestAction;
6}
RequestMessage
định nghĩa cấu trúc của thông điệp yêu cầu được gửi từ client tới worker.
1export interface ResponseMessage {
2 kind: 'response';
3 id: string;
4 ok: boolean;
5 result?: unknown;
6 error?: string;
7}
ResponseMessage
định nghĩa cấu trúc của thông điệp phản hồi trả từ worker về client.
1export interface BroadcastMessage {
2 kind: 'broadcast';
3 channel: string;
4 payload: unknown;
5 from: string;
6}
BroadcastMessage
định nghĩa cấu trúc của thông điệp broadcast mà worker gửi tới các client khác.
1export type WorkerInMessage =
2 | RequestMessage
3 | { kind: 'heartbeat'; from: string }
4 | { kind: 'bye'; from: string };
WorkerInMessage
là một kiểu đại diện cho mọi thông điệp mà worker nhận, như yêu cầu, heartbeat và thông báo ngắt kết nối.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
WorkerOutMessage
là một kiểu đại diện cho các thông điệp phản hồi hoặc broadcast mà worker gửi tới client.
1export const randomId = () => Math.random().toString(36).slice(2);
randomId
là một hàm tạo chuỗi chữ-số ngẫu nhiên để dùng cho ID thông điệp và tương tự.
Triển khai Shared Worker
Trong shared-worker.ts
, đăng ký các tab kết nối qua sự kiện onconnect
và xử lý thông điệp.
1// shared-worker.ts
2/// <reference lib="webworker" />
- Chỉ thị này yêu cầu TypeScript tải các định nghĩa kiểu cho Web Workers.
1import {
2 WorkerInMessage,
3 WorkerOutMessage,
4 RequestMessage,
5 ResponseMessage,
6} from './worker-protocol.js';
- Nhập (import) các định nghĩa kiểu dùng cho giao tiếp với worker.
1export default {};
2declare const self: SharedWorkerGlobalScope;
- Khai báo rõ ràng rằng
self
là phạm vi toàn cục củaShared Worker
.
1type Client = {
2 id: string;
3 port: MessagePort;
4 lastBeat: number;
5};
Client
là một kiểu đại diện cho định danh của mỗi client, cổng giao tiếp và dấu thời gian heartbeat gần nhất.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
- Quản lý danh sách client đã kết nối, kho khóa-giá trị, trạng thái khóa và thời gian chờ (timeout).
1function send(port: MessagePort, msg: WorkerOutMessage) {
2 port.postMessage(msg);
3}
send
là hàm tiện ích gửi một thông điệp tới cổng được chỉ định.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2 return { kind: 'response', id: req.id, ok, result, error };
3}
respond
tạo thông điệp phản hồi cho một yêu cầu.
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
gửi một thông điệp trên kênh chỉ định tới tất cả client.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
handleRequest
xử lý các yêu cầu đến theo loại và trả kết quả cho client.
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;
- Trong đoạn mã này, tùy theo loại yêu cầu nhận được, nó xử lý việc gửi và nhận thông điệp, truy xuất và lưu dữ liệu, và broadcast.
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 }
- Đoạn mã này hiện thực quy trình để một client giành được lock cho key được chỉ định. Nếu lock chưa bị giữ, nó sẽ được lấy ngay; nếu cùng một client yêu cầu lại, yêu cầu đó cũng được coi là thành công. Nếu một client khác đang giữ lock, nó sẽ thử lại mỗi 25 mili giây cho đến khi lock được giải phóng, và nếu vượt quá thời gian chờ được chỉ định (mặc định 5 giây), nó sẽ phản hồi lỗi.
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}
- Đoạn mã này giải phóng lock mà client đang giữ và trả về phản hồi lỗi nếu client không có quyền hoặc hành động không xác định.
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
phân tích cú pháp các thông điệp nhận từ client và xử lý yêu cầu cũng như 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
xóa các client đã ngắt kết nối khỏi danh sách đăng ký và trạng thái khóa.
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);
- Sử dụng
setInterval
để kiểm tra định kỳ heartbeat của tất cả client và dọn dẹp các kết nối đã hết thời gian chờ.
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
được gọi khi một tab hoặc trang mới kết nối tớiShared Worker
, đăng ký client và bắt đầu giao tiếp. -
Trong toàn bộ tệp này, các cơ chế nền tảng của
Shared Worker
cho phép quản lý trạng thái dùng chung và giao tiếp giữa nhiều tab trình duyệt được triển khai.
Trình bao phía client (RPC)
Tiếp theo, tạo một client RPC dựa trên Promise.
1// shared-worker-client.ts
2import {
3 RequestAction,
4 RequestMessage,
5 WorkerOutMessage,
6 randomId
7} from './worker-protocol.js';
- Nhập (import) các định nghĩa kiểu và hàm tiện ích dùng cho giao tiếp với worker.
1export type BroadcastHandler = (msg: {
2 channel: string;
3 payload: unknown;
4 from: string
5}) => void;
- Tại đây chúng ta định nghĩa kiểu của hàm callback chạy khi nhận một thông điệp broadcast.
1export class SharedWorkerClient {
SharedWorkerClient
là một lớp client giao tiếp vớiShared Worker
, gửi yêu cầu và xử lý phản hồi.
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 }>();
- Các biến này là thể hiện worker, cổng giao tiếp với worker và một bản đồ (Map) theo dõi các yêu cầu đang chờ phản hồi.
1 private clientId = randomId();
2 private heartbeatTimer?: number;
3 private onBroadcast?: BroadcastHandler;
- Các biến này lưu định danh client, bộ hẹn giờ gửi heartbeat và trình xử lý nhận 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;
- Trong constructor, nó khởi tạo kết nối tới
Shared Worker
và thiết lập lắng nghe thông điệp cũng như việc gửi 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();
- Tại đây nó nhận thông điệp từ worker và xử lý phản hồi hoặc broadcast.
1 this.heartbeatTimer = window.setInterval(() => {
2 this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3 }, 10_000);
- Gửi thông điệp heartbeat định kỳ để giữ kết nối luôn hoạt động.
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 }
- Gửi thông báo ngắt kết nối tới worker trước khi cửa sổ đóng.
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 }
- Phương thức
request
gửi hành động được chỉ định tới worker và nhận kết quả dưới dạngPromise
.
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 }
- Đây là các phương thức tiện ích để kiểm tra giao tiếp cơ bản và lấy thời gian hiện tại.
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 }
- Đây là các phương thức để lưu trữ và truy xuất các cặp khóa-giá trị.
1 broadcast(channel: string, payload: unknown) {
2 return this.request<boolean>({ type: 'broadcast', channel, payload });
3 }
- Đây là một phương thức gửi thông điệp broadcast tới các client khác thông qua worker.
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}
- Đây là các phương thức để chiếm và nhả khóa nhằm đạt loại trừ lẫn nhau trên tài nguyên dùng chung.
- Trong toàn bộ tệp này, một API phía client được triển khai để giao tiếp bất đồng bộ, an toàn từ mỗi tab trình duyệt tới Shared Worker.
Ví dụ sử dụng
Trong demo.ts
, chúng ta sử dụng lớp SharedWorkerClient
đã tạo trước đó và xác minh hành vi của nó. Nó lần lượt thực thi một loạt hàm, bao gồm kiểm tra giao tiếp, đọc và ghi dữ liệu, broadcast và xử lý lock.
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);
- Đoạn mã này là một bản demo sử dụng
Shared Worker
để chia sẻ và đồng bộ dữ liệu và trạng thái giữa nhiều tab trình duyệt. Bằng cách dùng giao tiếp dựa trên thông điệp, bạn có thể trao đổi thông điệp bất đồng bộ một cách an toàn với liên kết lỏng lẻo, giúp việc quản lý giao tiếp giữa các ngữ cảnh khác nhau dễ dàng hơn. Ngoài ra, bằng cách dùng RPC, nó trừu tượng hóa giao tiếp với worker theo phong cách trực quan giống lời gọi phương thức, cải thiện khả năng bảo trì và khả năng đọc.
Kiểm thử trong 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>
Các lưu ý về thiết kế và vận hành
Khi thiết kế và vận hành, ghi nhớ các điểm sau sẽ giúp bạn xây dựng một hệ thống vững chắc và dễ mở rộng hơn.
- Bạn có thể áp dụng một union phân biệt (tagged union) cho phép rẽ nhánh theo
kind
hoặctype
. - Sử dụng correlation ID để ghép đúng yêu cầu với phản hồi.
- Heartbeat và dọn dẹp tự động có thể ngăn các khóa bị bỏ rơi.
- Triển khai phiên bản hóa (versioning) để thích ứng linh hoạt với các thay đổi giao thức trong tương lai.
- Việc định nghĩa mã lỗi rõ ràng giúp việc xử lý phía UI và gỡ lỗi trở nên dễ dàng hơn.
Tóm tắt
Shared Worker
là một cơ chế cốt lõi để chia sẻ dữ liệu và trạng thái giữa nhiều tab trình duyệt.
Cấu trúc được giới thiệu ở đây cung cấp giao tiếp RPC an toàn kiểu, giám sát trạng thái sống (liveness) qua heartbeat và cơ chế khóa, tạo nên một thiết kế vững chắc có thể dùng nguyên xi trong môi trường sản xuất.
Trên nền cơ chế này, bạn cũng có thể triển khai các ứng dụng sau.
- Tuần tự hóa truy cập IndexedDB
- Tích hợp và chia sẻ các kết nối WebSocket
- Xây dựng hàng đợi tác vụ trên nhiều tab
- Giới hạn tốc độ (throttling) và phân phối thông báo tiến độ
Như bạn thấy, tận dụng Shared Worker
giúp chia sẻ dữ liệu và xử lý một cách an toàn và hiệu quả giữa nhiều tab.
Bạn có thể làm theo bài viết trên bằng cách sử dụng Visual Studio Code trên kênh YouTube của chúng tôi. Vui lòng ghé thăm kênh YouTube.