跨 Tab 页的强一致性通信:基于 SharedWorker 与 Lock API 的锁竞争实现

尊敬的各位技术同仁,大家好!

在现代复杂的前端应用开发中,我们经常面临一个挑战:如何在用户同时打开的多个浏览器 Tab 页之间,保持数据的强一致性。想象一下,一个用户在一个 Tab 页修改了某个设置,而另一个 Tab 页却依然显示着旧的数据;或者,多个 Tab 页同时尝试更新同一个资源,导致数据冲突或丢失。这些场景轻则影响用户体验,重则引发严重的业务逻辑错误。

今天,我们将深入探讨如何利用 Web 平台提供的两大强大工具——SharedWorkerLock API——来构建一个跨 Tab 页的强一致性通信机制,从而有效解决这些并发与同步问题。我们将从问题的根源出发,逐步剖析这两种技术的原理,最终通过具体的代码示例,展示如何将它们巧妙结合,实现我们所需的高可靠性系统。

跨 Tab 页通信的挑战与强一致性需求

浏览器天然的设计哲学是隔离。每个 Tab 页通常运行在独立的进程或线程中,拥有独立的 JavaScript 运行时、DOM 树和内存空间。这种隔离性保障了安全性与稳定性,但也为跨 Tab 页的数据共享与同步带来了挑战。

传统跨 Tab 页通信手段及其局限

在深入探讨解决方案之前,我们先回顾一下常见的跨 Tab 页通信手段,并分析它们在实现“强一致性”方面的不足:

  1. localStorage / sessionStorage:

    • 优点: 简单易用,数据持久化(localStorage),跨 Tab 页共享。
    • 缺点:
      • 非原子性:localStorage 的写入操作不是原子的。如果两个 Tab 页几乎同时读取、修改、写入同一个键值,很容易发生竞态条件,导致后写入的数据覆盖前写入的数据,或者基于旧数据进行的计算结果被覆盖。
      • 无通知机制: localStoragestorage 事件只能通知到 非当前写入 的 Tab 页,无法通知到 当前写入 的 Tab 页。这使得同步逻辑变得复杂。
      • 容量限制: 通常为 5-10MB。
    • 强一致性挑战: 缺乏原生的锁定机制,无法保证对共享数据的并发访问是安全的。
  2. BroadcastChannel API:

    • 优点: 专门为跨 Tab 页(同源)广播消息设计,API 简洁。
    • 缺点:
      • 纯消息广播: BroadcastChannel 只是一个消息通道,它本身不提供任何状态管理或同步机制。它只能通知其他 Tab 页“某个事件发生了”或“某个数据可能已更新”,但不能保证这些操作的原子性或顺序性。
      • 无原生锁定: 同样缺乏对共享资源的锁定能力。如果多个 Tab 页都监听并尝试响应同一消息,仍然可能出现竞态条件。
    • 强一致性挑战: 适用于事件通知或非关键数据的同步,但无法独立保证对共享状态的原子性更新。
  3. window.postMessage (配合 window.openeriframe):

    • 优点: 允许跨窗口/框架通信。
    • 缺点:
      • 限定通信目标: 只能与 opener 窗口或 iframe 中的内容通信,不适用于任意 Tab 页之间的广播。
      • 复杂性: 需要维护窗口引用,处理消息来源。
    • 强一致性挑战: 无法提供全局的协调和锁定机制。
  4. IndexedDB:

    • 优点: 客户端结构化存储,容量大,支持事务。
    • 缺点:
      • 事务粒度: IndexedDB 事务仅在其自身范围内提供原子性。跨 Tab 页的多个 IndexedDB 事务如果操作同一数据,仍然可能需要额外的协调。
      • 复杂性: API 相对复杂,直接用于通信不如专门的通信 API 方便。
    • 强一致性挑战: 虽然其事务机制有助于数据完整性,但要实现跨 Tab 页的 逻辑操作 的强一致性,仍需额外的同步原语。例如,两个 Tab 页各自在一个事务中读取、修改、写入同一个计数器,没有外部协调仍可能导致错误。

综上所述,传统的通信手段在实现“强一致性”时力不从心,主要症结在于缺乏一个统一的协调中心和原子的锁定机制。而这正是 SharedWorkerLock 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 可以:

  1. 统一管理共享状态: 所有跨 Tab 页共享的数据都存储在 SharedWorker 内部。
  2. 序列化操作: 所有对共享状态的修改请求都发送到 SharedWorker。由于 SharedWorker 是单线程的,它会按照接收到的顺序(或内部调度策略)依次处理这些请求,从而避免内部的竞态条件。
  3. 广播更新: 当共享状态发生变化时,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 来管理 sharedCountersharedMessage。所有连接的 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 提供了我们梦寐以求的“互斥访问”能力。通过在关键代码块(即所谓的“临界区”)前后请求和释放锁,我们可以确保在任何给定时间,只有一个上下文能够执行该临界区的代码。

结合 SharedWorkerLock API 的作用是协调 多个客户端SharedWorker 内部状态修改操作 的请求。即,在客户端发送可能导致竞态的消息之前,先通过 Lock API 获得一个独占锁。

场景示例:

  1. Tab A 想要执行一个需要强一致性的操作(例如,基于当前计数器的值进行复杂计算后更新计数器)。
  2. Tab A 调用 navigator.locks.request('my_resource_lock', ...)
  3. 如果锁可用,Tab A 成功获取锁,然后执行其临界区代码:
    • SharedWorker 发送消息,请求获取当前状态(如果需要)。
    • 基于获取到的状态进行计算。
    • SharedWorker 发送消息,请求更新状态。
    • 等待 SharedWorker 的确认或状态更新广播。
  4. Tab A 持有锁期间,如果 Tab B 也尝试获取 'my_resource_lock',它将必须等待。
  5. Tab A 的操作完成后(request 回调函数执行完毕或其内部 Promise 解决),锁会自动释放。
  6. 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 作为客户端协调并发请求的原子锁。是时候将它们结合起来,构建一个真正的强一致性通信方案了。

核心思想与工作流程

  1. SharedWorker 作为唯一数据源: 所有的共享状态都只在 SharedWorker 内部维护。
  2. 客户端通过 Lock API 协调请求: 当任何客户端(Tab 页)需要执行一个涉及共享状态修改的“临界操作”时,它必须首先请求一个独占锁。
  3. 锁内操作与 SharedWorker 交互: 只有在成功获取锁之后,客户端才能向 SharedWorker 发送修改请求。这个请求可能包含读取当前状态、基于当前状态计算新值、然后提交新值的整个流程。
  4. SharedWorker 处理并广播: SharedWorker 接收到请求后,更新其内部状态,然后将最新的状态广播给所有连接的客户端。
  5. 客户端释放锁: 客户端在收到 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 示例中,我们新增了一个“强一致性递增”按钮。当用户点击此按钮时:

  1. 客户端首先使用 navigator.locks.request('counter_update_lock', ...) 尝试获取一个名为 counter_update_lock 的独占锁。
  2. AbortSignal.timeout(5000) 用于设置锁获取的超时时间,防止因其他 Tab 页长时间持有锁而导致当前 Tab 页无限等待。
  3. 一旦成功获取锁(lock 参数存在),客户端进入临界区。
  4. 在临界区内,客户端向 SharedWorker 发送一个 INCREMENT_COUNTER_COMPLEX 消息。这个消息包含了递增值和最大限制,让 SharedWorker 执行一个带条件的递增操作。
  5. 客户端通过监听 SharedWorker 的消息,等待 INCREMENT_COMPLEX_ACK 响应。这是一个 Promise 包装的等待过程,确保客户端在锁释放前,已经知道 SharedWorker 的操作结果。
  6. SharedWorker 处理请求,更新 sharedCounter,并广播 STATE_UPDATE 给所有客户端。
  7. 客户端收到 INCREMENT_COMPLEX_ACK 消息后,Promise 解决。
  8. navigator.locks.request 的回调函数执行完毕,浏览器自动释放 counter_update_lock
  9. 其他等待该锁的 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 时,它需要立即获取当前的最新状态。在我们的示例中,SharedWorkeronconnect 事件中立即发送 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 页持有的所有锁,这有效防止了死锁。

性能考量

虽然 SharedWorkerLock API 提供了强大的同步能力,但它们并非没有开销。

  • 消息传递开销: postMessage 涉及数据的序列化和反序列化。对于大量或复杂的数据,这可能产生性能负担。
  • 锁竞争开销: 频繁的锁竞争会导致一些 Tab 页等待,这在高并发场景下可能影响响应速度。
  • 权衡: 这种机制最适用于需要严格一致性的关键业务操作。对于非核心、允许最终一致性的数据同步,BroadcastChannellocalStorage 配合事件监听可能更简单高效。

替代方案与混合模式

  • IndexedDB + Lock API: 如果共享状态需要持久化,并且 SharedWorker 的生命周期不够(例如,用户关闭所有 Tab 页后状态丢失),可以将 IndexedDB 作为后端存储,然后使用 Lock API 来协调对 IndexedDB 事务的访问。SharedWorker 依然可以作为协调者,但数据源变为 IndexedDB
  • 服务器端同步: 对于需要跨浏览器、跨设备甚至跨用户的数据一致性,服务器端同步是不可避免的。SharedWorkerLock API 解决的是 单个用户在同一浏览器中 的一致性问题。

总结与展望

SharedWorkerWeb Locks API 是现代 Web 平台提供的强大工具,它们共同为前端开发者解决跨 Tab 页强一致性通信这一复杂挑战提供了可靠的方案。SharedWorker 作为中心化的状态管理和消息分发枢纽,确保了数据源的唯一性和操作的序列化;而 Lock API 则为客户端提供了原子的互斥访问机制,有效防止了竞态条件,保证了“读-改-写”等临界操作的完整性。

通过本文的深入探讨和代码示例,我们已经掌握了如何利用这两个 API 构建一个健壮的、高一致性的跨 Tab 页通信系统。在实际应用中,开发者应根据业务需求,权衡性能与一致性,选择最合适的同步策略。理解并熟练运用这些技术,将使我们能够构建出更加复杂、更加稳定的富客户端 Web 应用。随着 Web 平台能力的不断增强,期待未来有更多创新的解决方案涌现,进一步提升用户体验和开发效率。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注