尊敬的各位技术同仁,大家好!
在现代复杂的前端应用开发中,我们经常面临一个挑战:如何在用户同时打开的多个浏览器 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 平台能力的不断增强,期待未来有更多创新的解决方案涌现,进一步提升用户体验和开发效率。