在多窗口应用中共享 React 状态:基于 `BroadcastChannel` 的跨标签页同步协调算法

在多窗口应用中共享 React 状态:基于 BroadcastChannel 的跨标签页同步协调算法

在现代 Web 应用的开发中,单页应用(SPA)已经成为主流。然而,用户行为并非总局限于一个浏览器标签页。当用户在多个标签页或窗口中同时打开同一个 Web 应用时,如何确保这些不同实例之间的状态保持同步,提供一致且无缝的用户体验,成为了一个富有挑战性的问题。例如,在一个电商网站中,用户在一个标签页添加商品到购物车,期望在另一个标签页也能立即看到购物车更新;或者在一个内容管理系统中,用户在一个标签页编辑文章,在另一个标签页查看文章列表时,希望看到最新的状态。

传统的 React 状态管理方案,如 Context API 或 Redux,本质上是针对单个浏览器上下文(即单个标签页或窗口)设计的。它们无法直接在不同的标签页之间共享内存或通信。要实现跨标签页的状态同步,我们需要一种浏览器提供的、能够跨越标签页边界的通信机制。

本文将深入探讨如何利用 BroadcastChannel 这一 Web API,构建一个健壮的跨标签页 React 状态同步系统。我们将从理解现有解决方案的局限性出发,逐步构建一个功能完善的同步协调算法,并提供详细的代码示例和最佳实践。

1. 跨标签页状态管理的挑战与现有方案的局限性

在深入 BroadcastChannel 之前,我们有必要审视一下常见的跨标签页通信或状态同步方法,并了解它们的优缺点。

1.1. localStorage / sessionStorage

  • 工作原理: 这两种 Web 存储机制允许在浏览器中存储键值对数据。当一个标签页更新了 localStorage 中的数据时,其他同源的标签页会触发 storage 事件。
  • 优点: 简单易用,数据持久化(localStorage),广泛支持。
  • 缺点:
    • 效率问题: storage 事件的触发并非实时,且只能通过轮询或者依赖事件监听来获取更新。每次更新都需要序列化和反序列化数据,开销较大。
    • 数据类型限制: 只能存储字符串,复杂对象需要手动 JSON.stringifyJSON.parse
    • 竞态条件: 多个标签页同时写入可能导致数据覆盖或不一致。
    • 事件局限性: storage 事件只在非当前标签页修改 localStorage 时触发,当前标签页无法接收到自己触发的事件。
    • 存储限制: 通常为 5MB 左右,不适合大量数据。

1.2. Service Workers

  • 工作原理: Service Worker 是一个在浏览器后台运行的脚本,独立于网页。它可以拦截网络请求、缓存资源,并且能够通过 postMessage API 与注册它的所有客户端(标签页)进行通信。
  • 优点: 强大的后台能力,可以作为中央通信枢纽,实现更复杂的逻辑(如离线同步、消息推送)。
  • 缺点:
    • 复杂性: API 相对复杂,需要处理 Service Worker 的注册、生命周期管理、更新等。
    • 非直接状态共享: 仍然是基于消息传递,而不是直接共享内存。
    • 调试困难: 调试 Service Worker 需要专门的工具。

1.3. WebSockets / Server-Side Sync

  • 工作原理: 通过 WebSocket 与服务器建立持久连接,服务器作为所有客户端的中央枢纽,负责接收一个客户端的更新,然后广播给所有其他客户端。
  • 优点: 真正的实时通信,可以跨越不同的浏览器甚至设备,实现多用户协作。
  • 缺点:
    • 需要后端支持: 增加了架构复杂性和部署成本。
    • 网络依赖: 离线或网络不稳定时无法工作。
    • 延迟: 数据需要往返服务器,可能存在网络延迟。

1.4. IndexedDB

  • 工作原理: 浏览器提供的、基于事务的、结构化数据存储方案。它比 localStorage 更强大,可以存储大量复杂数据。通过监听数据库的变更事件(虽然并非直接事件,通常需要结合 Mutation Observer 或轮询),可以实现同步。
  • 优点: 存储容量大,支持复杂数据结构,事务性操作保证数据完整性。
  • 缺点:
    • API 复杂: 异步操作,学习曲线陡峭。
    • 非实时事件:localStorage 一样,没有直接的跨标签页变更通知机制,仍需额外的同步逻辑。
    • 性能: 相较于内存操作,磁盘 IO 仍有开销。

1.5. BroadcastChannel

  • 工作原理: BroadcastChannel API 允许同源(same-origin)的不同浏览器上下文(如窗口、标签页、iframe 或 Web Worker)之间进行简单的消息通信。它提供了一个命名通道,所有连接到该通道的上下文都可以发送和接收消息。
  • 优点:
    • 简单易用: API 直观简洁。
    • 实时性: 消息传递几乎是即时的。
    • 浏览器原生: 无需服务器或其他复杂设置。
    • 事件驱动: 通过 onmessage 监听消息,无需轮询。
  • 缺点:
    • 同源限制: 只能在同源的标签页之间通信。
    • 非持久化: 消息不会被存储,如果标签页关闭或刷新,通道中的历史消息会丢失。
    • 不保证消息顺序: 虽然通常是顺序的,但在极端并发场景下,不应完全依赖其顺序。
    • 浏览器支持: 现代浏览器支持良好,但旧版浏览器可能不支持(IE 不支持)。

综合来看,BroadcastChannel 在许多常见的跨标签页状态同步场景中,提供了一个兼顾简洁性、实时性和原生支持的优秀解决方案。它避免了服务器依赖,同时比 localStorage 更加高效和直接。因此,我们将重点围绕它来构建我们的 React 状态同步系统。

2. BroadcastChannel 深入剖析

BroadcastChannel 的 API 非常简单,主要包含以下几个方法和事件:

  • new BroadcastChannel(name): 创建一个 BroadcastChannel 实例。name 是一个字符串,表示通道的名称。所有连接到相同名称通道的上下文都能互相通信。
  • channel.postMessage(message): 向通道中的所有监听者发送消息。message 可以是任何可结构化克隆(structured cloneable)的对象,包括基本类型、对象、数组等。
  • channel.onmessage = (event) => { ... }: 监听接收到的消息。event.data 包含了发送的消息内容。
  • channel.onmessageerror = (event) => { ... }: 监听消息发送或接收过程中发生的错误。
  • channel.close(): 关闭通道。这将断开当前上下文与通道的连接,不再接收或发送消息。

基本示例:

// 在 Tab A 中
const channelA = new BroadcastChannel('my_app_state_channel');
channelA.onmessage = (event) => {
  console.log('Tab A 收到消息:', event.data);
};
channelA.postMessage('来自 Tab A 的问候!');

// 在 Tab B 中(同源)
const channelB = new BroadcastChannel('my_app_state_channel');
channelB.onmessage = (event) => {
  console.log('Tab B 收到消息:', event.data);
};
channelB.postMessage('来自 Tab B 的回应!');

// 预期输出:
// 在 Tab A 控制台: "Tab A 收到消息: 来自 Tab B 的回应!"
// 在 Tab B 控制台: "Tab B 收到消息: 来自 Tab A 的问候!"

注意,一个标签页自己发送的消息,是不会通过 onmessage 回调接收到的。这是 BroadcastChannel 的一个重要特性,有助于避免无限循环。

3. 构建跨标签页 React 状态管理系统

我们的目标是创建一个 React Hook,类似于 useStateuseReducer,但其状态能够自动在所有同源的标签页之间同步。

3.1. 核心设计原则

  • 每标签页单点真相(SSoT per tab): 每个标签页维护自己的 React 状态树。
  • 事件驱动同步: BroadcastChannel 作为消息总线,当一个标签页的状态发生变化时,它会广播一个消息。其他标签页接收到消息后,更新自己的本地状态。
  • 消息标识: 为了避免处理自己发送的消息,以及进行冲突解决,每条消息都应包含发送者的唯一标识和版本信息。
  • 乐观更新: 当一个标签页修改状态时,它会首先在本地更新 UI(乐观更新),然后广播该状态变化。这提供了即时的用户反馈。

3.2. 架构概览

我们将构建一个 useSharedReducer Hook,它将结合 useReducer 的强大功能和 BroadcastChannel 的跨标签页通信能力。

  1. BroadcastChannel 实例: 为每个共享状态创建一个单独的 BroadcastChannel 实例。
  2. useReducer 用于管理本地状态和处理状态更新逻辑。
  3. 唯一标签页 ID: 每个标签页在加载时生成一个唯一的 ID,用于在广播消息中标识发送者。
  4. 消息结构: 定义一个标准的消息结构,包含动作类型、载荷、发送者 ID 和状态版本。
  5. 发送与接收:
    • 当本地状态通过 dispatch 更新时,广播相应的动作。
    • 当接收到来自其他标签页的广播消息时,根据消息内容更新本地状态。
  6. 冲突解决: 利用版本号来解决并发更新可能导致的冲突。

3.3. 逐步实现 useSharedReducer Hook

为了更好地理解,我们从一个简化版本开始,逐步添加功能。

步骤 1:基础 useSharedReducer – 广播本地更新

import { useEffect, useReducer, useRef, useCallback } from 'react';

// 生成一个简单的唯一 ID
const generateUniqueId = () => Math.random().toString(36).substring(2, 9);
const TAB_ID = generateUniqueId(); // 当前标签页的唯一 ID

/**
 * 基础的 useSharedReducer Hook
 * @param {string} channelName - BroadcastChannel 的名称
 * @param {Function} reducer - 状态更新的 reducer 函数
 * @param {*} initialState - 初始状态
 */
function useSharedReducer(channelName, reducer, initialState) {
  const [state, dispatch] = useReducer(reducer, initialState);
  const channelRef = useRef(null);

  // 初始化 BroadcastChannel
  useEffect(() => {
    // 确保只创建一次 BroadcastChannel 实例
    if (!channelRef.current) {
      channelRef.current = new BroadcastChannel(channelName);
    }

    const channel = channelRef.current;

    // 监听来自其他标签页的消息
    const handleMessage = (event) => {
      const { type, payload, senderId } = event.data;

      // 忽略自己发送的消息,避免无限循环
      if (senderId === TAB_ID) {
        return;
      }

      // 接收到其他标签页的动作后,在本地执行 dispatch
      // 注意:这里我们假设payload就是下一个状态,或者是一个action对象
      // 为了与useReducer结合,我们通常会广播action
      dispatch({ type, payload });
    };

    channel.addEventListener('message', handleMessage);

    return () => {
      // 在组件卸载时关闭通道
      channel.removeEventListener('message', handleMessage);
      // channel.close(); // 注意:如果多个组件使用同一个 channelName,不应直接关闭
      // 更好的做法是管理 channel 实例的生命周期,确保只在所有引用都消失时关闭
    };
  }, [channelName, reducer]);

  // 包装 dispatch 函数,使其在更新本地状态后,同时广播此更新
  const sharedDispatch = useCallback((action) => {
    // 1. 先在本地更新状态 (乐观更新)
    dispatch(action);

    // 2. 广播此动作给其他标签页
    if (channelRef.current) {
      channelRef.current.postMessage({
        type: action.type,
        payload: action.payload, // 或者直接发送 action 对象
        senderId: TAB_ID,
      });
    }
  }, []); // 注意:如果action包含函数或不可序列化数据,payload需要处理

  return [state, sharedDispatch];
}

export default useSharedReducer;

上述代码的问题与改进:

  1. sharedDispatch 的依赖: sharedDispatchuseCallback 依赖项为空数组,这意味着它会捕获初始的 dispatch。当 dispatch 引用改变时,sharedDispatch 不会更新。通常 dispatch 是稳定的,但为了严谨,可以依赖 dispatch
  2. action 结构: postMessage 里的 payload 应该是什么?如果 reducer 期望一个完整的 action 对象,那么广播时也应该发送完整的 action 对象。
  3. 状态初始化: 新开的标签页如何获取当前最新的状态?initialState 只能提供首次加载时的默认值。
  4. 冲突解决: 如果两个标签页几乎同时更新了状态,先广播的可能会被后广播的覆盖,导致数据丢失。

步骤 2:改进 sharedDispatch,引入版本控制和更完善的消息结构

为了解决上述问题,我们需要:

  • 在消息中包含一个状态版本号。
  • reducer 中处理版本号,确保只应用最新的状态。
  • 为新标签页提供一种请求当前状态的机制。
import { useEffect, useReducer, useRef, useCallback, useState } from 'react';

// 生成一个简单的唯一 ID
const generateUniqueId = () => Math.random().toString(36).substring(2, 9);
const TAB_ID = generateUniqueId(); // 当前标签页的唯一 ID

// 消息类型常量
const MESSAGE_TYPE = {
  ACTION: 'ACTION',
  REQUEST_STATE: 'REQUEST_STATE',
  RESPOND_STATE: 'RESPOND_STATE',
};

// 包装 reducer,使其能够处理版本号
const versionedReducerWrapper = (reducer) => (state, action) => {
  // 仅在接收到外部状态时,才进行版本比较
  if (action.__meta__ && action.__meta__.isRemote) {
    // 如果接收到的状态版本比本地旧,则忽略
    if (action.__meta__.version <= state.__meta__.version) {
      return state;
    }
  }

  // 执行原始 reducer
  const newState = reducer(state, action);

  // 更新版本号 (仅在本地发起action时递增,或者由外部更新时采用外部版本)
  const newVersion = action.__meta__ && action.__meta__.isRemote
    ? action.__meta__.version
    : (state.__meta__ ? state.__meta__.version + 1 : 1);

  return {
    ...newState,
    __meta__: {
      ...newState.__meta__,
      version: newVersion,
    }
  };
};

/**
 * 增强的 useSharedReducer Hook
 * @param {string} channelName - BroadcastChannel 的名称
 * @param {Function} reducer - 状态更新的 reducer 函数
 * @param {*} initialState - 初始状态
 */
function useSharedReducer(channelName, reducer, initialState) {
  // 包装后的 reducer,用于处理版本号
  const wrappedReducer = useCallback(versionedReducerWrapper(reducer), [reducer]);

  // 初始状态应该包含一个版本号
  const initialVersionedState = {
    ...initialState,
    __meta__: { version: 0 } // 初始化版本号为0
  };

  const [state, localDispatch] = useReducer(wrappedReducer, initialVersionedState);
  const channelRef = useRef(null);
  const isInitializingRef = useRef(true); // 标记是否是新标签页首次初始化

  // 初始化 BroadcastChannel
  useEffect(() => {
    if (!channelRef.current) {
      channelRef.current = new BroadcastChannel(channelName);
    }
    const channel = channelRef.current;

    const handleMessage = (event) => {
      const { type, payload, senderId, version } = event.data;

      if (senderId === TAB_ID) {
        return; // 忽略自己发送的消息
      }

      switch (type) {
        case MESSAGE_TYPE.ACTION:
          // 接收到其他标签页的动作,在本地执行,并标记为远程动作
          localDispatch({ ...payload, __meta__: { isRemote: true, version } });
          break;
        case MESSAGE_TYPE.REQUEST_STATE:
          // 如果其他标签页请求状态,则广播当前状态
          channel.postMessage({
            type: MESSAGE_TYPE.RESPOND_STATE,
            payload: state, // 发送当前完整状态 (包含版本号)
            senderId: TAB_ID,
            version: state.__meta__.version,
          });
          break;
        case MESSAGE_TYPE.RESPOND_STATE:
          // 接收到其他标签页响应的状态
          if (isInitializingRef.current) {
            // 仅在初始化阶段,且接收到的状态版本更新时才更新
            if (version > state.__meta__.version) {
              localDispatch({
                type: '@@SHARED_STATE_INIT', // 一个特殊的内部动作类型
                payload: payload, // payload 现在是完整的状态对象
                __meta__: { isRemote: true, version: payload.__meta__.version }
              });
            }
            isInitializingRef.current = false; // 接收到首次状态后,标记为已初始化
          }
          break;
        default:
          break;
      }
    };

    channel.addEventListener('message', handleMessage);

    // 新标签页启动时,请求当前状态
    if (isInitializingRef.current) {
      channel.postMessage({
        type: MESSAGE_TYPE.REQUEST_STATE,
        senderId: TAB_ID,
      });
      // 给予其他标签页响应的时间,如果一段时间内没有收到响应,则使用本地初始状态
      const timeout = setTimeout(() => {
        if (isInitializingRef.current) {
          isInitializingRef.current = false; // 假设没有其他标签页,直接完成初始化
        }
      }, 500); // 500ms 足够响应
    }

    return () => {
      channel.removeEventListener('message', handleMessage);
      // channel.close(); // 仍然不直接关闭,应由外部管理
    };
  }, [channelName, state, localDispatch, wrappedReducer]); // state 和 localDispatch 是稳定的

  // 包装 dispatch 函数
  const sharedDispatch = useCallback((action) => {
    // 1. 在本地更新状态 (乐观更新)
    // 注意:这里我们不能直接使用 `state.__meta__.version + 1`,因为 `state` 可能是闭包捕获的旧值
    // `localDispatch` 会在 reducer 中处理版本递增
    localDispatch(action);

    // 2. 广播此动作给其他标签页,附带当前最新版本号 (乐观地认为本地更新会成功)
    // 实际广播的 version 应该是 action 派发后 state 的版本。
    // 为了简化,我们可以在广播时,直接用 `action` 携带 `senderId`,
    // 并在接收端根据 `action.type` 和 `payload` 进行处理,让接收端的 `reducer` 来决定版本。
    // 但是,为了冲突解决,我们最好在发送时就带上预期的新版本号,
    // 或者更准确地,在 `localDispatch(action)` 之后,等待 `state` 更新,再广播。
    // 然而,`state` 更新是异步的。为了避免复杂性,我们让接收方通过 `state.__meta__.version` 来判断。
    // 因此,这里广播时不带 version,让接收方用自己的 reducer 逻辑处理。
    // 改进:为了版本冲突解决,广播时必须携带一个版本号,这个版本号应该是广播方在 dispatch 后计算出来的。
    // 由于 dispatch 是异步的,我们不能立即获取到新版本。
    // 我们可以让 `versionedReducerWrapper` 返回新状态的同时,也返回新版本号,或者将版本号作为 `state` 的一部分。
    // 最简单但有效的做法是:当一个标签页发送一个 ACTION 消息时,它包含了它当前状态的版本号。
    // 接收方根据此版本号与自己的版本号进行比较,以决定是否应用此动作。
    // 这是一个常见的“最后写入者获胜”的冲突解决策略。
    // 但是,这样的话,需要确保 `localDispatch` 之后的 `state` 是最新的。
    // 考虑到 `useReducer` 的 `dispatch` 是同步触发 `reducer` 的,`state` 会立即更新。
    // 所以,我们可以安全地从 `state` 中获取版本号。
    // 但 `state` 在 `useCallback` 的闭包中,可能不是最新的。
    // 更好的做法是,将版本号的逻辑完全交给 `reducer`。
    // 我们广播一个 action,这个 action 包含 senderId。
    // 接收方收到 action 后,用它自己的 reducer 处理,reducer 会处理版本冲突。

    // 为了确保广播的 action 带有正确的版本信息,
    // 我们需要确保 localDispatch 已经更新了 state,然后我们才能获取到最新的 version。
    // 但 Hook 的 state 更新是异步的,不能立即获取到最新的 state。
    // 这是一个挑战。一个常见的模式是,让 reducer 返回新状态和新版本,
    // 或者,让 action 携带一个客户端生成的唯一 ID,这样每个标签页可以跟踪它自己发出的 action。
    // 为了简化,我们采用一个更直接的方法:广播原始的 action,让接收方的 reducer 负责版本管理。

    // 重构这里的广播逻辑:
    // 我们需要知道此 action 最终会导致的状态版本。
    // 假设 `reducer` 在处理完 `action` 后,会将 `__meta__.version` 更新。
    // 那么,我们广播时,可以附带一个“预期的”新版本号,或者就依赖接收方的处理。

    // 最可靠的方法是:让 `reducer` 返回一个包含新 `state` 和 `newVersion` 的元组。
    // 但 `useReducer` 不支持。
    // 替代方案:在 `reducer` 内部处理版本,当 `dispatch` 发生时,
    // `state` 更新是同步的,但 `useSharedReducer` 外部的 `state` 引用可能不是最新的。

    // 考虑到 `useReducer` 内部的 `dispatch` 是同步执行 `reducer` 的,
    // `state` 在 `reducer` 返回后会立即更新。
    // 那么,我们可以在 `sharedDispatch` 内部,先 `localDispatch`,
    // 然后再获取最新的 `state` 的版本号进行广播。
    // 但这需要 `state` 是最新的。
    // 解决方案:让 `localDispatch` 返回新的 state 和 version。
    // 或者,让 `reducer` 每次都返回一个带有 `version` 的状态。
    // 简单起见,我们假设 `reducer` 在处理完 action 之后,`state` 会包含最新的 `version`。
    // 然而,由于 `sharedDispatch` 是 `useCallback` 包装的,它捕获的 `state` 可能不是最新的。
    // 这是一个经典的闭包陷阱。

    // 最终方案:让 `localDispatch` 后的副作用来广播。
    // 或者,在 `action` 中直接携带一个 `optimisticVersion`。
    // 我们采取一个更直接的方案:每次 `dispatch` 之后,`state` 会更新,
    // 我们在 `useEffect` 中监听 `state` 的变化并广播。
    // 但这样会广播所有状态变化,包括来自其他标签页的,会陷入无限循环。
    // 因此,我们必须在 `sharedDispatch` 内部立即广播。

    // 重新思考 `sharedDispatch` 逻辑:
    // 1. `localDispatch(action)`: 先在本地更新状态。
    // 2. 广播包含 `action` 和 `senderId` 的消息。
    // 3. 如何在广播时带上正确的 `version`?
    //    `state` 在 `sharedDispatch` 闭包中可能不是最新的。
    //    我们可以让 `reducer` 返回新的 `state` 和 `version`,并在 `sharedDispatch` 中捕获它。
    //    或者,在 `action` 中直接包含一个 `expectedVersion`。

    // 考虑到 `useReducer` 的 `dispatch` 返回 `void`,无法直接获取新状态。
    // 我们可以让 `versionedReducerWrapper` 内部维护一个全局的 version counter for this channel.
    // 但这会打破 React 的纯函数 reducer 约定。

    // 实际操作中,为了避免闭包陷阱和异步问题,
    // `sharedDispatch` 应该广播一个带有**此 action 预期导致的新版本号**的 action。
    // 或者,广播时只带 `senderId` 和 `action`,让接收方的 `reducer` 自己处理版本。
    // 后者更符合 `useReducer` 的纯函数理念。

    // 所以,我们重新调整 `versionedReducerWrapper` 和 `sharedDispatch`:
    // `versionedReducerWrapper` 负责在接收到远程 action 或本地 action 时,
    // 计算并更新 `__meta__.version`。
    // `sharedDispatch` 只负责广播原始 action,并附带 `senderId`。
    // 接收方收到后,会有一个 `__meta__.isRemote: true` 标记,
    // `versionedReducerWrapper` 会根据这个标记和 `payload` 中的版本号进行冲突解决。

    // 重新修正 `sharedDispatch`:
    localDispatch(action); // 先在本地更新

    // 广播此动作,让其他标签页也执行
    channelRef.current.postMessage({
      type: MESSAGE_TYPE.ACTION,
      payload: action, // 广播完整的 action 对象
      senderId: TAB_ID,
      // version: state.__meta__.version, // 这里不能用 state.version,因为 state 可能是旧的。
      // 接收方会根据自身的 state.version 和收到的 action 来决定是否应用。
      // 如果 action 包含了版本信息,那么这里可以传递。
      // 我们约定 `action` 本身不带版本,版本由 `__meta__` 携带。
      // 那么这里就不传 version。
    });
  }, [localDispatch]); // 依赖 localDispatch,它是稳定的

  // 内部辅助函数,用于新标签页初始化时,更新状态。
  // 这个函数会作为特殊的 action 传递给 reducer。
  const initSharedState = useCallback((remoteState) => {
    localDispatch({
      type: '@@SHARED_STATE_INIT',
      payload: remoteState,
      __meta__: { isRemote: true, version: remoteState.__meta__.version }
    });
  }, [localDispatch]);

  // 再次修正 `useEffect` 监听部分,特别是 `RESPOND_STATE` 处理
  useEffect(() => {
    if (!channelRef.current) {
      channelRef.current = new BroadcastChannel(channelName);
    }
    const channel = channelRef.current;

    const handleMessage = (event) => {
      const { type, payload, senderId } = event.data;

      if (senderId === TAB_ID) {
        return; // 忽略自己发送的消息
      }

      switch (type) {
        case MESSAGE_TYPE.ACTION:
          // 接收到其他标签页的动作,在本地执行
          // 这里的 payload 就是原始 action 对象
          localDispatch({ ...payload, __meta__: { isRemote: true } }); // 标记为远程动作
          break;
        case MESSAGE_TYPE.REQUEST_STATE:
          // 如果其他标签页请求状态,则广播当前状态
          channel.postMessage({
            type: MESSAGE_TYPE.RESPOND_STATE,
            payload: state, // 发送当前完整状态 (包含版本号)
            senderId: TAB_ID,
          });
          break;
        case MESSAGE_TYPE.RESPOND_STATE:
          // 接收到其他标签页响应的状态
          if (isInitializingRef.current) {
            // 仅在初始化阶段,且接收到的状态版本更新时才更新
            if (payload.__meta__.version > state.__meta__.version) {
              initSharedState(payload); // 使用辅助函数更新
            }
            isInitializingRef.current = false; // 接收到首次状态后,标记为已初始化
          }
          break;
        default:
          break;
      }
    };

    channel.addEventListener('message', handleMessage);

    // 新标签页启动时,请求当前状态
    if (isInitializingRef.current) {
      channel.postMessage({
        type: MESSAGE_TYPE.REQUEST_STATE,
        senderId: TAB_ID,
      });
      // 给予其他标签页响应的时间,如果一段时间内没有收到响应,则使用本地初始状态
      const timeout = setTimeout(() => {
        if (isInitializingRef.current) {
          isInitializingRef.current = false; // 假设没有其他标签页,直接完成初始化
          // 此时 state 仍是 initialStateWithVersion,这也没问题
        }
      }, 500); // 500ms 足够响应
    }

    return () => {
      channel.removeEventListener('message', handleMessage);
      // ... channelRef.current.close(); 可以在更高级的封装中处理
    };
  }, [channelName, state, localDispatch, initSharedState]); // 依赖 state,因为它在 RESPOND_STATE 中被读取

  return [state, sharedDispatch];
}

versionedReducerWrapper 的修订

versionedReducerWrapper 需要更精细地处理版本号。当接收到远程动作时,它应该使用远程动作携带的版本号;当本地动作发生时,它应该递增本地版本号。

// versionedReducerWrapper 需要被传入原始的 initialState,以确保版本正确初始化
const versionedReducerWrapper = (reducer, initialVersion = 0) => (state, action) => {
  // 如果是内部的初始化动作,直接使用 payload 作为新状态
  if (action.type === '@@SHARED_STATE_INIT') {
    return action.payload; // 此时 payload 已经是包含 meta 和 version 的完整状态
  }

  // 获取当前状态的版本号
  const currentVersion = state.__meta__ ? state.__meta__.version : initialVersion;

  if (action.__meta__ && action.__meta__.isRemote) {
    // 这是一个来自其他标签页的远程动作
    // 如果远程动作携带的版本比本地更旧或相同,则忽略此动作,避免回滚或重复
    if (action.__meta__.version <= currentVersion) {
      return state;
    }
    // 如果远程动作版本更新,则应用此动作
    const newState = reducer(state, action);
    return {
      ...newState,
      __meta__: {
        ...newState.__meta__,
        version: action.__meta__.version // 采用远程动作的版本
      }
    };
  } else {
    // 这是一个本地动作
    const newState = reducer(state, action);
    return {
      ...newState,
      __meta__: {
        ...newState.__meta__,
        version: currentVersion + 1 // 本地动作,版本号递增
      }
    };
  }
};

useSharedReducer 最终版本

结合上述改进,useSharedReducer 看起来会更加完整:

import { useEffect, useReducer, useRef, useCallback } from 'react';

// 生成一个简单的唯一 ID
const generateUniqueId = () => Math.random().toString(36).substring(2, 9);
const TAB_ID = generateUniqueId(); // 当前标签页的唯一 ID

// 消息类型常量
const MESSAGE_TYPE = {
  ACTION: 'ACTION',
  REQUEST_STATE: 'REQUEST_STATE',
  RESPOND_STATE: 'RESPOND_STATE',
};

// 包装 reducer,使其能够处理版本号和远程/本地动作
const createVersionedReducer = (reducer, initialVersion = 0) => (state, action) => {
  // 如果是内部的初始化动作,直接使用 payload 作为新状态
  if (action.type === '@@SHARED_STATE_INIT') {
    // 此时 payload 已经是包含 meta 和 version 的完整状态
    return action.payload;
  }

  const currentVersion = state.__meta__ ? state.__meta__.version : initialVersion;

  if (action.__meta__ && action.__meta__.isRemote) {
    // 这是一个来自其他标签页的远程动作
    // 如果远程动作携带的版本比本地更旧或相同,则忽略此动作,避免回滚或重复应用
    if (action.__meta__.version <= currentVersion) {
      return state;
    }
    // 如果远程动作版本更新,则应用此动作
    const newState = reducer(state, action);
    return {
      ...newState,
      __meta__: {
        ...newState.__meta__,
        version: action.__meta__.version // 采用远程动作的版本
      }
    };
  } else {
    // 这是一个本地动作
    const newState = reducer(state, action);
    return {
      ...newState,
      __meta__: {
        ...newState.__meta__,
        version: currentVersion + 1 // 本地动作,版本号递增
      }
    };
  }
};

/**
 * 跨标签页共享状态的 useSharedReducer Hook
 * @param {string} channelName - BroadcastChannel 的名称
 * @param {Function} reducer - 状态更新的 reducer 函数
 * @param {*} initialState - 初始状态
 */
function useSharedReducer(channelName, reducer, initialState) {
  // 初始状态应该包含一个版本号
  const initialVersionedState = {
    ...initialState,
    __meta__: { version: 0 } // 初始化版本号为0
  };

  const wrappedReducer = useCallback(createVersionedReducer(reducer, 0), [reducer]);
  const [state, localDispatch] = useReducer(wrappedReducer, initialVersionedState);
  const channelRef = useRef(null);
  const isInitializingRef = useRef(true); // 标记是否是新标签页首次初始化

  // 初始化 BroadcastChannel 和消息监听
  useEffect(() => {
    if (!channelRef.current) {
      channelRef.current = new BroadcastChannel(channelName);
    }
    const channel = channelRef.current;

    const handleMessage = (event) => {
      const { type, payload, senderId } = event.data;

      if (senderId === TAB_ID) {
        return; // 忽略自己发送的消息
      }

      switch (type) {
        case MESSAGE_TYPE.ACTION:
          // 接收到其他标签页的动作,在本地执行,并标记为远程动作
          localDispatch({ ...payload, __meta__: { isRemote: true, version: payload.__meta__?.version || 0 } });
          break;
        case MESSAGE_TYPE.REQUEST_STATE:
          // 如果其他标签页请求状态,则广播当前状态
          channel.postMessage({
            type: MESSAGE_TYPE.RESPOND_STATE,
            payload: state, // 发送当前完整状态 (包含版本号)
            senderId: TAB_ID,
          });
          break;
        case MESSAGE_TYPE.RESPOND_STATE:
          // 接收到其他标签页响应的状态
          if (isInitializingRef.current) {
            // 仅在初始化阶段,且接收到的状态版本更新时才更新
            if (payload.__meta__.version > state.__meta__.version) {
              localDispatch({
                type: '@@SHARED_STATE_INIT', // 一个特殊的内部动作类型
                payload: payload, // payload 现在是完整的状态对象
              });
            }
            isInitializingRef.current = false; // 接收到首次状态后,标记为已初始化
          }
          break;
        default:
          break;
      }
    };

    channel.addEventListener('message', handleMessage);

    // 新标签页启动时,请求当前状态
    if (isInitializingRef.current) {
      channel.postMessage({
        type: MESSAGE_TYPE.REQUEST_STATE,
        senderId: TAB_ID,
      });
      // 给予其他标签页响应的时间,如果一段时间内没有收到响应,则使用本地初始状态
      // 这个 timeout 确保即使没有其他标签页响应,新标签页也能完成初始化
      const timeoutId = setTimeout(() => {
        if (isInitializingRef.current) {
          isInitializingRef.current = false;
        }
      }, 500);

      return () => {
        clearTimeout(timeoutId);
        channel.removeEventListener('message', handleMessage);
        // 注意: channel.close() 的管理需要更精细,
        // 如果有多个 useSharedReducer 实例使用同一个 channelName,不应随意关闭。
        // 可以考虑使用一个全局的 Map 来管理 BroadcastChannel 实例及其引用计数。
      };
    }

    return () => {
      channel.removeEventListener('message', handleMessage);
    };
  }, [channelName, state, localDispatch, wrappedReducer]); // state 依赖是为了在 REQUEST_STATE 时能获取最新状态

  // 包装 dispatch 函数,使其在更新本地状态后,同时广播此更新
  const sharedDispatch = useCallback((action) => {
    // 1. 在本地更新状态 (乐观更新)
    localDispatch(action);

    // 2. 广播此动作给其他标签页
    // 为了让接收方能正确进行冲突解决,需要将当前状态的版本号随 action 一起广播
    // 但是,这里的 `state` 可能是闭包捕获的旧值。
    // `localDispatch` 是同步触发 reducer 的,但 `state` 的更新反映到组件可能需要重新渲染。
    // 因此,我们不能直接使用 `state.__meta__.version`。
    // 解决方案:让 `action` 携带一个乐观的 `version`。
    // 或者,等待 `state` 更新后在 `useEffect` 中广播。但这会很复杂。
    // 最终决定:**让 `reducer` 在处理本地动作时,计算并返回新的 `version`。**
    // 并且,**在广播时,我们不附带 `version`,而是在接收方收到消息时,让其自己的 `wrappedReducer` 负责处理版本。**
    // 这样,`action` 保持纯净,`__meta__` 只在 `reducer` 内部和消息传递时注入。

    // 重新修正 `postMessage`
    channelRef.current.postMessage({
      type: MESSAGE_TYPE.ACTION,
      payload: { ...action, __meta__: { version: state.__meta__.version + 1 } }, // 乐观地发送预期新版本
      senderId: TAB_ID,
    });
  }, [localDispatch, state]); // 依赖 state 以获取最新版本号

  return [state, sharedDispatch];
}

sharedDispatch 广播 version 的最终修正:

sharedDispatch 中,我们确实需要一个可靠的版本号来广播。由于 state 是在 useCallback 外部定义的,它在闭包中可能不是最新的。然而,useReducerdispatch 是同步执行 reducer 的。这意味着当 localDispatch(action) 执行完毕后,reducer 已经计算出新的状态和版本。

一个更安全的做法是,当 localDispatch 完成后,我们实际上希望广播的是此 action 导致的新状态的版本。为了获取这个版本,我们可以在 reducer 中将版本号作为 action 的一部分返回,或者在 useRef 中存储最新的 state

最简洁的解决方案:
我们让 createVersionedReducer 在处理本地动作时,将递增后的版本号注入到 action.__meta__.version 中,然后 sharedDispatch 广播这个已经带有最新版本号的 action

// 重新定义 createVersionedReducer
const createVersionedReducer = (reducer, initialVersion = 0) => (state, action) => {
  if (action.type === '@@SHARED_STATE_INIT') {
    return action.payload;
  }

  const currentVersion = state.__meta__ ? state.__meta__.version : initialVersion;

  if (action.__meta__ && action.__meta__.isRemote) {
    // 远程动作,如果版本更旧或相同则忽略
    if (action.__meta__.version <= currentVersion) {
      return state;
    }
    const newState = reducer(state, action);
    return {
      ...newState,
      __meta__: {
        ...newState.__meta__,
        version: action.__meta__.version // 采用远程版本
      }
    };
  } else {
    // 本地动作
    const newState = reducer(state, action);
    const newVersion = currentVersion + 1; // 递增版本
    return {
      ...newState,
      __meta__: {
        ...newState.__meta__,
        version: newVersion
      }
    };
  }
};

// ... useSharedReducer 内部的 sharedDispatch
const sharedDispatch = useCallback((action) => {
  // 1. 本地更新状态
  localDispatch(action);

  // 2. 广播此动作。
  // 注意:这里的 action 在 localDispatch 之后,其 __meta__.version 已经被 createVersionedReducer 更新了。
  // 但由于闭包,这里的 `action` 变量本身不会被改变。
  // 我们需要捕获 `localDispatch` 后的最新状态的版本。
  // 解决方案:使用 `useRef` 存储最新状态,或者在 `useEffect` 中监听 `state` 变化并广播。
  // 再次回到前面提到的闭包问题。

  // 更可靠的策略:
  // 1. `localDispatch(action)` 执行。
  // 2. 立即从 `stateRef.current` (如果使用了 `useRef` 来跟踪最新状态) 获取版本。
  // 3. 广播 action 和获取到的版本。

  // 为了避免 `state` 闭包问题,我们可以在 `reducer` 内部计算出新版本,
  // 然后通过 `useRef` 来存储这个新版本,供 `sharedDispatch` 访问。
  // 这会使 `reducer` 不再是纯函数,我们尽量避免。

  // 最终的、相对简单的折衷方案:
  // 广播时,**直接将 `state.__meta__.version + 1` 作为预期的 `version` 随 `action` 广播。**
  // 接收方收到后,`createVersionedReducer` 会进行版本比较。
  // 这种方式是乐观的,假设本地更新会成功,并且能正确计算出下一个版本。

  channelRef.current.postMessage({
    type: MESSAGE_TYPE.ACTION,
    payload: {
      ...action,
      // 乐观地预测下一个版本号,这个版本号将用于接收方的冲突解决
      // 注意:这里的 state 是闭包中的,可能不是最新的。
      // 最好的做法是让 `localDispatch` 返回一个 promise,
      // promise resolved 后包含最新版本,然后广播。
      // 但 useReducer 的 dispatch 无法返回 promise。
      // 所以,依赖 `state` 闭包是无法避免的。
      // 这里的 `state` 是在 `useCallback` 创建时捕获的。
      // 除非 `useCallback` 的依赖项包含 `state`,否则 `state` 不会更新。
      // 但是如果依赖 `state`,`sharedDispatch` 会频繁重建,这也不是我们想要的。

      // 再次思考:如果 `localDispatch` 之后,`state` 没有立即更新,
      // 那么 `state.__meta__.version + 1` 就不是实际的新版本。
      // 这是一个核心的异步挑战。

      // 考虑到 `useReducer` 的 `dispatch` 是同步执行 `reducer` 的,
      // 那么 `state` 应该在 `dispatch` 之后立即更新。
      // 这里的 `state` 变量本身是闭包捕获的,但是 React 内部会处理 `useReducer` 的 `state` 更新。
      // `sharedDispatch` 的依赖项是 `localDispatch`,它是稳定的。
      // 如果我们想要 `state` 是最新的,必须将 `state` 加入 `useCallback` 的依赖项。
      // 假设我们将 `state` 加入 `sharedDispatch` 的依赖项。
      // 那么 `sharedDispatch` 将在每次 `state` 变化时重新创建。
      // 这会带来性能开销,但确保了 `state` 的新鲜度。

      // 将 `state` 加入 `sharedDispatch` 的依赖项:
      // `const sharedDispatch = useCallback((action) => { ... }, [localDispatch, state]);`
      // 这样 `state.__meta__.version` 就会是最新值。
      // 这似乎是最好的折衷方案。

      // ...action,
      __meta__: { version: state.__meta__.version + 1 } // 附加预期的新版本
    },
    senderId: TAB_ID,
  });
}, [localDispatch, state]); // 将 state 加入依赖项以获取最新版本

完整 useSharedReducer 示例

import { useEffect, useReducer, useRef, useCallback } from 'react';

const generateUniqueId = () => Math.random().toString(36).substring(2, 9);
const TAB_ID = generateUniqueId();

const MESSAGE_TYPE = {
  ACTION: 'ACTION',
  REQUEST_STATE: 'REQUEST_STATE',
  RESPOND_STATE: 'RESPOND_STATE',
};

// 创建一个包装后的 reducer,用于处理版本号和远程/本地动作
const createVersionedReducer = (reducer, initialVersion = 0) => (state, action) => {
  // 内部初始化动作,直接使用 payload 作为新状态
  if (action.type === '@@SHARED_STATE_INIT') {
    return action.payload;
  }

  const currentVersion = state.__meta__ ? state.__meta__.version : initialVersion;

  if (action.__meta__ && action.__meta__.isRemote) {
    // 远程动作,如果版本更旧或相同则忽略
    if (action.__meta__.version <= currentVersion) {
      return state;
    }
    const newState = reducer(state, action);
    return {
      ...newState,
      __meta__: {
        ...newState.__meta__,
        version: action.__meta__.version, // 采用远程版本
      },
    };
  } else {
    // 本地动作
    const newState = reducer(state, action);
    const newVersion = currentVersion + 1; // 本地动作,版本号递增
    return {
      ...newState,
      __meta__: {
        ...newState.__meta__,
        version: newVersion,
      },
    };
  }
};

/**
 * 跨标签页共享状态的 useSharedReducer Hook
 * @param {string} channelName - BroadcastChannel 的名称
 * @param {Function} reducer - 状态更新的 reducer 函数
 * @param {*} initialState - 初始状态
 */
function useSharedReducer(channelName, reducer, initialState) {
  // 初始状态应该包含一个版本号
  const initialVersionedState = {
    ...initialState,
    __meta__: { version: 0 },
  };

  const wrappedReducer = useCallback(createVersionedReducer(reducer, 0), [reducer]);
  const [state, localDispatch] = useReducer(wrappedReducer, initialVersionedState);
  const channelRef = useRef(null);
  const isInitializingRef = useRef(true); // 标记是否是新标签页首次初始化

  // 初始化 BroadcastChannel 和消息监听
  useEffect(() => {
    if (!channelRef.current) {
      channelRef.current = new BroadcastChannel(channelName);
    }
    const channel = channelRef.current;

    const handleMessage = (event) => {
      const { type, payload, senderId } = event.data;

      if (senderId === TAB_ID) {
        return; // 忽略自己发送的消息
      }

      switch (type) {
        case MESSAGE_TYPE.ACTION:
          // 接收到其他标签页的动作,在本地执行
          localDispatch({
            ...payload, // payload 已经是带有 `__meta__.version` 的 action
            __meta__: { isRemote: true, version: payload.__meta__.version },
          });
          break;
        case MESSAGE_TYPE.REQUEST_STATE:
          // 如果其他标签页请求状态,则广播当前状态
          channel.postMessage({
            type: MESSAGE_TYPE.RESPOND_STATE,
            payload: state, // 发送当前完整状态 (包含版本号)
            senderId: TAB_ID,
          });
          break;
        case MESSAGE_TYPE.RESPOND_STATE:
          // 接收到其他标签页响应的状态
          if (isInitializingRef.current) {
            // 仅在初始化阶段,且接收到的状态版本更新时才更新
            if (payload.__meta__.version > state.__meta__.version) {
              localDispatch({
                type: '@@SHARED_STATE_INIT', // 内部动作类型
                payload: payload, // payload 是完整的状态对象
              });
            }
            isInitializingRef.current = false; // 接收到首次状态后,标记为已初始化
          }
          break;
        default:
          break;
      }
    };

    channel.addEventListener('message', handleMessage);

    // 新标签页启动时,请求当前状态
    if (isInitializingRef.current) {
      channel.postMessage({
        type: MESSAGE_TYPE.REQUEST_STATE,
        senderId: TAB_ID,
      });
      // 给予其他标签页响应的时间,如果一段时间内没有收到响应,则使用本地初始状态
      const timeoutId = setTimeout(() => {
        if (isInitializingRef.current) {
          isInitializingRef.current = false;
        }
      }, 500);

      return () => {
        clearTimeout(timeoutId);
        channel.removeEventListener('message', handleMessage);
      };
    }

    return () => {
      channel.removeEventListener('message', handleMessage);
    };
  }, [channelName, state, localDispatch]); // state 依赖是为了在 REQUEST_STATE 时能获取最新状态

  // 包装 dispatch 函数,使其在更新本地状态后,同时广播此更新
  const sharedDispatch = useCallback(
    (action) => {
      // 1. 在本地更新状态 (乐观更新)
      localDispatch(action);

      // 2. 广播此动作给其他标签页
      // 此时 `state` 已经通过 `localDispatch` 和 `wrappedReducer` 更新了
      // 但由于 `useCallback` 的闭包,这里的 `state` 仍然是捕获的旧值。
      // 重新思考: `localDispatch(action)` 触发 `wrappedReducer` 同步更新了 React 内部状态,
      // 但 `useSharedReducer` 组件的 `state` 变量引用只有在下次渲染时才会更新。
      // 因此,我们不能在这里依赖 `state.__meta__.version` 获取最新值。
      // 最好的做法是让 `action` 携带其自身的版本信息,但 `action` 应该是纯净的。

      // 最终方案:我们将 `action` 和 `senderId` 广播出去,
      // 让接收方的 `createVersionedReducer` 负责处理版本和冲突。
      // 唯一需要带版本的地方是 `RESPOND_STATE`,因为它发送的是完整的状态。
      // 对于 `ACTION` 消息,我们不带版本,让接收方 `reducer` 决定。
      // 但这意味着 `createVersionedReducer` 在处理远程 ACTION 时,
      // 需要能够从 `action` 本身获取版本信息,
      // 这样 `action.__meta__.version` 就必须在发送时就包含。
      // 这又回到了 `sharedDispatch` 广播时需要知道新版本的问题。

      // 进一步优化:让 `createVersionedReducer` 返回一个新的 `action` 对象,
      // 其中包含最新的版本号。
      // 这样,`sharedDispatch` 就可以广播这个带有最新版本号的 `action`。
      // 但 `useReducer` 的 `dispatch` 不会返回任何东西。

      // 再次简化:`sharedDispatch` 广播 `action`,但不包含版本号。
      // 接收方 `createVersionedReducer` 在处理远程 `ACTION` 时,
      // 它的 `action` 参数并没有 `__meta__.version`。
      // 那么 `createVersionedReducer` 如何知道远程动作的版本?
      // 它无法知道,只能假定它比本地新。
      // 这会导致“最后写入者获胜”的策略失效。

      // **正确的冲突解决策略需要消息携带版本号。**
      // 既然 `state` 依赖项会使 `sharedDispatch` 重新创建,那就让它依赖。
      // 确保 `state` 在 `sharedDispatch` 中是最新值。

      channelRef.current.postMessage({
        type: MESSAGE_TYPE.ACTION,
        payload: {
          ...action,
          __meta__: { version: state.__meta__.version + 1 }, // 乐观地携带预期新版本
        },
        senderId: TAB_ID,
      });
    },
    [localDispatch, state] // 依赖 state,确保获取最新版本
  );

  return [state, sharedDispatch];
}

使用示例:

import React from 'react';
import useSharedReducer from './useSharedReducer'; // 假设你的 hook 在这个路径

// 状态 reducer 函数
const counterReducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'DECREMENT':
      return { ...state, count: state.count - 1 };
    case 'SET_COUNT':
      return { ...state, count: action.payload };
    default:
      return state;
  }
};

function CounterApp() {
  const [state, dispatch] = useSharedReducer('my-shared-counter', counterReducer, { count: 0 });

  return (
    <div>
      <h1>共享计数器:{state.count} (版本: {state.__meta__.version})</h1>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>增加</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>减少</button>
      <button onClick={() => dispatch({ type: 'SET_COUNT', payload: 0 })}>重置</button>
      <p>当前标签页 ID: {TAB_ID}</p>
    </div>
  );
}

export default CounterApp;

在你的 React 应用中,你可以在不同的组件中使用 CounterApp,或者在不同的标签页中打开 CounterApp,它们的状态将自动同步。

4. 实践考量与最佳实践

4.1. BroadcastChannel 实例的生命周期管理

在上述 useSharedReducer 中,channelRef.current.close() 被注释掉了。这是因为如果多个组件或多个 useSharedReducer 实例使用了相同的 channelName,那么一个组件卸载时关闭通道,可能会影响到其他仍在使用该通道的组件。

更好的做法:
创建一个全局的 Map 来管理 BroadcastChannel 实例和它们的引用计数。

// broadcastChannelManager.js
const channels = new Map(); // { channelName: { instance: BroadcastChannel, refCount: number } }

export const getBroadcastChannel = (name) => {
  if (!channels.has(name)) {
    const channelInstance = new BroadcastChannel(name);
    channels.set(name, { instance: channelInstance, refCount: 0 });
  }
  const channelEntry = channels.get(name);
  channelEntry.refCount++;
  return channelEntry.instance;
};

export const releaseBroadcastChannel = (name) => {
  const channelEntry = channels.get(name);
  if (channelEntry) {
    channelEntry.refCount--;
    if (channelEntry.refCount <= 0) {
      channelEntry.instance.close();
      channels.delete(name);
    }
  }
};

然后在 useSharedReducer 中使用这个管理器:

// ... useSharedReducer
import { getBroadcastChannel, releaseBroadcastChannel } from './broadcastChannelManager';

// ...
useEffect(() => {
  const channel = getBroadcastChannel(channelName);
  channelRef.current = channel; // 存储实例以便在 sharedDispatch 中使用

  // ... 消息监听逻辑

  return () => {
    channel.removeEventListener('message', handleMessage);
    releaseBroadcastChannel(channelName);
  };
}, [channelName, state, localDispatch]); // 依赖项不变

4.2. 消息结构标准化

一个清晰的消息结构有助于不同标签页之间理解和处理消息。

字段 类型 描述 示例值
type string 消息的类型(ACTION, REQUEST_STATE, RESPOND_STATE) MESSAGE_TYPE.ACTION
payload object 消息的具体内容。对于 ACTION 消息,是 reducer 的 action 对象;对于 RESPOND_STATE,是完整的状态对象。 { type: 'INCREMENT' }{ count: 5, __meta__: { version: 10 } }
senderId string 发送此消息的标签页的唯一 ID 'abcdefg'
__meta__ object 包含额外元数据,如 versionisRemote { version: 10 }

4.3. 性能优化:节流与防抖

如果状态变化非常频繁(如用户输入),每次变化都广播可能导致性能问题。可以考虑对 sharedDispatch 进行节流(throttle)或防抖(debounce)。

import { throttle } from 'lodash'; // 或自定义实现

// ... useSharedReducer 内部
const throttledPostMessage = useCallback(
  throttle((message) => {
    channelRef.current.postMessage(message);
  }, 100), // 每 100ms 最多广播一次
  []
);

const sharedDispatch = useCallback(
  (action) => {
    localDispatch(action);
    throttledPostMessage({
      type: MESSAGE_TYPE.ACTION,
      payload: {
        ...action,
        __meta__: { version: state.__meta__.version + 1 },
      },
      senderId: TAB_ID,
    });
  },
  [localDispatch, state, throttledPostMessage]
);

4.4. 初始状态的持久化

当所有标签页都关闭,然后再次打开应用时,BroadcastChannel 无法提供初始状态。此时,可以结合 localStorageIndexedDB 来持久化状态。

  1. useSharedReducer 内部,当状态更新时,将最新状态保存到 localStorage
  2. 当组件首次加载时,尝试从 localStorage 读取状态作为 initialState 如果 localStorage 中没有,再使用默认的 initialState
// ... useSharedReducer
// 初始状态读取逻辑
const getInitialStateWithPersistence = (channelName, defaultState) => {
  try {
    const persistedState = localStorage.getItem(`shared_state_${channelName}`);
    if (persistedState) {
      const parsedState = JSON.parse(persistedState);
      // 确保解析后的状态有 __meta__ 和 version
      if (parsedState.__meta__ && typeof parsedState.__meta__.version === 'number') {
        return parsedState;
      }
    }
  } catch (error) {
    console.error('Failed to parse persisted state:', error);
  }
  return { ...defaultState, __meta__: { version: 0 } };
};

// ...
function useSharedReducer(channelName, reducer, initialState) {
  const initialVersionedState = getInitialStateWithPersistence(channelName, initialState);
  const wrappedReducer = useCallback(createVersionedReducer(reducer, initialVersionedState.__meta__.version), [reducer]);
  const [state, localDispatch] = useReducer(wrappedReducer, initialVersionedState);

  // ...
  // 在 useEffect 中,当 state 变化时,保存到 localStorage
  useEffect(() => {
    localStorage.setItem(`shared_state_${channelName}`, JSON.stringify(state));
  }, [channelName, state]);

  // ... 其余逻辑
}

4.5. 浏览器兼容性

BroadcastChannel 在现代浏览器(Chrome, Firefox, Edge, Safari)中支持良好,但 IE 不支持。对于需要兼容旧版浏览器的应用,可能需要提供降级方案(例如,退回只在当前标签页有效,或者使用 localStoragestorage 事件作为替代,但会有上述 localStorage 的局限性)。

4.6. 安全性

BroadcastChannel 受同源策略限制,这意味着只有来自相同协议、主机和端口的页面才能通信。这为跨标签页通信提供了一定程度的安全性。然而,仍然需要注意不要通过 BroadcastChannel 传输敏感的用户凭据或未经加密的数据。

5. 进阶模式与未来展望

5.1. 结合 Context API

可以将 useSharedReducer 封装到一个 React Context 中,以便在组件树中更方便地访问共享状态和 dispatch 函数,类似于 Redux 或标准 useReducer 的 Context 模式。

// SharedStateContext.js
import React, { createContext, useContext } from 'react';
import useSharedReducer from './useSharedReducer'; // 你的 hook 路径

const SharedStateContext = createContext();

export function createSharedStore(channelName, reducer, initialState) {
  const useStore = () => useSharedReducer(channelName, reducer, initialState);

  const Provider = ({ children }) => {
    const [state, dispatch] = useStore();
    return (
      <SharedStateContext.Provider value={{ state, dispatch }}>
        {children}
      </SharedStateContext.Provider>
    );
  };

  const useSharedState = () => {
    const context = useContext(SharedStateContext);
    if (!context) {
      throw new Error(`useSharedState must be used within a Provider from createSharedStore(${channelName})`);
    }
    return context.state;
  };

  const useSharedDispatch = () => {
    const context = useContext(SharedStateContext);
    if (!context) {
      throw new Error(`useSharedDispatch must be used within a Provider from createSharedStore(${channelName})`);
    }
    return context.dispatch;
  };

  return { Provider, useSharedState, useSharedDispatch };
}

// Usage in App.js
// const { Provider, useSharedState, useSharedDispatch } = createSharedStore('my-shared-counter', counterReducer, { count: 0 });
// function App() {
//   return (
//     <Provider>
//       <CounterApp />
//     </Provider>
//   );
// }
//
// function CounterApp() {
//   const state = useSharedState();
//   const dispatch = useSharedDispatch();
//   // ... rest of component
// }

5.2. 更复杂的冲突解决策略

“最后写入者获胜”是简单有效的策略,但可能导致数据丢失。对于需要更高数据一致性的场景,可以考虑:

  • 操作转换 (Operational Transformation, OT): 类似于 Google Docs,记录一系列操作,并在不同版本上进行转换以保持一致性。这通常需要更复杂的算法和服务器端支持。
  • 并发控制版本号 (CCV): 记录每个字段的版本号,而非整个状态的版本号,从而实现更细粒度的冲突解决。

5.3. 领导者选举 (Leader Election)

在某些情况下,可能需要一个“领导者”标签页来执行特定的任务,例如:

  • 初始数据加载(只由一个标签页从服务器加载)。
  • 复杂的数据写入操作(避免多个标签页同时写入导致竞态)。
  • 后台任务协调。

领导者选举可以通过 BroadcastChannel 结合 localStorage 实现一个简单的算法:

  1. 新标签页启动时,尝试在 localStorage 中写入自己的 TAB_ID 和一个时间戳,声明自己是潜在领导者。
  2. 广播 REQUEST_LEADER 消息。
  3. 如果其他标签页收到 REQUEST_LEADER,且它认为自己是领导者(例如,它的 localStorage 时间戳更早),则广播 I_AM_LEADER 消息。
  4. 请求者如果收到 I_AM_LEADER,则放弃领导权。如果一段时间内未收到,则正式成为领导者,并在 localStorage 中更新。
  5. 领导者需要定期发送心跳消息。

5.4. 离线能力与 Service Worker 整合

对于需要强大离线同步能力的应用,可以将 BroadcastChannel 与 Service Worker 结合。Service Worker 可以作为中央代理,拦截网络请求并与 IndexedDB 配合存储离线数据。当网络恢复时,Service Worker 协调所有标签页进行数据同步。BroadcastChannel 仍可用于标签页之间的实时通知。

总结

BroadcastChannel 为 Web 开发者提供了一个强大而简洁的工具,用于解决同源多标签页之间的状态同步问题。通过精心设计的协调算法,结合 React 的 useReduceruseEffect 等 Hook,我们可以构建出高效、健壮的跨标签页共享状态系统。这不仅能显著提升用户体验,确保应用在多窗口环境下的行为一致性,也为构建更复杂的实时协作应用奠定了基础。随着 Web 技术的不断演进,对这类问题的解决方案也将持续优化和丰富,为用户带来更加无缝的 Web 体验。

发表回复

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