尊敬的各位技术同仁,大家好!
在现代复杂的前端应用开发中,我们经常面临一个挑战:如何在用户同时打开的多个浏览器 Tab 页之间,保持数据的强一致性。想象一下,一个用户在一个 Tab 页修改了某个设置,而另一个 Tab 页却依然显示着旧的数据;或者,多个 Tab 页同时尝试更新同一个资源,导致数据冲突或丢失。这些场景轻则影响用户体验,重则引发严重的业务逻辑错误。
今天,我们将深入探讨如何利用 Web 平台提供的两大强大工具——SharedWorker 和 Lock API——来构建一个跨 Tab 页的强一致性通信机制,从而有效解决这些并发与同步问题。我们将从问题的根源出发,逐步剖析这两种技术的原理,最终通过具体的代码示例,展示如何将它们巧妙结合,实现我们所需的高可靠性系统。
跨 Tab 页通信的挑战与强一致性需求
浏览器天然的设计哲学是隔离。每个 Tab 页通常运行在独立的进程或线程中,拥有独立的 JavaScript 运行时、DOM 树和内存空间。这种隔离性保障了安全性与稳定性,但也为跨 Tab 页的数据共享与同步带来了挑战。
传统跨 Tab 页通信手段及其局限
在深入探讨解决方案之前,我们先回顾一下常见的跨 Tab 页通信手段,并分析它们在实现“强一致性”方面的不足:
-
localStorage/sessionStorage:- 优点: 简单易用,数据持久化(
localStorage),跨 Tab 页共享。 - 缺点:
- 非原子性: 对
localStorage的写入操作不是原子的。如果两个 Tab 页几乎同时读取、修改、写入同一个键值,很容易发生竞态条件,导致后写入的数据覆盖前写入的数据,或者基于旧数据进行的计算结果被覆盖。 - 无通知机制:
localStorage的storage事件只能通知到 非当前写入 的 Tab 页,无法通知到 当前写入 的 Tab 页。这使得同步逻辑变得复杂。 - 容量限制: 通常为 5-10MB。
- 非原子性: 对
- 强一致性挑战: 缺乏原生的锁定机制,无法保证对共享数据的并发访问是安全的。
- 优点: 简单易用,数据持久化(
-
BroadcastChannelAPI:- 优点: 专门为跨 Tab 页(同源)广播消息设计,API 简洁。
- 缺点:
- 纯消息广播:
BroadcastChannel只是一个消息通道,它本身不提供任何状态管理或同步机制。它只能通知其他 Tab 页“某个事件发生了”或“某个数据可能已更新”,但不能保证这些操作的原子性或顺序性。 - 无原生锁定: 同样缺乏对共享资源的锁定能力。如果多个 Tab 页都监听并尝试响应同一消息,仍然可能出现竞态条件。
- 纯消息广播:
- 强一致性挑战: 适用于事件通知或非关键数据的同步,但无法独立保证对共享状态的原子性更新。
-
window.postMessage(配合window.opener或iframe):- 优点: 允许跨窗口/框架通信。
- 缺点:
- 限定通信目标: 只能与
opener窗口或iframe中的内容通信,不适用于任意 Tab 页之间的广播。 - 复杂性: 需要维护窗口引用,处理消息来源。
- 限定通信目标: 只能与
- 强一致性挑战: 无法提供全局的协调和锁定机制。
-
IndexedDB:- 优点: 客户端结构化存储,容量大,支持事务。
- 缺点:
- 事务粒度:
IndexedDB事务仅在其自身范围内提供原子性。跨 Tab 页的多个IndexedDB事务如果操作同一数据,仍然可能需要额外的协调。 - 复杂性: API 相对复杂,直接用于通信不如专门的通信 API 方便。
- 事务粒度:
- 强一致性挑战: 虽然其事务机制有助于数据完整性,但要实现跨 Tab 页的 逻辑操作 的强一致性,仍需额外的同步原语。例如,两个 Tab 页各自在一个事务中读取、修改、写入同一个计数器,没有外部协调仍可能导致错误。
综上所述,传统的通信手段在实现“强一致性”时力不从心,主要症结在于缺乏一个统一的协调中心和原子的锁定机制。而这正是 SharedWorker 和 Lock API 能够大放异彩的地方。
SharedWorker:跨 Tab 页的中央协调者
SharedWorker 是 Web Worker 的一种特殊形式,它可以在同源的所有浏览器 Tab 页、窗口、iframe 甚至其他 SharedWorker 之间共享。与普通的 WebWorker(也称为 DedicatedWorker)不同,DedicatedWorker 每次加载页面都会创建一个新的实例,而 SharedWorker 在同一源下只会被实例化一次,所有连接到它的上下文(比如多个 Tab 页)都会共享这同一个实例。
SharedWorker 的核心特性
- 单例模式: 同一源下的所有页面共享同一个
SharedWorker实例。这使得它天然成为一个理想的中央协调者,可以管理共享状态、处理并发请求并广播结果。 - 独立线程:
SharedWorker运行在独立的线程中,不会阻塞主线程,保持页面响应性。 - 端口通信: 主线程(或任何其他上下文)通过
MessagePort对象与SharedWorker进行通信。每个连接到SharedWorker的上下文都会获得一个独立的MessagePort。 - 持久性: 只要有至少一个 Tab 页或窗口连接着
SharedWorker,它就会一直运行。当所有连接都关闭后,SharedWorker也会被终止。
SharedWorker 如何解决一致性问题
作为中央协调者,SharedWorker 可以:
- 统一管理共享状态: 所有跨 Tab 页共享的数据都存储在
SharedWorker内部。 - 序列化操作: 所有对共享状态的修改请求都发送到
SharedWorker。由于SharedWorker是单线程的,它会按照接收到的顺序(或内部调度策略)依次处理这些请求,从而避免内部的竞态条件。 - 广播更新: 当共享状态发生变化时,
SharedWorker可以通过其维护的MessagePort列表,将最新的状态广播给所有连接的 Tab 页,确保所有 Tab 页都及时获取到一致的数据。
然而,SharedWorker 自身并不能解决所有并发问题。例如,如果多个 Tab 页同时向 SharedWorker 发送“请求修改”消息,SharedWorker 会按顺序处理它们,这解决了 SharedWorker 内部的竞态。但如果在 Tab 页发送请求之前,需要进行一些基于当前共享状态的预判断或计算,而这个判断或计算的结果又可能被其他 Tab 页在发送消息的间隙所改变,那么仍然存在“读-改-写”的竞态条件。
例如,Tab A 和 Tab B 都想将一个计数器从 10 增加到 11。
- Tab A 读取到计数器是 10。
- Tab B 读取到计数器是 10。
- Tab A 计算出新值 11,发送给
SharedWorker。 - Tab B 计算出新值 11,发送给
SharedWorker。 SharedWorker收到 Tab A 的请求,将计数器设置为 11。SharedWorker收到 Tab B 的请求,将计数器设置为 11。
最终结果是 11,而不是预期的 12。这就是我们需要Lock API来协调 客户端 行为的原因。
SharedWorker 基本代码示例
1. shared-worker.js (SharedWorker 脚本)
// 定义一个用于存储所有连接端口的数组
const ports = [];
// 示例共享状态
let sharedCounter = 0;
let sharedMessage = "Hello from SharedWorker!";
/**
* 广播最新状态给所有连接的客户端
*/
function broadcastState() {
const state = {
counter: sharedCounter,
message: sharedMessage
};
ports.forEach(port => {
port.postMessage({ type: 'STATE_UPDATE', payload: state });
});
console.log(`[SharedWorker] State broadcasted:`, state);
}
// 当新的连接被建立时触发
self.onconnect = (event) => {
const port = event.ports[0]; // 获取连接端口
ports.push(port); // 将端口添加到列表中
console.log(`[SharedWorker] New client connected. Total connections: ${ports.length}`);
// 向新连接的客户端发送当前状态
port.postMessage({ type: 'INITIAL_STATE', payload: { counter: sharedCounter, message: sharedMessage } });
// 监听来自客户端的消息
port.onmessage = (msgEvent) => {
const message = msgEvent.data;
console.log(`[SharedWorker] Received message from client:`, message);
switch (message.type) {
case 'INCREMENT_COUNTER':
sharedCounter++;
console.log(`[SharedWorker] Counter incremented to: ${sharedCounter}`);
broadcastState(); // 状态更新后广播
// 可以选择向发送方回复确认消息
port.postMessage({ type: 'INCREMENT_ACK', success: true, newCounter: sharedCounter });
break;
case 'SET_MESSAGE':
if (message.payload && typeof message.payload === 'string') {
sharedMessage = message.payload;
console.log(`[SharedWorker] Message updated to: "${sharedMessage}"`);
broadcastState(); // 状态更新后广播
port.postMessage({ type: 'SET_MESSAGE_ACK', success: true, newMessage: sharedMessage });
} else {
port.postMessage({ type: 'SET_MESSAGE_ACK', success: false, error: 'Invalid message payload' });
}
break;
case 'GET_CURRENT_STATE':
port.postMessage({ type: 'CURRENT_STATE', payload: { counter: sharedCounter, message: sharedMessage } });
break;
default:
console.warn(`[SharedWorker] Unknown message type: ${message.type}`);
}
};
// 监听端口断开事件(例如 Tab 页关闭)
port.onmessageerror = (error) => {
console.error(`[SharedWorker] Message error on port:`, error);
// 通常不需要手动移除,因为 onclose 会处理
};
// 当端口关闭时触发(例如 Tab 页关闭)
// 注意:onclose 事件在某些浏览器中可能不会立即触发或行为不一致
// 更可靠的断开连接检测通常依赖于主线程的错误处理或心跳机制
// 不过,对于 SharedWorker,当所有连接都断开时,worker 实例会被终止
// 因此,这里无需显式移除 port,因为它会自动清理
// 实际应用中,如果需要精确管理连接数,可以考虑更复杂的生命周期管理
// 例如:port.onclose = () => { const index = ports.indexOf(port); if (index > -1) ports.splice(index, 1); console.log(`[SharedWorker] Client disconnected. Total connections: ${ports.length}`); };
};
console.log('[SharedWorker] Script loaded.');
2. index.html (客户端页面)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharedWorker Client</title>
<style>
body { font-family: sans-serif; margin: 20px; }
.container { border: 1px solid #ccc; padding: 15px; margin-bottom: 15px; border-radius: 5px; }
button { padding: 8px 15px; margin-right: 10px; cursor: pointer; }
input[type="text"] { padding: 8px; width: 200px; margin-right: 10px; }
#status { margin-top: 15px; padding: 10px; background-color: #f0f0f0; border-radius: 3px; }
</style>
</head>
<body>
<h1>SharedWorker 客户端示例</h1>
<p>打开多个 Tab 页,观察计数器和消息的同步。</p>
<div class="container">
<h2>共享计数器</h2>
<p>当前计数器值: <span id="counterValue">0</span></p>
<button id="incrementButton">增加计数器 (非强一致性)</button>
</div>
<div class="container">
<h2>共享消息</h2>
<p>当前共享消息: "<span id="messageValue">Hello from SharedWorker!</span>"</p>
<input type="text" id="messageInput" placeholder="输入新消息">
<button id="setMessageButton">设置消息 (非强一致性)</button>
</div>
<div id="status">
<h3>事件日志:</h3>
<ul id="eventLog"></ul>
</div>
<script>
const counterValueSpan = document.getElementById('counterValue');
const messageValueSpan = document.getElementById('messageValue');
const incrementButton = document.getElementById('incrementButton');
const messageInput = document.getElementById('messageInput');
const setMessageButton = document.getElementById('setMessageButton');
const eventLog = document.getElementById('eventLog');
let sharedWorker;
let workerPort;
function logEvent(message, type = 'info') {
const li = document.createElement('li');
li.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
if (type === 'error') li.style.color = 'red';
eventLog.prepend(li);
if (eventLog.children.length > 20) {
eventLog.removeChild(eventLog.lastChild);
}
}
if (window.SharedWorker) {
// 尝试连接到 SharedWorker
// URL 必须是同源的
sharedWorker = new SharedWorker('shared-worker.js');
workerPort = sharedWorker.port; // 获取端口对象
logEvent('尝试连接到 SharedWorker...');
// 监听来自 SharedWorker 的消息
workerPort.onmessage = (event) => {
const message = event.data;
logEvent(`收到 SharedWorker 消息: ${JSON.stringify(message)}`);
switch (message.type) {
case 'INITIAL_STATE':
case 'STATE_UPDATE':
case 'CURRENT_STATE':
counterValueSpan.textContent = message.payload.counter;
messageValueSpan.textContent = message.payload.message;
logEvent(`更新页面状态: 计数器=${message.payload.counter}, 消息="${message.payload.message}"`);
break;
case 'INCREMENT_ACK':
if (message.success) {
logEvent(`计数器增加成功,新值: ${message.newCounter}`);
} else {
logEvent(`计数器增加失败: ${message.error}`, 'error');
}
break;
case 'SET_MESSAGE_ACK':
if (message.success) {
logEvent(`消息设置成功,新消息: "${message.newMessage}"`);
} else {
logEvent(`消息设置失败: ${message.error}`, 'error');
}
break;
default:
logEvent(`未知消息类型: ${message.type}`);
}
};
workerPort.onerror = (error) => {
logEvent(`SharedWorker 错误: ${error.message}`, 'error');
console.error('SharedWorker error:', error);
};
// 启动端口通信
workerPort.start();
logEvent('SharedWorker 端口已启动。');
} else {
logEvent('您的浏览器不支持 SharedWorker。', 'error');
alert('您的浏览器不支持 SharedWorker。');
}
incrementButton.addEventListener('click', () => {
if (workerPort) {
logEvent('发送 INCREMENT_COUNTER 请求...');
workerPort.postMessage({ type: 'INCREMENT_COUNTER' });
}
});
setMessageButton.addEventListener('click', () => {
if (workerPort) {
const newMessage = messageInput.value.trim();
if (newMessage) {
logEvent(`发送 SET_MESSAGE 请求: "${newMessage}"...`);
workerPort.postMessage({ type: 'SET_MESSAGE', payload: newMessage });
} else {
logEvent('请填写要设置的消息。', 'warn');
}
}
});
// 首次加载时请求当前状态,以防 SharedWorker 已经运行
window.addEventListener('load', () => {
if (workerPort) {
workerPort.postMessage({ type: 'GET_CURRENT_STATE' });
}
});
</script>
</body>
</html>
在上述示例中,我们创建了一个 SharedWorker 来管理 sharedCounter 和 sharedMessage。所有连接的 Tab 页都可以发送请求来修改这些状态,SharedWorker 会处理这些请求并广播最新的状态给所有 Tab 页。
然而,正如前面提到的,如果 Tab 页在发送 INCREMENT_COUNTER 之前需要基于 sharedCounter 的值进行一些复杂判断,并且这个判断和发送消息之间存在时间差,那么仍然可能出现问题。这就是 Lock API 发挥作用的地方。
Web Locks API (Lock API):浏览器原生的原子锁
Web Locks API,通常简称为 Lock API,提供了一种在同源内所有上下文(包括 Tab 页、窗口、iframe 和 Web Worker)之间协调对共享资源访问的机制。它允许开发者请求一个带名称的锁,并保证在锁被持有期间,没有其他上下文可以获取到同名的独占锁。
Lock API 的核心特性
- 原子性保证:
Lock API是浏览器原生的,它确保锁的获取是原子的。一旦一个上下文成功获取到独占锁,其他上下文就无法获取同名独占锁,直到锁被释放。 - 命名锁: 锁通过字符串名称进行标识。只要名称相同,不同上下文之间就能竞争同一个锁。
- 作用域: 锁的作用域限定在同一个源 (origin) 内。
- 两种模式:
exclusive(独占模式): 这是默认模式。一旦一个上下文获得了独占锁,其他上下文就不能获得同名的任何锁(无论是独占还是共享),直到该独占锁被释放。适用于写操作。shared(共享模式): 多个上下文可以同时获取同名的共享锁。但如果有一个上下文尝试获取同名的独占锁,它必须等待所有共享锁都被释放。适用于读操作。
request()方法:navigator.locks.request(name, [options,] callback)是核心方法。它返回一个 Promise,该 Promise 在锁被成功获取后解决,并在callback函数执行完毕(或 Promise 解决/拒绝)后自动释放锁。- 自动释放: 最强大的特性之一是,当获取锁的上下文(Tab 页或 Worker)关闭或发生导航时,浏览器会自动释放该上下文持有的所有锁。这大大降低了死锁的风险。
Lock API 如何解决一致性问题
Lock API 提供了我们梦寐以求的“互斥访问”能力。通过在关键代码块(即所谓的“临界区”)前后请求和释放锁,我们可以确保在任何给定时间,只有一个上下文能够执行该临界区的代码。
结合 SharedWorker,Lock API 的作用是协调 多个客户端 对 SharedWorker 内部状态修改操作 的请求。即,在客户端发送可能导致竞态的消息之前,先通过 Lock API 获得一个独占锁。
场景示例:
- Tab A 想要执行一个需要强一致性的操作(例如,基于当前计数器的值进行复杂计算后更新计数器)。
- Tab A 调用
navigator.locks.request('my_resource_lock', ...)。 - 如果锁可用,Tab A 成功获取锁,然后执行其临界区代码:
- 向
SharedWorker发送消息,请求获取当前状态(如果需要)。 - 基于获取到的状态进行计算。
- 向
SharedWorker发送消息,请求更新状态。 - 等待
SharedWorker的确认或状态更新广播。
- 向
- 在 Tab A 持有锁期间,如果 Tab B 也尝试获取
'my_resource_lock',它将必须等待。 - Tab A 的操作完成后(
request回调函数执行完毕或其内部 Promise 解决),锁会自动释放。 - Tab B 随后可以获取锁并执行其操作。
通过这种方式,即使 SharedWorker 内部是单线程处理请求,外部客户端在发起请求之前也通过 Lock API 进行了协调,从而保证了整个“读-改-写”或“操作-更新”流程的原子性和强一致性。
Lock API 基本代码示例
async function doSomethingWithLock(resourceName, operation) {
console.log(`[Tab] 尝试获取独占锁 "${resourceName}"...`);
try {
await navigator.locks.request(resourceName, { mode: 'exclusive', ifAvailable: false, steal: false, signal: AbortSignal.timeout(5000) }, async (lock) => {
// lock 对象本身没有太多直接用途,它的存在表示你持有了锁
if (lock) {
console.log(`[Tab] 成功获取独占锁 "${resourceName}"。`);
try {
// 执行临界区操作
await operation();
} catch (opError) {
console.error(`[Tab] 临界区操作失败:`, opError);
throw opError; // 重新抛出,让外层捕获
} finally {
console.log(`[Tab] 独占锁 "${resourceName}" 释放中...`);
// 锁会在 callback 结束时自动释放,无需手动调用 release
}
} else {
// ifAvailable: true 时可能发生,但我们设置为 false
console.warn(`[Tab] 未能获取独占锁 "${resourceName}" (意外情况,因为 ifAvailable=false)`);
}
});
console.log(`[Tab] 独占锁 "${resourceName}" 已释放。`);
} catch (error) {
if (error.name === 'AbortError') {
console.warn(`[Tab] 获取锁 "${resourceName}" 超时或被中止。`);
} else {
console.error(`[Tab] 获取锁或执行操作时发生错误:`, error);
}
throw error; // 重新抛出错误以便调用者处理
}
}
// 示例使用
async function exampleUsage() {
await doSomethingWithLock('my_shared_resource', async () => {
// 模拟一个耗时且需要独占访问的操作
console.log('[Tab] 独占操作开始...');
await new Promise(resolve => setTimeout(resolve, 2000));
console.log('[Tab] 独占操作完成。');
});
}
// exampleUsage(); // 调用此函数来测试
navigator.locks.request 参数说明:
| 参数名 | 类型 | 描述 |
|---|---|---|
name |
string |
锁的名称。这是识别和竞争锁的关键。 |
options |
object |
可选对象,用于配置锁的行为。 |
mode |
string |
锁的模式。'exclusive' (默认) 或 'shared'。 |
ifAvailable |
boolean |
如果设置为 true,则在锁不可用时,Promise 会立即以 undefined 解决(而不是等待)。默认 false。 |
steal |
boolean |
如果设置为 true,且锁不可用,则会尝试“窃取”当前持有的锁。只有当当前锁持有者是一个非活动的 Tab 页或 Worker 时,窃取才可能成功。使用时需谨慎,可能导致数据不一致。默认 false。 |
signal |
AbortSignal |
允许你通过一个 AbortController 实例来取消锁的获取请求。如果 signal 被触发,请求锁的 Promise 将会以 AbortError 拒绝。常用于设置超时。 |
callback |
Function |
一个异步回调函数,当锁被成功获取后执行。它接收一个 lock 对象作为参数(通常不需要直接使用)。该函数返回的 Promise 解决后,锁会自动释放。 |
基于 SharedWorker 与 Lock API 的锁竞争实现强一致性通信
现在,我们有了 SharedWorker 作为统一的状态管理者和消息分发中心,以及 Lock API 作为客户端协调并发请求的原子锁。是时候将它们结合起来,构建一个真正的强一致性通信方案了。
核心思想与工作流程
- SharedWorker 作为唯一数据源: 所有的共享状态都只在
SharedWorker内部维护。 - 客户端通过 Lock API 协调请求: 当任何客户端(Tab 页)需要执行一个涉及共享状态修改的“临界操作”时,它必须首先请求一个独占锁。
- 锁内操作与 SharedWorker 交互: 只有在成功获取锁之后,客户端才能向
SharedWorker发送修改请求。这个请求可能包含读取当前状态、基于当前状态计算新值、然后提交新值的整个流程。 - SharedWorker 处理并广播:
SharedWorker接收到请求后,更新其内部状态,然后将最新的状态广播给所有连接的客户端。 - 客户端释放锁: 客户端在收到
SharedWorker的确认或状态更新广播后,或者在临界操作完成后,锁会自动释放。
通过这种模式,我们确保了:
- 在任何时刻,只有一个客户端能够发起对共享状态的原子性修改请求。
SharedWorker作为单线程实体,其内部状态的修改总是顺序执行的。- 所有客户端都能通过
SharedWorker实时获取到最新的、一致的共享状态。
代码示例:强一致性计数器与消息管理
我们将修改之前的客户端代码,为计数器增加一个强一致性的操作。
1. shared-worker.js (保持不变,它只负责处理收到的请求和广播状态)
// shared-worker.js 与之前相同
const ports = [];
let sharedCounter = 0;
let sharedMessage = "Hello from SharedWorker!";
function broadcastState() {
const state = {
counter: sharedCounter,
message: sharedMessage
};
ports.forEach(port => {
port.postMessage({ type: 'STATE_UPDATE', payload: state });
});
console.log(`[SharedWorker] State broadcasted:`, state);
}
self.onconnect = (event) => {
const port = event.ports[0];
ports.push(port);
console.log(`[SharedWorker] New client connected. Total connections: ${ports.length}`);
port.postMessage({ type: 'INITIAL_STATE', payload: { counter: sharedCounter, message: sharedMessage } });
port.onmessage = async (msgEvent) => { // 注意这里改为 async,以便等待处理
const message = msgEvent.data;
console.log(`[SharedWorker] Received message from client:`, message);
switch (message.type) {
case 'INCREMENT_COUNTER':
// SharedWorker 内部是单线程的,所以这里不需要 Lock API
// 收到请求就直接处理,确保内部状态更新的原子性
sharedCounter++;
console.log(`[SharedWorker] Counter incremented to: ${sharedCounter}`);
broadcastState();
port.postMessage({ type: 'INCREMENT_ACK', success: true, newCounter: sharedCounter });
break;
case 'INCREMENT_COUNTER_COMPLEX':
// 对于更复杂的更新,SharedWorker 也可以提供一个确认机制
// 这里模拟一个带条件的复杂递增
if (sharedCounter < message.payload.maxLimit) {
sharedCounter += message.payload.value;
console.log(`[SharedWorker] Complex counter incremented by ${message.payload.value} to: ${sharedCounter}`);
broadcastState();
port.postMessage({ type: 'INCREMENT_COMPLEX_ACK', success: true, newCounter: sharedCounter });
} else {
console.warn(`[SharedWorker] Complex increment failed: max limit reached (${message.payload.maxLimit})`);
port.postMessage({ type: 'INCREMENT_COMPLEX_ACK', success: false, error: 'Max limit reached', currentCounter: sharedCounter });
}
break;
case 'SET_MESSAGE':
if (message.payload && typeof message.payload === 'string') {
sharedMessage = message.payload;
console.log(`[SharedWorker] Message updated to: "${sharedMessage}"`);
broadcastState();
port.postMessage({ type: 'SET_MESSAGE_ACK', success: true, newMessage: sharedMessage });
} else {
port.postMessage({ type: 'SET_MESSAGE_ACK', success: false, error: 'Invalid message payload' });
}
break;
case 'GET_CURRENT_STATE':
port.postMessage({ type: 'CURRENT_STATE', payload: { counter: sharedCounter, message: sharedMessage } });
break;
default:
console.warn(`[SharedWorker] Unknown message type: ${message.type}`);
}
};
};
console.log('[SharedWorker] Script loaded.');
2. index.html (客户端页面,增加强一致性操作按钮)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharedWorker + Lock API Client</title>
<style>
body { font-family: sans-serif; margin: 20px; }
.container { border: 1px solid #ccc; padding: 15px; margin-bottom: 15px; border-radius: 5px; }
button { padding: 8px 15px; margin-right: 10px; cursor: pointer; }
input[type="text"] { padding: 8px; width: 200px; margin-right: 10px; }
input[type="number"] { padding: 8px; width: 80px; margin-right: 10px; }
#status { margin-top: 15px; padding: 10px; background-color: #f0f0f0; border-radius: 3px; }
.error { color: red; }
</style>
</head>
<body>
<h1>SharedWorker + Lock API 客户端示例</h1>
<p>打开多个 Tab 页,通过锁定机制协调对共享数据的更新。</p>
<div class="container">
<h2>共享计数器</h2>
<p>当前计数器值: <span id="counterValue">0</span></p>
<button id="incrementButton">增加计数器 (非强一致性)</button>
<hr>
<h3>强一致性递增 (使用 Lock API)</h3>
<p>递增值: <input type="number" id="incrementAmount" value="1" min="1"></p>
<p>最大限制: <input type="number" id="maxLimit" value="10" min="1"></p>
<button id="incrementStronglyButton">强一致性递增</button>
<span id="strongIncrementStatus" style="margin-left: 10px;"></span>
</div>
<div class="container">
<h2>共享消息</h2>
<p>当前共享消息: "<span id="messageValue">Hello from SharedWorker!</span>"</p>
<input type="text" id="messageInput" placeholder="输入新消息">
<button id="setMessageButton">设置消息 (非强一致性)</button>
</div>
<div id="status">
<h3>事件日志:</h3>
<ul id="eventLog"></ul>
</div>
<script>
const counterValueSpan = document.getElementById('counterValue');
const messageValueSpan = document.getElementById('messageValue');
const incrementButton = document.getElementById('incrementButton');
const incrementAmountInput = document.getElementById('incrementAmount');
const maxLimitInput = document.getElementById('maxLimit');
const incrementStronglyButton = document.getElementById('incrementStronglyButton');
const strongIncrementStatus = document.getElementById('strongIncrementStatus');
const messageInput = document.getElementById('messageInput');
const setMessageButton = document.getElementById('setMessageButton');
const eventLog = document.getElementById('eventLog');
let sharedWorker;
let workerPort;
let currentCounterState = 0; // 客户端维护的最新计数器状态
function logEvent(message, type = 'info') {
const li = document.createElement('li');
li.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
if (type === 'error') li.classList.add('error');
if (type === 'warn') li.style.color = 'orange';
eventLog.prepend(li);
if (eventLog.children.length > 20) {
eventLog.removeChild(eventLog.lastChild);
}
}
if (window.SharedWorker && navigator.locks) {
sharedWorker = new SharedWorker('shared-worker.js');
workerPort = sharedWorker.port;
logEvent('尝试连接到 SharedWorker...');
workerPort.onmessage = (event) => {
const message = event.data;
// logEvent(`收到 SharedWorker 消息: ${JSON.stringify(message)}`); // 调试时开启
switch (message.type) {
case 'INITIAL_STATE':
case 'STATE_UPDATE':
case 'CURRENT_STATE':
currentCounterState = message.payload.counter; // 更新客户端维护的最新状态
counterValueSpan.textContent = message.payload.counter;
messageValueSpan.textContent = message.payload.message;
logEvent(`更新页面状态: 计数器=${message.payload.counter}, 消息="${message.payload.message}"`);
break;
case 'INCREMENT_ACK':
if (message.success) {
logEvent(`计数器非强一致性增加成功,新值: ${message.newCounter}`);
} else {
logEvent(`计数器非强一致性增加失败: ${message.error}`, 'error');
}
break;
case 'INCREMENT_COMPLEX_ACK':
// 此 ACK 消息通常是在 Lock API 内部等待的,这里只是为了演示
// 实际逻辑中,Lock API 的 Promise 解决就代表操作完成
if (message.success) {
logEvent(`计数器强一致性增加成功,新值: ${message.newCounter}`);
strongIncrementStatus.textContent = `成功! 新值: ${message.newCounter}`;
strongIncrementStatus.style.color = 'green';
} else {
logEvent(`计数器强一致性增加失败: ${message.error} (当前值: ${message.currentCounter})`, 'error');
strongIncrementStatus.textContent = `失败: ${message.error}`;
strongIncrementStatus.style.color = 'red';
}
break;
case 'SET_MESSAGE_ACK':
if (message.success) {
logEvent(`消息设置成功,新消息: "${message.newMessage}"`);
} else {
logEvent(`消息设置失败: ${message.error}`, 'error');
}
break;
default:
logEvent(`未知消息类型: ${message.type}`);
}
};
workerPort.onerror = (error) => {
logEvent(`SharedWorker 错误: ${error.message}`, 'error');
console.error('SharedWorker error:', error);
};
workerPort.start();
logEvent('SharedWorker 端口已启动。');
} else {
logEvent('您的浏览器不支持 SharedWorker 或 Lock API。', 'error');
alert('您的浏览器不支持 SharedWorker 或 Lock API。');
}
// --- 非强一致性操作 ---
incrementButton.addEventListener('click', () => {
if (workerPort) {
logEvent('发送 INCREMENT_COUNTER 请求 (非强一致性)...');
workerPort.postMessage({ type: 'INCREMENT_COUNTER' });
}
});
setMessageButton.addEventListener('click', () => {
if (workerPort) {
const newMessage = messageInput.value.trim();
if (newMessage) {
logEvent(`发送 SET_MESSAGE 请求 (非强一致性): "${newMessage}"...`);
workerPort.postMessage({ type: 'SET_MESSAGE', payload: newMessage });
} else {
logEvent('请填写要设置的消息。', 'warn');
}
}
});
// --- 强一致性操作 ---
incrementStronglyButton.addEventListener('click', async () => {
if (!workerPort) {
logEvent('SharedWorker 未连接。', 'error');
return;
}
const incrementAmount = parseInt(incrementAmountInput.value, 10);
const maxLimit = parseInt(maxLimitInput.value, 10);
if (isNaN(incrementAmount) || incrementAmount <= 0) {
logEvent('递增值必须是大于0的数字。', 'warn');
return;
}
if (isNaN(maxLimit) || maxLimit <= 0) {
logEvent('最大限制必须是大于0的数字。', 'warn');
return;
}
strongIncrementStatus.textContent = '尝试获取锁...';
strongIncrementStatus.style.color = 'gray';
logEvent(`[Tab] 尝试获取独占锁 'counter_update_lock' 来执行强一致性递增...`);
try {
// 使用 Lock API 请求独占锁
// signal: AbortSignal.timeout(5000) 设定5秒超时,防止无限等待
await navigator.locks.request('counter_update_lock', { mode: 'exclusive', signal: AbortSignal.timeout(5000) }, async (lock) => {
// lock 参数的存在表示我们成功获得了锁
if (!lock) {
// 理论上不会发生,因为我们没有设置 ifAvailable: true
logEvent(`[Tab] 意外:未能获取独占锁 'counter_update_lock'。`, 'error');
strongIncrementStatus.textContent = '获取锁失败 (意外)';
strongIncrementStatus.style.color = 'red';
return;
}
logEvent(`[Tab] 成功获取独占锁 'counter_update_lock'。开始执行临界区操作...`);
strongIncrementStatus.textContent = '获取锁成功,正在操作...';
strongIncrementStatus.style.color = 'blue';
// 临界区开始:在这里执行需要强一致性的操作
// 1. 发送消息给 SharedWorker 请求更新
// 2. 等待 SharedWorker 的确认消息
await new Promise((resolve, reject) => {
const messageHandler = (event) => {
const response = event.data;
if (response.type === 'INCREMENT_COMPLEX_ACK') {
workerPort.removeEventListener('message', messageHandler); // 移除监听器
if (response.success) {
resolve(response);
} else {
reject(new Error(response.error || '强一致性递增失败'));
}
}
};
workerPort.addEventListener('message', messageHandler);
workerPort.postMessage({
type: 'INCREMENT_COUNTER_COMPLEX',
payload: {
value: incrementAmount,
maxLimit: maxLimit
}
});
logEvent(`[Tab] 发送强一致性递增请求 (递增值: ${incrementAmount}, 最大限制: ${maxLimit})...`);
});
logEvent(`[Tab] 强一致性递增操作完成。锁即将自动释放。`);
});
// Lock API 的 Promise 解决时,表示锁已释放且临界区操作成功
logEvent(`[Tab] 独占锁 'counter_update_lock' 已释放。`, 'info');
} catch (error) {
if (error.name === 'AbortError') {
logEvent(`[Tab] 获取锁 'counter_update_lock' 超时或被中止。`, 'warn');
strongIncrementStatus.textContent = '获取锁超时或被取消';
strongIncrementStatus.style.color = 'orange';
} else {
logEvent(`[Tab] 强一致性递增操作失败: ${error.message}`, 'error');
strongIncrementStatus.textContent = `操作失败: ${error.message}`;
strongIncrementStatus.style.color = 'red';
}
console.error(`[Tab] 强一致性递增错误:`, error);
} finally {
// 无论成功失败,确保状态显示正确
// 最终的 counterValueSpan 会通过 SharedWorker 的 STATE_UPDATE 消息更新
incrementStronglyButton.disabled = false; // 重新启用按钮
}
});
// 首次加载时请求当前状态,以防 SharedWorker 已经运行
window.addEventListener('load', () => {
if (workerPort) {
workerPort.postMessage({ type: 'GET_CURRENT_STATE' });
}
});
</script>
</body>
</html>
在上述 index.html 示例中,我们新增了一个“强一致性递增”按钮。当用户点击此按钮时:
- 客户端首先使用
navigator.locks.request('counter_update_lock', ...)尝试获取一个名为counter_update_lock的独占锁。 AbortSignal.timeout(5000)用于设置锁获取的超时时间,防止因其他 Tab 页长时间持有锁而导致当前 Tab 页无限等待。- 一旦成功获取锁(
lock参数存在),客户端进入临界区。 - 在临界区内,客户端向
SharedWorker发送一个INCREMENT_COUNTER_COMPLEX消息。这个消息包含了递增值和最大限制,让SharedWorker执行一个带条件的递增操作。 - 客户端通过监听
SharedWorker的消息,等待INCREMENT_COMPLEX_ACK响应。这是一个Promise包装的等待过程,确保客户端在锁释放前,已经知道SharedWorker的操作结果。 SharedWorker处理请求,更新sharedCounter,并广播STATE_UPDATE给所有客户端。- 客户端收到
INCREMENT_COMPLEX_ACK消息后,Promise解决。 navigator.locks.request的回调函数执行完毕,浏览器自动释放counter_update_lock。- 其他等待该锁的 Tab 页现在可以尝试获取它。
通过这种机制,即使多个 Tab 页同时点击“强一致性递增”按钮,它们也会按照顺序,一个接一个地获取锁,向 SharedWorker 发送请求,从而确保每次递增操作都是原子且基于最新的共享状态进行的。例如,如果计数器最大限制是10,当前是9,两个Tab页同时请求递增2。只有一个Tab页能获取锁,它发送请求,SharedWorker处理后发现9+2=11 > 10,则拒绝,并广播当前状态9。另一个Tab页获取锁后,发现当前是9,同样拒绝。这样就避免了计数器超出限制或产生错误计算。
进阶场景与考量
共享锁 (Shared Lock) 用于读操作
在某些情况下,我们可能希望多个 Tab 页可以同时读取共享资源,但只有在写入时才需要独占访问。这时可以使用 shared 模式的锁。
- 读操作:
navigator.locks.request('my_resource', { mode: 'shared' }, async (lock) => { /* 读取操作 */ }); - 写操作:
navigator.locks.request('my_resource', { mode: 'exclusive' }, async (lock) => { /* 写入操作 */ });
当有共享锁被持有,独占锁的请求会等待。当独占锁被持有,任何共享锁或独占锁的请求都会等待。这提供了一种经典的读写锁模型。
新 Tab 页加载时的状态同步
当一个新的 Tab 页打开并连接到 SharedWorker 时,它需要立即获取当前的最新状态。在我们的示例中,SharedWorker 在 onconnect 事件中立即发送 INITIAL_STATE 消息,或者客户端在 window.load 时发送 GET_CURRENT_STATE 请求,都能实现这一点。
错误处理与鲁棒性
AbortSignal: 在navigator.locks.request中使用AbortSignal.timeout()是非常重要的,可以防止锁请求无限等待,提升用户体验。try...catch: 始终在异步操作(包括锁的获取和临界区内的操作)中使用try...catch块来捕获和处理错误。- SharedWorker 崩溃: 如果
SharedWorker脚本本身出现未捕获的错误导致崩溃,所有连接到它的port都会收到错误事件。客户端需要有重连或降级策略。幸运的是,SharedWorker崩溃时,浏览器会自动释放所有由其客户端持有的锁,避免死锁。 - Tab 页崩溃/关闭:
Lock API的一个强大之处在于,如果持有锁的 Tab 页意外关闭或导航离开,浏览器会自动释放该 Tab 页持有的所有锁,这有效防止了死锁。
性能考量
虽然 SharedWorker 和 Lock API 提供了强大的同步能力,但它们并非没有开销。
- 消息传递开销:
postMessage涉及数据的序列化和反序列化。对于大量或复杂的数据,这可能产生性能负担。 - 锁竞争开销: 频繁的锁竞争会导致一些 Tab 页等待,这在高并发场景下可能影响响应速度。
- 权衡: 这种机制最适用于需要严格一致性的关键业务操作。对于非核心、允许最终一致性的数据同步,
BroadcastChannel或localStorage配合事件监听可能更简单高效。
替代方案与混合模式
IndexedDB+Lock API: 如果共享状态需要持久化,并且SharedWorker的生命周期不够(例如,用户关闭所有 Tab 页后状态丢失),可以将IndexedDB作为后端存储,然后使用Lock API来协调对IndexedDB事务的访问。SharedWorker依然可以作为协调者,但数据源变为IndexedDB。- 服务器端同步: 对于需要跨浏览器、跨设备甚至跨用户的数据一致性,服务器端同步是不可避免的。
SharedWorker和Lock API解决的是 单个用户在同一浏览器中 的一致性问题。
总结与展望
SharedWorker 和 Web Locks API 是现代 Web 平台提供的强大工具,它们共同为前端开发者解决跨 Tab 页强一致性通信这一复杂挑战提供了可靠的方案。SharedWorker 作为中心化的状态管理和消息分发枢纽,确保了数据源的唯一性和操作的序列化;而 Lock API 则为客户端提供了原子的互斥访问机制,有效防止了竞态条件,保证了“读-改-写”等临界操作的完整性。
通过本文的深入探讨和代码示例,我们已经掌握了如何利用这两个 API 构建一个健壮的、高一致性的跨 Tab 页通信系统。在实际应用中,开发者应根据业务需求,权衡性能与一致性,选择最合适的同步策略。理解并熟练运用这些技术,将使我们能够构建出更加复杂、更加稳定的富客户端 Web 应用。随着 Web 平台能力的不断增强,期待未来有更多创新的解决方案涌现,进一步提升用户体验和开发效率。