JavaScript中的共享工作者

JavaScript中的共享工作者

本文介绍了JavaScript中的Shared Worker

我们将一步一步地讲解从Shared Worker的基础到实际应用场景的所有内容。

YouTube Video

javascript-shared-worker.html
  1<!DOCTYPE html>
  2<html lang="en">
  3<head>
  4  <meta charset="UTF-8">
  5  <title>JavaScript &amp; HTML</title>
  6  <style>
  7    * {
  8        box-sizing: border-box;
  9    }
 10
 11    body {
 12        margin: 0;
 13        padding: 1em;
 14        padding-bottom: 10em;
 15        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
 16        background-color: #f7f9fc;
 17        color: #333;
 18        line-height: 1.6;
 19    }
 20
 21    .container {
 22        max-width: 800px;
 23        margin: 0 auto;
 24        padding: 1em;
 25        background-color: #ffffff;
 26        border: 1px solid #ccc;
 27        border-radius: 10px;
 28        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
 29    }
 30
 31    .container-flex {
 32        display: flex;
 33        flex-wrap: wrap;
 34        gap: 2em;
 35        max-width: 1000px;
 36        margin: 0 auto;
 37        padding: 1em;
 38        background-color: #ffffff;
 39        border: 1px solid #ccc;
 40        border-radius: 10px;
 41        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
 42    }
 43
 44    .left-column, .right-column {
 45        flex: 1 1 200px;
 46        min-width: 200px;
 47    }
 48
 49    h1, h2 {
 50        font-size: 1.2rem;
 51        color: #007bff;
 52        margin-top: 0.5em;
 53        margin-bottom: 0.5em;
 54        border-left: 5px solid #007bff;
 55        padding-left: 0.6em;
 56        background-color: #e9f2ff;
 57    }
 58
 59    button {
 60        display: block;
 61        margin: 1em auto;
 62        padding: 0.75em 1.5em;
 63        font-size: 1rem;
 64        background-color: #007bff;
 65        color: white;
 66        border: none;
 67        border-radius: 6px;
 68        cursor: pointer;
 69        transition: background-color 0.3s ease;
 70    }
 71
 72    button:hover {
 73        background-color: #0056b3;
 74    }
 75
 76    #output {
 77        margin-top: 1em;
 78        background-color: #1e1e1e;
 79        color: #0f0;
 80        padding: 1em;
 81        border-radius: 8px;
 82        min-height: 200px;
 83        font-family: Consolas, monospace;
 84        font-size: 0.95rem;
 85        overflow-y: auto;
 86        white-space: pre-wrap;
 87    }
 88
 89    .highlight {
 90        outline: 3px solid #ffc107; /* yellow border */
 91        background-color: #fff8e1;  /* soft yellow background */
 92        transition: background-color 0.3s ease, outline 0.3s ease;
 93    }
 94
 95    .active {
 96        background-color: #28a745; /* green background */
 97        color: #fff;
 98        box-shadow: 0 0 10px rgba(40, 167, 69, 0.5);
 99        transition: background-color 0.3s ease, box-shadow 0.3s ease;
100    }
101  </style>
102</head>
103<body>
104    <div class="container">
105        <h1>JavaScript Console</h1>
106        <button id="executeBtn">Execute</button>
107        <div id="output"></div>
108    </div>
109
110    <script>
111        // Override console.log to display messages in the #output element
112        (function () {
113            // Override console.log
114            const originalLog = console.log;
115            console.log = function (...args) {
116                originalLog.apply(console, args);
117                const message = document.createElement('div');
118                message.textContent = args.map(String).join(' ');
119                output.appendChild(message);
120            };
121
122            // Override console.error
123            const originalError = console.error;
124            console.error = function (...args) {
125                originalError.apply(console, args);
126                const message = document.createElement('div');
127                message.textContent = args.map(String).join(' ');
128                message.style.color = 'red'; // Color error messages red
129                output.appendChild(message);
130            };
131        })();
132
133        document.getElementById('executeBtn').addEventListener('click', () => {
134            // Prevent multiple loads
135            if (document.getElementById('externalScript')) return;
136
137            const script = document.createElement('script');
138            script.src = 'javascript-shared-worker.js';
139            script.id = 'externalScript';
140            //script.onload = () => console.log('javascript-shared-worker.js loaded and executed.');
141            //script.onerror = () => console.log('Failed to load javascript-shared-worker.js.');
142            document.body.appendChild(script);
143        });
144    </script>
145</body>
146</html>

JavaScript中的共享工作者

Shared Worker是什么?

Shared Worker是一种可以在同一源下被多个页面(标签页、窗口、iframe等)共享的工作线程。与特定页面的 Dedicated Worker 不同,主要特点是可以在多个页面之间共享同一个后台进程。典型的使用场景包括:。

  • 你可以在多个标签页之间共享一个WebSocket连接,从而减少连接数量并集中管理重连。
  • 你可以在标签页之间同步状态(如Pub/Sub或数据存储的集中管理)。
  • 你可以序列化IndexedDB操作,以协调并发访问。
  • 你可以防止高计算量过程的重复执行

Shared WorkerService Worker 的作用不同。Service Worker 主要充当网络代理,而 Shared Worker 侧重于跨页面执行任意计算或共享状态管理

基本API与生命周期

创建(主线程)

 1// main.js
 2// The second argument 'name' serves as an identifier to share the same SharedWorker
 3// if both the URL and name are the same.
 4const worker = new SharedWorker('/shared-worker.js', { name: 'app-core' });
 5
 6// Get the communication port (MessagePort) with this SharedWorker
 7const port = worker.port;
 8
 9// Receive messages with onmessage, send messages with postMessage
10port.onmessage = (ev) => {
11    console.log('From SharedWorker:', JSON.stringify(ev.data));
12};
13
14// When using onmessage, start() is not required
15// (start() is needed when using addEventListener instead)
16port.postMessage({ type: 'hello', from: location.pathname });
  • 这段代码演示了如何创建Shared Worker并通过其端口收发消息。

接收端(在Shared Worker作用域内)

 1// shared-worker.js
 2// SharedWorkerGlobalScope has an onconnect event,
 3// and a MessagePort is provided for each connection
 4const ports = new Set();
 5
 6onconnect = (e) => {
 7    const port = e.ports[0];
 8    ports.add(port);
 9
10    port.onmessage = (ev) => {
11        // Log the received message and send a reply back to the sender
12        console.log('From page:', ev.data);
13        port.postMessage({ type: 'ack', received: ev.data });
14    };
15
16    // For handling explicit disconnection from the page (via MessagePort.close())
17    port.onmessageerror = (err) => {
18        console.error('Message error:', err);
19    };
20
21    // Greeting immediately after connection
22    port.postMessage({ type: 'welcome', clientCount: ports.size });
23};
24
25// Explicit termination of the SharedWorker is usually unnecessary
26// (It will be garbage collected when all ports are closed)
  • 此代码展示了如何在Shared Worker内部为每一个连接接收MessagePort并处理、回复来自客户端的消息。

Shared Worker的生命周期

Shared Worker 会在建立第一个连接时启动,并可能在最后一个端口关闭时终止。当页面刷新/关闭时连接被关闭,如有需要会重新创建worker。

Shared Worker 是在浏览器中共享的"长生命周期脚本"。如果你不注意 Shared Worker 的生命周期,更容易出现资源泄漏、陈旧状态持久化和意外重启等问题。

试试看:标签页之间的广播

这是一个最简单的实现,多个标签页连接到同一个Shared Worker任何发送的消息都会广播给所有标签页

main.js

 1const worker = new SharedWorker('/shared-worker.js', { name: 'bus' });
 2const port = worker.port;
 3
 4port.onmessage = (ev) => {
 5    const msg = ev.data;
 6    if (msg.type === 'message') {
 7        const li = document.createElement('li');
 8        li.textContent = `[${new Date(msg.at).toLocaleTimeString()}] ${msg.payload}`;
 9        document.querySelector('#messages').appendChild(li);
10    } else if (msg.type === 'ready') {
11        console.log(`Connected. clients=${msg.clients}`);
12    }
13};
14
15document.querySelector('#form').addEventListener('submit', (e) => {
16    e.preventDefault();
17    const input = document.querySelector('#text');
18    port.postMessage({ type: 'publish', payload: input.value });
19    input.value = '';
20});
21
22document.querySelector('#ping').addEventListener('click', () => {
23    port.postMessage({ type: 'ping' });
24});
  • 此代码连接到Shared Worker,实现了消息的接收/显示、通过发送表单发送消息,以及点击按钮发送ping。

shared-worker.js

 1// Minimal pub/sub bus in a SharedWorker
 2const subscribers = new Set();
 3
 4/** Broadcast to all connected ports */
 5function broadcast(message) {
 6    for (const port of subscribers) {
 7        try {
 8            port.postMessage(message);
 9        } catch (e) {
10            // Unsubscribe if sending fails, just in case
11            subscribers.delete(port);
12        }
13    }
14}
15
16onconnect = (e) => {
17    const port = e.ports[0];
18    subscribers.add(port);
19
20    port.onmessage = (ev) => {
21        const msg = ev.data;
22        if (msg && msg.type === 'publish') {
23            broadcast({ type: 'message', payload: msg.payload, at: Date.now() });
24        } else if (msg && msg.type === 'ping') {
25            port.postMessage({ type: 'pong', at: Date.now() });
26        }
27    };
28
29    port.postMessage({ type: 'ready', clients: subscribers.size });
30};
  • 此代码在Shared Worker内部实现了简单的发布/订阅功能,在多个客户端之间转发消息。

index.html

 1<!doctype html>
 2<html>
 3    <body>
 4        <form id="form">
 5            <input id="text" placeholder="say something" />
 6            <button>Send</button>
 7        </form>
 8        <button id="ping">Ping</button>
 9        <ul id="messages"></ul>
10        <script src="/main.js" type="module"></script>
11    </body>
12</html>
  • 如果你在多个标签页中打开此页面,从任一标签页发送的消息都能通知其他标签页。

消息设计:请求/响应和关联ID

当多个客户端与Shared Worker交互时,你经常需要识别哪个响应对应哪个请求。因此,标准做法是包含相关 ID 以将响应与请求关联起来。

main.js

 1function createClient(workerUrl, name) {
 2    const w = new SharedWorker(workerUrl, { name });
 3    const port = w.port;
 4    const pending = new Map(); // id -> {resolve,reject}
 5    let nextId = 1;
 6
 7    port.onmessage = (ev) => {
 8        const { id, ok, result, error } = ev.data || {};
 9        if (!id || !pending.has(id)) return;
10        const { resolve, reject } = pending.get(id);
11        pending.delete(id);
12        ok ? resolve(result) : reject(new Error(error));
13    };
14
15    function call(method, params) {
16        const id = nextId++;
17        return new Promise((resolve, reject) => {
18            pending.set(id, { resolve, reject });
19            port.postMessage({ id, method, params });
20        });
21    }
22
23    return { call };
24}
25
26// usage
27const client = createClient('/shared-worker.js', 'rpc');
28client.call('add', { a: 2, b: 3 }).then(console.log);   // -> 5
29client.call('sleep', { ms: 500 }).then(console.log);     // -> 'done'
  • 此代码实现了一个简单的RPC客户端,可以向Shared Worker发起异步方法调用。 这里的 RPC 服务器(远程过程调用服务器)是指提供从其他程序或进程调用函数和过程机制的服务器。
  • 在此示例中,id 只是简单递增,但你也可以使用 UUID、随机字符串,或将时间戳与计数器结合来创建唯一键。

shared-worker.js

 1// A small request/response router with correlation ids
 2onconnect = (e) => {
 3    const port = e.ports[0];
 4
 5    port.onmessage = async (ev) => {
 6        const { id, method, params } = ev.data || {};
 7        try {
 8            let result;
 9            switch (method) {
10                case 'add':
11                    result = (params?.a ?? 0) + (params?.b ?? 0);
12                    break;
13                case 'sleep':
14                    await new Promise(r => setTimeout(r, params?.ms ?? 0));
15                    result = 'done';
16                    break;
17                default:
18                    throw new Error(`Unknown method: ${method}`);
19            }
20            port.postMessage({ id, ok: true, result });
21        } catch (err) {
22            port.postMessage({ id, ok: false, error: String(err) });
23        }
24    };
25};
  • 该代码实现了一个简单的 RPC 服务器,根据 method 执行处理,并将结果与请求 ID 一起返回。

典型使用模式

使用Shared Worker时,有几种常见实用模式,如下:。

多路复用一个WebSocket(在标签页之间共享)

如果每个标签页都打开自己的 WebSocket 连接,会影响服务器的负载和连接数上限。将唯一一个WebSocket放在Shared Worker中,每个标签页通过worker收发消息。

main.js

 1const w = new SharedWorker('/shared-worker.js', { name: 'ws' });
 2const port = w.port;
 3
 4port.onmessage = (ev) => {
 5    const msg = ev.data;
 6    if (msg.type === 'data') {
 7        console.log('Server says:', msg.payload);
 8    } else if (msg.type === 'socket') {
 9        console.log('WS state:', msg.state);
10    }
11};
12
13function send(payload) {
14    port.postMessage({ type: 'send', payload });
15}
  • 此代码实现了通过Shared Worker向WebSocket服务器发送/接收消息和获得连接状态通知的流程。

shared-worker.js

 1let ws;
 2const ports = new Set();
 3
 4function ensureSocket() {
 5    if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
 6        return;
 7    }
 8    ws = new WebSocket('wss://example.com/stream');
 9    ws.onopen = () => broadcast({ type: 'socket', state: 'open' });
10    ws.onclose = () => broadcast({ type: 'socket', state: 'closed' });
11    ws.onerror = (e) => broadcast({ type: 'socket', state: 'error', detail: String(e) });
12    ws.onmessage = (m) => broadcast({ type: 'data', payload: m.data });
13}
14
15function broadcast(msg) {
16    for (const p of ports) p.postMessage(msg);
17}
18
19onconnect = (e) => {
20    const port = e.ports[0];
21    ports.add(port);
22    ensureSocket();
23
24    port.onmessage = (ev) => {
25        const msg = ev.data;
26        if (msg?.type === 'send' && ws?.readyState === WebSocket.OPEN) {
27            ws.send(JSON.stringify(msg.payload));
28        }
29    };
30};
  • 此代码在Shared Worker中实现了共享单一WebSocket连接的机制,将连接状态和收到的数据广播给所有客户端,将每个客户端的请求通过WebSocket发送给服务器。

  • 通过在 Shared Worker 中集中处理重连策略和未发送消息队列,所有标签页的行为变得一致

IndexedDB操作调度(序列化)

当从多个标签页访问同一个数据库时,为避免并发事务冲突或锁等待,可以在Shared Worker中将请求排队,顺序处理。

 1// db-worker.js (very simplified – error handling omitted)
 2let db;
 3async function openDB() {
 4    if (db) return db;
 5    db = await new Promise((resolve, reject) => {
 6        const req = indexedDB.open('appdb', 1);
 7        req.onupgradeneeded = () => req.result.createObjectStore('kv');
 8        req.onsuccess = () => resolve(req.result);
 9        req.onerror = () => reject(req.error);
10    });
11    return db;
12}
13
14async function put(key, value) {
15    const d = await openDB();
16    return new Promise((resolve, reject) => {
17        const tx = d.transaction('kv', 'readwrite');
18        tx.objectStore('kv').put(value, key);
19        tx.oncomplete = () => resolve(true);
20        tx.onerror = () => reject(tx.error);
21    });
22}
23
24async function get(key) {
25    const d = await openDB();
26    return new Promise((resolve, reject) => {
27        const tx = d.transaction('kv', 'readonly');
28        const req = tx.objectStore('kv').get(key);
29        req.onsuccess = () => resolve(req.result);
30        req.onerror = () => reject(req.error);
31    });
32}
33
34onconnect = (e) => {
35    const port = e.ports[0];
36    port.onmessage = async (ev) => {
37        const { id, op, key, value } = ev.data;
38        try {
39            const result = op === 'put' ? await put(key, value) : await get(key);
40            port.postMessage({ id, ok: true, result });
41        } catch (err) {
42            port.postMessage({ id, ok: false, error: String(err) });
43        }
44    };
45};
  • 此代码实现了通过Shared Worker对IndexedDB的读写请求进行排队,从而串行化多标签页的访问,避免冲突与锁竞争。

模块化与打包工具提示

如果你想将Shared Worker脚本写成ES模块,不同环境对其行为和支持可能不同。实际中,更安全的做法是选择以下之一:。

  • 编写为传统worker格式,如需依赖通过importScripts()加载。
  • 使用打包工具的worker入口功能(如Vite / Webpack / esbuild等),在构建时为Shared Worker单独打包。

错误处理、断线检测与健壮性建议

关于错误处理和健壮性,请考虑以下几点:。

  • 处理连接建立前的发送 将端口未准备好时到达的消息进行排队。

  • 断线检测 MessagePort没有标准的onclose事件。在主页面中,在 beforeunload 期间使用 port.postMessage 发送 {type: 'bye'} 消息,或在协议中明确指定,以确保 worker 被正确清理。

  • 重连 当页面刷新或标签页重新打开时,会新建一个端口。准备一个初始同步消息(用于一次性发送完整状态)。

  • 反压机制 在高频广播时,切换为节流/防抖发送快照

  • 安全性 Shared Worker本质上只在同一个源内可共享。如果在worker中存放敏感信息,建议在调用方通过令牌等方式进行侧面验证和设计。

如何正确使用Dedicated WorkerShared WorkerService Worker

每种类型的Worker具有以下特点:。

  • Dedicated Worker Dedicated Worker只供单个页面使用。它实现了计算与UI的1:1分离。

  • Shared Worker Shared Worker可以被同一源下的多个页面共享。非常适合标签页间通信共享同一个连接

  • Service Worker Service Worker可用于网络代理、缓存、离线操作、推送通知和后台同步。其优势在于可以拦截fetch请求

经验法则:如果需要在标签页间进行信息共享和任意处理,用 Shared Worker;如果需要网络控制,用 Service Worker;如果只是想将大量计算从 UI 中分离出来,用 Dedicated Worker

常见陷阱

使用Shared Worker时,需要注意以下几点。

  • 忘记调用 start() 或不必要的调用 使用port.addEventListener('message', ...)时,必须调用port.start()。如果使用port.onmessage = ...则不需要这样做。

  • 无条件广播 随着标签页数量的增加,负载也会增加。可通过实现差异化投递订阅主题(根据主题过滤)来降低负载。

  • 对象复制的成本 postMessage会复制数据。对于大数据,可以考虑以可转移对象(例如ArrayBuffer)发送,或结合Atomics使用共享内存(SharedArrayBuffer

  • 生命周期与重新初始化 当最后一个客户端断开后,Shared Worker可能会被终止。需要为首次连接和重启妥善设计初始化逻辑,以避免产生副作用和Bug。

总结

  • Shared Worker是实现多个页面共享的长寿命自定义逻辑的地方。
  • 明确消息协定(类型/协议),并通过添加关联ID使你的请求/响应设计更加健壮。
  • 它适用于所有标签页统一处理时效果更佳的场景,例如复用WebSocket连接串行化访问IndexedDB等。
  • 对打包工具、类型定义和重连机制等细节进行优化,能极大提升可维护性和用户体验

您可以在我们的YouTube频道上使用Visual Studio Code跟随上述文章进行学习。 请也查看我们的YouTube频道。

YouTube Video