陌上人如玉
公子世无双

3.14 跨标签页同步

3.14 跨标签页同步

一句话:跨标签页同步的技术选型是「BroadcastChannel 优先、storage 事件降级、SharedWorker 兜底」,而真正的难点不在「怎么发消息」,而在「回声去重、冲突解决、写放大、以及『只让一个标签页做后台任务』(Leader 选举)」。

一、什么时候需要它

⭐⭐ 五个「不做就会出问题」的场景:

① 【登录 / 登出】
   标签页 A 退出登录 → 标签页 B 还显示着用户信息、还能点击操作
   ⚠️ 用户会以为「没退成功」,而且 B 的请求会 401
   ✅ 必须同步

② 【主题 / 语言 / 偏好】
   A 切到暗色 → B 还是亮的(同一个用户的偏好,应该一致)

③ 【购物车 / 订单状态】
   A 加购 → B 的角标不变(用户困惑「我加的在哪」)
   ⚠️ 且「同时下单」可能重复提交

④ 【通知已读 / 未读数】
   A 点开消息 → B 的未读角标还在(⭐ 很显眼的体验缺陷)

⑤ 【草稿 / 表单内容】
   A 和 B 同时编辑同一份草稿 → 互相覆盖(⭐ 需要「冲突处理」,见下文)

⭐ 反向判据:「**这个状态如果两个标签页不一致,用户会不会困惑或出错**」——
   会 → 需要同步;不会(如「某个折叠面板展开了」)→ 不需要。

二、四种方案:能力与取舍

方案 机制 优点 缺点
⭐ BroadcastChannel 同源的多个「浏览器上下文」共享一个频道 全双工、无存储副作用、可传结构化数据、性能好 ⚠️ Safari 15.4+ / 不支持 IE(需要降级)
storage 事件 其他标签页改 localStorage 时触发 ✅ 兼容性最好、天然持久化 ⚠️ 不触发自己所在的标签页;⚠️ 只能传字符串;⚠️ 与「真正的持久化写入」耦合
SharedWorker 多个标签页共享同一个 Worker 实例 ⭐ 可以做「中心化的状态」+ 广播 ⚠️ 兼容性差(Safari 支持但行为有差异)、调试麻烦
Service Worker 通过 SW 中转广播 ⭐ 已有 SW 时零成本 ⚠️ 需要 SW 已激活、消息延迟、语义不适合「实时同步」
// ✅ BroadcastChannel:最推荐的方案
const channel = new BroadcastChannel('app-sync');

// 发送(结构化数据可以直接传,会被「结构化克隆」)
channel.postMessage({ type: 'AUTH_LOGOUT', at: Date.now() });

// 接收
channel.onmessage = (event) => {
  console.log(event.data);
};

// ⭐ 关闭(⭐ 必须:否则「SPA 长时间不卸载」会积累 channel)
channel.close();
// ✅ storage 事件的用法与「三个必须知道的细节」
window.addEventListener('storage', (event) => {
  // ⭐ 细节 1:只有「其他标签页」修改才会触发(自己改自己不触发)
  // ⭐ 细节 2:event.key 为 null 表示「storage 被 clear()」
  // ⭐ 细节 3:event.newValue / oldValue 都是字符串(要自己解析)
  if (event.key !== 'app-state') return;
  if (!event.newValue) return;
  const next = JSON.parse(event.newValue);
  applyRemoteState(next);
});

// 发送(改 localStorage 来「广播」)
localStorage.setItem('app-state', JSON.stringify(state));
// ⚠️ 副作用:「广播」与「持久化」耦合了 ——
//    如果你只想广播(不想持久化),就得「写入再删除」或用别的 key
⭐⭐⭐ 「`storage` 事件不触发自己」这个细节,是很多 bug 的根源:

   【设计意图】storage 事件是「**通知其他标签页『我改了』**」——
     改动的那个标签页「本来就知道自己改了」,不需要再通知一次。
   ⭐ 所以正确的心智是:「**本地先改(乐观),然后再广播**」。

   ❌ 常见 bug:写了「监听 storage 事件 → 更新本地状态」,
     结果「本标签页修改后,状态没更新」(因为它收不到自己的事件)——
     开发者会以为「事件没触发 / 代码写错了」,其实是设计如此。
   ✅ 正确写法:
     function setState(next) {
       localState = next;                          // ⭐ ① 先改本地
       localStorage.setItem(KEY, JSON.stringify(next));   // ⭐ ② 广播给其它标签页
     }

三、四个真正的难点

3.1 回声(Echo):自己发的消息自己收到

⭐⭐⭐ 问题:BroadcastChannel 会「把消息发给所有同源的上下文」——
   ⚠️ 很多实现里**包括发送者自己**(规范上「不含发送它的那个对象」,
     但如果你有「两个 channel 对象」或「另一个标签页中有多个 consumer」,
     就会「自己收到自己的消息」→ 重复应用状态 → 出 bug)。

   场景:
     A 标签页「加购」→ 广播 { type: 'ADD_ITEM', item } →
     A 自己也收到了 → 「再加一次」→ ⭐ 商品数量翻倍

⭐ 解法:**每条消息带「发送者 id」,收到自己发的就忽略**
   const TAB_ID = crypto.randomUUID();
   const msg = { id: crypto.randomUUID(), from: TAB_ID, ... };
   channel.onmessage = (e) => {
     if (e.data.from === TAB_ID) return;          // ⭐ 忽略自己
     apply(e.data);
   };
   ⭐ 更严格的做法:用「消息 id 的 LRU 集合」去重
     (防止「同一条消息被投递两次」这类边界情况)

3.2 冲突:多个标签页同时写

⭐⭐⭐ 三种冲突解决策略(按复杂度和适用性排序):

① 【最后写入赢(LWW, Last-Write-Wins)】⭐ 最常用、最简单
   每条消息带 `timestamp`(或「Lamport 时钟」/「版本号」),
   收到时「只应用更新的那个」。
   ✅ 简单、无冲突处理
   ⚠️ 代价:会「静默丢失」较早的写入
   ⭐ 适合:「偏好设置、主题、已读标记」这类「后写覆盖前写」语义的数据

② 【操作合并(CRDT 思想)】
   把「状态」改成「操作」(如「+1」而不是「=5」),
   然后做「可交换的合并」(计数器、集合的并集、文本的 RGA)。
   ✅ 不会丢数据
   ⚠️ 复杂(需要理解 CRDT 的语义;文本编辑的 CRDT 相当复杂)
   ⭐ 适合:「计数器、点赞、集合增删」

③ 【加锁(Leader / 单一写者)】
   只让「一个标签页」做写入(其它标签页「只读 + 请求它改」)。
   ✅ 彻底避免冲突
   ⚠️ 需要「Leader 选举」机制(见 3.4)
   ⭐ 适合:「会写服务端的关键数据」(如「只让一个标签页轮询接口」)
// ✅ LWW 的最小实现(带「版本号 + 时间戳」)
interface SyncMessage<T> {
  id: string;
  from: string;
  /** ⭐ 逻辑版本号(比「墙钟时间」可靠,避免「时钟回拨/不同机器」的问题) */
  version: number;
  /** 墙钟时间(仅用于展示/调试) */
  at: number;
  patch: Partial<T>;
}

function createLwwSync<T extends object>(key: string, initial: T, onRemote: (next: T) => void) {
  let localVersion = 0;
  let state = { ...initial };

  const channel = new BroadcastChannel(key);
  const seenIds = new Set<string>();          // ⭐ 去重(防同一条消息投递两次)

  function apply(msg: SyncMessage<T>) {
    // ⭐ ① 回声与重复过滤
    if (msg.from === TAB_ID) return;
    if (seenIds.has(msg.id)) return;
    seenIds.add(msg.id);
    // ⭐ 限制 seenIds 的大小(避免无限增长)
    if (seenIds.size > 1000) {
      const first = seenIds.values().next().value;
      if (first) seenIds.delete(first);
    }

    // ⭐⭐ ② LWW:只应用「比本地更新」的
    if (msg.version <= localVersion) return;

    localVersion = msg.version;
    state = { ...state, ...msg.patch };
    onRemote(state);
  }

  channel.onmessage = (e) => apply(e.data as SyncMessage<T>);

  return {
    /** ⭐ 本地修改:先改本地,再广播(版本号 +1) */
    set(patch: Partial<T>) {
      state = { ...state, ...patch };
      localVersion += 1;
      channel.postMessage({
        id: crypto.randomUUID(),
        from: TAB_ID,
        version: localVersion,
        at: Date.now(),
        patch,
      } satisfies SyncMessage<T>);
      return state;
    },
    get: () => state,
    close: () => channel.close(),
  };
}

const TAB_ID = crypto.randomUUID();

3.3 写放大:频繁写入导致消息风暴

⭐⭐⭐ 问题:如果「每次输入都广播」,会有两个后果:
   ① 消息风暴(每个字符一条消息 × N 个标签页)
   ② 如果「广播挂在 localStorage 上」,还会「每次写盘」(⭐ 阻塞主线程,见 3.15)

⭐ 解法:**节流合并(throttle + coalesce)**
   · 把「短时间内的多次修改」合并成「一次广播」
   · ⭐ 注意:合并要「按字段合并」(用 Object.assign),
     而不是「丢弃前面的」(否则 A 改 x、B 改 y 时可能丢一个)
   · 对「高频输入」用「防抖(debounce,如 300ms 无输入才广播)」
// ✅ 节流合并的实现
function createBatchedPublisher<T extends object>(publish: (patch: Partial<T>) => void, waitMs = 100) {
  let pending: Partial<T> = {};
  let timer: ReturnType<typeof setTimeout> | null = null;

  return {
    push(patch: Partial<T>) {
      // ⭐ 关键:合并(而不是覆盖),保留所有字段的最新值
      pending = { ...pending, ...patch };

      if (timer) return;                       // 已有定时器 → 合并到那一次
      timer = setTimeout(() => {
        timer = null;
        const toSend = pending;
        pending = {};
        publish(toSend);                       // ⭐ 一次广播带多个字段
      }, waitMs);
    },
    flush() {
      if (timer) { clearTimeout(timer); timer = null; }
      if (Object.keys(pending).length) {
        publish(pending);
        pending = {};
      }
    },
  };
}

3.4 Leader 选举:只让一个标签页做「后台任务」

⭐⭐⭐⭐ 「五个标签页各跑一个轮询」是真实的性能灾难:

   · 5 个标签页 × 每 10 秒一次请求 → 服务端压力 ×5
   · 5 个「心跳 / token 刷新」→ 可能互相把对方的 token 刷失效
   · 5 个「定时上报埋点」→ 数据重复
   · 5 个 WebSocket 连接 → 连接数 ×5

⭐ 解法:**Leader 选举**(选一个「主标签页」做后台任务,其它只做 UI)

⭐ 三种实现方式(按可靠性排序):

   ① ⭐⭐ **Web Locks API(最正确)**
      navigator.locks.request('leader', async () => { /* 持有锁 = 我是 leader */ });
      ⭐ 浏览器保证「同一时刻只有一个标签页持有同名锁」,
        且「标签页关闭/崩溃时自动释放」(不需要「心跳 + 超时」)→ 这是最可靠的方案
      ⚠️ 支持度:Chrome/Edge/Firefox 已支持;Safari 16.4+ 支持

   ② **BroadcastChannel 抢锁**(需要「心跳 + 超时」)
      启动时广播「我要当 leader」→ 若在 N ms 内没收到「有人已经是 leader」→ 自任 leader
      ⚠️ 需要「定期心跳」来检测「leader 挂了」(崩溃时无法主动通知)
      ⚠️ 时间窗口与心跳间隔要调,容易「双 leader」或「无 leader」

   ③ **localStorage 抢锁**(最脆弱)
      「写一个带时间戳的 key,然后读回来确认是不是自己的」
      ⚠️ 存在「竞态窗口」(两个标签页同时写,可能都认为自己是 leader)
      ⚠️ 且「写 localStorage 是同步的」(阻塞)→ 不推荐

   ⭐ 实践中:**优先用 Web Locks,降级用 BroadcastChannel 抢锁**。
// ✅ 用 Web Locks 做 Leader 选举(最可靠)
export function createLeaderElection(
  name: string,
  onBecomeLeader: () => void,
  onLoseLeadership: () => void
) {
  if (typeof navigator === 'undefined' || !('locks' in navigator)) {
    // ⭐ 不支持 Web Locks → 降级「单标签页模式」(每个标签页都当 leader)
    onBecomeLeader();
    return { release: () => onLoseLeadership() };
  }

  let released = false;
  let abortController: AbortController | null = new AbortController();

  // ⭐⭐ navigator.locks.request 会「一直持有锁直到 Promise resolve」
  //    所以我们在回调里「等待一个『主动释放』的信号」
  void navigator.locks.request(
    `leader:${name}`,
    { mode: 'exclusive', signal: abortController.signal },
    async () => {
      onBecomeLeader();
      // ⭐ 一直挂着,直到释放(标签页关闭时浏览器会自动释放锁)
      await new Promise<void>((resolve) => {
        const check = () => {
          if (released) resolve();
          else setTimeout(check, 1000);
        };
        check();
      });
      onLoseLeadership();
    }
  ).catch(() => {
    // ⭐ AbortError(主动释放)忽略
  });

  return {
    release() {
      released = true;
      abortController?.abort();
      abortController = null;
    },
  };
}

四、完整实现:一个跨标签页状态同步(250 行)

// cross-tab-sync.ts —— 「BroadcastChannel + storage 降级」的完整实现
// 覆盖:回声去重 / LWW 冲突 / 节流合并 / 消息 id 去重

type Listener<T> = (state: T) => void;

interface SyncMessage<T> {
  /** ⭐ 消息唯一 id(去重) */
  id: string;
  /** ⭐ 发送者标签页 id(回声过滤) */
  from: string;
  /** ⭐ 逻辑版本号(LWW) */
  version: number;
  /** 墙钟时间(调试/展示用) */
  at: number;
  /** 变更的字段(⭐ 增量而不是全量) */
  patch: Partial<T>;
}

/** ⭐ 每个标签页的唯一 id(会话级) */
const TAB_ID = (() => {
  try {
    const existing = sessionStorage.getItem('__tab_id');
    if (existing) return existing;
    const id = crypto.randomUUID();
    sessionStorage.setItem('__tab_id', id);
    return id;
  } catch {
    return crypto.randomUUID();
  }
})();

/** ⭐⭐ 传输层抽象:优先 BroadcastChannel,降级 storage 事件 */
interface Transport {
  post(msg: unknown): void;
  onMessage(handler: (msg: unknown) => void): void;
  close(): void;
  readonly kind: 'broadcast' | 'storage';
}

function createTransport(channelName: string, storageKey: string): Transport {
  // ⭐ 优先 BroadcastChannel
  if (typeof BroadcastChannel !== 'undefined') {
    const bc = new BroadcastChannel(channelName);
    return {
      kind: 'broadcast',
      post: (msg) => bc.postMessage(msg),
      onMessage: (handler) => { bc.onmessage = (e) => handler(e.data); },
      close: () => bc.close(),
    };
  }

  // ⭐ 降级:storage 事件(⭐ 注意「自己改自己不触发」)
  const listeners: Array<(msg: unknown) => void> = [];
  const onStorage = (e: StorageEvent) => {
    if (e.key !== storageKey || !e.newValue) return;
    try { listeners.forEach((fn) => fn(JSON.parse(e.newValue!))); } catch { /* 忽略 */ }
  };
  window.addEventListener('storage', onStorage);

  return {
    kind: 'storage',
    post: (msg) => {
      try {
        // ⚠️ 用「带随机后缀的 key」避免「同一条消息内容相同被浏览器优化掉」
        //    (storage 事件是「值变化才触发」,如果两次写入内容一样,第二次不触发)
        localStorage.setItem(storageKey, JSON.stringify(msg));
        // ⭐ 立即清理(避免把「广播」变成「持久化」)
        localStorage.removeItem(storageKey);
      } catch { /* 隐私模式/配额满 */ }
    },
    onMessage: (handler) => { listeners.push(handler); },
    close: () => {
      window.removeEventListener('storage', onStorage);
      listeners.length = 0;
    },
  };
}
/** ============ 核心:同步状态 ============ */
export interface CrossTabSyncOptions<T> {
  /** 频道名(同源的所有标签页共享) */
  channel: string;
  /** 初始值 */
  initial: T;
  /** ⭐ 只广播「这些字段」的变化(避免把无关字段也同步) */
  allowKeys?: Array<keyof T>;
  /** 广播节流(ms) */
  throttleMs?: number;
  /** 收到远端更新时的回调(用于「合并进本地 store」) */
  onRemote?: (patch: Partial<T>, meta: { from: string; version: number }) => void;
}

export function createCrossTabSync<T extends object>(options: CrossTabSyncOptions<T>) {
  const { channel, initial, allowKeys, throttleMs = 100, onRemote } = options;

  const storageKey = `__sync_${channel}`;
  const transport = createTransport(channel, storageKey);

  let version = 0;
  /** ⭐ 消息 id 去重集合(带容量上限,避免无限增长) */
  const seenIds = new Set<string>();

  /** ============ ① 发送(带节流合并) ============ */
  let pending: Partial<T> = {};
  let timer: ReturnType<typeof setTimeout> | null = null;

  function publish(patch: Partial<T>) {
    if (Object.keys(patch).length === 0) return;
    transport.post({
      id: crypto.randomUUID(),
      from: TAB_ID,
      version: ++version,                       // ⭐ 每次广播版本号 +1
      at: Date.now(),
      patch,
    } satisfies SyncMessage<T>);
  }

  function flush() {
    if (timer) { clearTimeout(timer); timer = null; }
    const toSend = pending;
    pending = {};
    publish(toSend);
  }

  /** ⭐ 广播一个「变更」(会自动合并 + 节流) */
  function broadcast(patch: Partial<T>) {
    // ⭐ 只广播「允许的字段」
    const filtered: Partial<T> = {};
    for (const [k, v] of Object.entries(patch)) {
      if (!allowKeys || allowKeys.includes(k as keyof T)) {
        (filtered as Record<string, unknown>)[k] = v;
      }
    }
    if (Object.keys(filtered).length === 0) return;

    // ⭐ 合并(保留所有字段的最新值,而不是覆盖)
    pending = { ...pending, ...filtered };

    if (timer) return;                          // 已有定时器 → 等它一起发
    timer = setTimeout(() => { timer = null; flush(); }, throttleMs);
  }

  /** ============ ② 接收(回声 + 去重 + LWW) ============ */
  transport.onMessage((raw) => {
    const msg = raw as SyncMessage<T>;
    if (!msg || typeof msg !== 'object' || !msg.id) return;

    // ⭐ ① 回声过滤
    if (msg.from === TAB_ID) return;

    // ⭐ ② 消息去重
    if (seenIds.has(msg.id)) return;
    seenIds.add(msg.id);
    if (seenIds.size > 2000) {
      const first = seenIds.values().next().value;
      if (first) seenIds.delete(first);
    }

    // ⭐⭐ ③ LWW:只应用「比本地新」的
    if (msg.version <= version && msg.version !== 0) {
      // ⚠️ 注意:不同标签页的 version 是「各自递增」的,
      //    所以「跨标签页比较 version」只在「同一写者序列」里可靠。
      //    ⭐ 更严谨的做法是用「Lamport 时钟」或「混合逻辑时钟」:
      //      收到远端 version 时 localVersion = max(localVersion, remote) + 1
    }
    version = Math.max(version, msg.version);

    // ⭐ ④ 通知本地应用(⭐ 由调用方决定「怎么合并」——通常是「浅合并 + 触发 store 更新」)
    onRemote?.(msg.patch, { from: msg.from, version: msg.version });

    // ⭐ ⑤ 转发(可选):让「只连到一个标签页的窗口」(如 iframe)也能收到
    //    这里不做(避免消息风暴),但如果有多层结构可以考虑
  });

  return {
    /** ⭐ 本地修改后调用它来广播 */
    broadcast,
    /** ⭐ 立即发送(不等节流) */
    flush,
    /** 当前传输方式(用于诊断) */
    transportKind: transport.kind,
    tabId: TAB_ID,
    close: () => {
      if (timer) clearTimeout(timer);
      transport.close();
    },
  };
}
// ============ 与 React / store 集成 ============
import { useEffect, useRef } from 'react';

/**
 * ⭐ 把「一个本地 store」接上跨标签页同步。
 * 约定:store 必须提供 `subscribe` 与 `setFromRemote`(或一个「应用 patch」的函数)。
 */
export function useCrossTabSync<T extends object>(
  store: {
    getState: () => T;
    subscribe: (fn: (s: T) => void) => () => void;
    applyRemote: (patch: Partial<T>) => void;
  },
  options: Omit<CrossTabSyncOptions<T>, 'initial' | 'onRemote'> & {
    /** 只同步这些 key(默认全同步) */
    syncKeys: Array<keyof T>;
  }
) {
  const syncRef = useRef<ReturnType<typeof createCrossTabSync<T>> | null>(null);
  const prevRef = useRef<Partial<T>>({});

  useEffect(() => {
    const sync = createCrossTabSync<T>({
      ...options,
      initial: store.getState(),
      allowKeys: options.syncKeys,
      // ⭐ 收到远端 → 应用到本地 store
      onRemote: (patch) => store.applyRemote(patch),
    });
    syncRef.current = sync;

    // ⭐⭐ 关键:订阅本地变化 → 计算「哪些 key 变了」→ 广播(增量)
    const unsubscribe = store.subscribe((state) => {
      const prev = prevRef.current;
      const changed: Partial<T> = {};
      let hasChange = false;

      for (const key of options.syncKeys) {
        if (!Object.is(prev[key], state[key])) {
          (changed as Record<string, unknown>)[key as string] = state[key];
          hasChange = true;
        }
      }
      prevRef.current = state;
      if (hasChange) sync.broadcast(changed);      // ⭐ 只广播变化的部分
    });

    // ⭐ 页面卸载前「把节流队列里剩下的发出去」
    const onVisibility = () => { if (document.visibilityState === 'hidden') sync.flush(); };
    document.addEventListener('visibilitychange', onVisibility);

    return () => {
      document.removeEventListener('visibilitychange', onVisibility);
      unsubscribe();
      sync.close();
    };
  }, [store, options]);
}
/** ============ 使用示例:一个「跨标签页同步的购物车」 ============ */
// 1) 先有一个普通 store(用 11.13 的 mini-zustand)
import { create } from './mini-zustand';

interface CartState {
  items: Array<{ id: string; name: string; qty: number }>;
  add: (item: { id: string; name: string }) => void;
  applyRemote: (patch: Partial<{ items: CartState['items'] }>) => void;
}

export const useCart = create<CartState>((set, get) => ({
  items: [],
  add: (item) => {
    const items = get().items;
    const existing = items.find((i) => i.id === item.id);
    set({
      items: existing
        ? items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i))
        : [...items, { ...item, qty: 1 }],
    });
  },
  // ⭐ 远端来的 patch「直接合并」(不再广播,避免无限循环)
  applyRemote: (patch) => set(patch),
}));

// 2) 在应用根部接上跨标签页同步
function CartSync() {
  useCrossTabSync(
    {
      getState: useCart.getState,
      subscribe: (fn) => useCart.subscribe((s) => fn(s)),
      applyRemote: (patch) => useCart.setState(patch),      // ⭐ 不触发广播
    },
    { channel: 'cart', syncKeys: ['items'], throttleMs: 120 }
  );
  return null;
}

// 3) 组件照常用(不需要知道「跨标签页」这件事)
function CartBadge() {
  const count = useCart((s) => s.items.reduce((acc, i) => acc + i.qty, 0));
  return <span>{count}</span>;
}

export function App() {
  return (
    <>
      <CartSync />
      <CartBadge />
      <AddButton />
    </>
  );
}
⭐⭐⭐ 这个实现里最值得看的四个点:

① 【传输层抽象(`Transport`)】
   BroadcastChannel 与 storage 事件「被包在同一个接口后」——
   ⭐ 业务代码不需要知道「用的是哪个」(也不需要在各处 `if` 判断支持性)。

② 【storage 降级的两个坑】
   · ⭐ **「值相同不触发」**:如果两次广播内容一样,storage 事件**不会触发** →
     必须把它「写进去再删掉」(或加随机后缀),且「只用于广播,不用于持久化」
   · ⭐ **「自己改自己不触发」**:所以 storage 模式下「本地修改」**不需要**额外处理
     (而 BroadcastChannel 需要「回声过滤」)——这两个差异由 Transport 吸收

③ 【「增量广播 + 节流合并」】
   ⭐ 不广播「整个 state」而广播「变化的 key」:
     · 消息更小
     · 合并时不会「用旧值覆盖新值」(因为只带变化的字段)
   ⭐ 配合节流 → 「输入框连打 10 个字符只广播 1 次」

④ 【`applyRemote` 不再触发广播】
   ⭐ 这是**避免「消息回环」**的关键:
     如果「收到远端 patch → 改 store → 又广播出去」→
     两个标签页会「无限来回广播」(直到栈溢出或用户看到 CPU 100%)
   ✅ 做法:区分「本地操作」与「远端应用」——
     远端应用用「不触发广播的」路径(`applyRemote` / `setState(..., {silent: true})`)。

五、本篇特有的坑

// ① 忘了「回声过滤」(自己发的消息自己处理 → 重复应用)
channel.onmessage = (e) => apply(e.data);                    // ❌ 没有 from 判断
// ✅ if (e.data.from === TAB_ID) return;

// ② 「收到远端 → 改 store → 又广播」(无限回环)⭐ 最严重
onRemote: (patch) => { store.setState(patch); }              // ⚠️ setState 会触发订阅 → 广播
// ✅ 用「不触发广播」的路径:applyRemote / setState(patch, { silent: true })

// ③ storage 模式下「期望自己改自己也能触发」
window.addEventListener('storage', () => { ... });           // ⚠️ 本标签页修改不会触发
// ✅ 本地先改(乐观),广播只负责「通知别人」

// ④ storage 模式下「两次广播内容相同」(第二次不触发)
localStorage.setItem('sync', JSON.stringify(samePayload));   // ❌ 第二次无效果
// ✅ 写入后立即 removeItem(或用「随机后缀」保证内容不同)

// ⑤ 广播「整个 state」(消息大 + 合并时用旧值覆盖新值)
channel.postMessage({ patch: fullState });                    // ⚠️
// ✅ 只广播「变化的 key」

// ⑥ 高频广播(每个字符一次 → 消息风暴)
onChange: (v) => { setState({ v }); broadcast({ v }); }       // ❌
// ✅ 节流合并(100ms)+ 只广播变化的字段

// ⑦ 没有「版本号/时间戳」(冲突时谁赢?)
// ⚠️ 结果:后到的消息被忽略(或都应用 → 状态乱)
// ✅ LWW(版本号)+ 明确「哪些字段用 LWW、哪些用『合并语义』」

// ⑧ 「多个标签页各跑一个轮询/心跳」⭐ 真实的性能灾难
// ✅ Leader 选举(Web Locks API 最优)

// ⑨ 用 localStorage 做 Leader 选举(有竞态窗口 + 同步阻塞)
// ✅ navigator.locks.request(...)

// ⑩ 「seenIds」无限增长(长时间运行后内存膨胀)
const seenIds = new Set();  // 只增不删                              // ⚠️
// ✅ 加上限(FIFO/LRU),或用「时间窗口内的消息」

// ⑪ 「消息带的对象」里有「不可结构化克隆的东西」(函数/Proxy/DOM 节点)
channel.postMessage({ handler: () => {} });                    // ❌ DataCloneError
// ✅ 只传「纯数据」(可用 structuredClone 预检)

// ⑫ 忘了「关闭 channel」(SPA 长时间运行 → 多个 channel 累积)
useEffect(() => { const bc = new BroadcastChannel('x'); });     // ❌ 没 close
// ✅ return () => bc.close();

// ⑬ 在「隐私模式/禁用存储」下崩溃
localStorage.setItem(...)                                      // ⚠️ 抛异常
// ✅ try/catch + 「内存降级」(只同步本次会话)

// ⑭ 「跨源标签页」也想同步(不同子域/端口)
// ⚠️ BroadcastChannel 与 storage 事件都「只在同源」生效
// ✅ 用 postMessage + 显式 origin 校验(⚠️ 必须校验,否则是安全漏洞)

// ⑮ 「同步了不该同步的东西」(如「正在输入的草稿」按字段广播 → 互相覆盖光标)
// ⭐ 判据:「这个字段两边不一致会不会有问题」
// ✅ 白名单(`syncKeys`)+ 对「编辑类字段」用「更细致的冲突策略」

// ⑯ 忘记「后台标签页会被节流」(定时器不准、动画暂停)
// ⚠️ 用「后台标签页做定时任务」本身就不靠谱
// ✅ Leader 选举 + 用 `visibilitychange` 调整行为

// ⑰ 「登出」只清了本标签页的 token(其它标签页仍能发请求)
// ✅ 广播登出 + 其它标签页「清内存状态 + 跳登录页」

// ⑱ 用「轮询 localStorage」当同步手段(落后、浪费)
setInterval(() => { if (localStorage.getItem('x') !== last) {...} }, 1000);   // ❌
// ✅ 用事件(BroadcastChannel/storage)

// ⑲ 认为「BroadcastChannel 能跨浏览器/跨设备」(不能)
// ⭐ 它只在「同一个浏览器、同一个源」的上下文之间
// ✅ 跨设备同步必须「走服务端」(WebSocket / SSE / 轮询,见 7.14)

// ⑳ 「同步时机」只在「写入时」(错过「标签页恢复可见」时的一致性)
// ✅ 在 `visibilitychange`(变可见)时「主动拉一次最新状态」(防止「离线期间错过消息」)
// ② 的完整处理(避免回环:区分「本地操作」与「远端应用」)
// ✅ 方案 A:两条写入路径
interface Store<T> {
  setLocal: (patch: Partial<T>) => void;     // ⭐ 会广播
  applyRemote: (patch: Partial<T>) => void;  // ⭐ 不广播
}

// ✅ 方案 B:给 setState 加一个「来源标记」
let applyingRemote = false;

store.subscribe(() => {
  if (applyingRemote) return;                 // ⭐ 远端应用不广播
  broadcast(computeChangedKeys(store.getState()));
});

onRemote: (patch) => {
  applyingRemote = true;
  try { store.setState(patch); } finally { applyingRemote = false; }
}
// ⑳ 的完整处理(恢复可见时对齐一次)
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'visible') return;

  // ⭐ 后台标签页可能「错过了消息」(浏览器可能丢弃/延迟唤醒)
  //    恢复可见时「主动要一次最新状态」
  channel.postMessage({ type: 'REQUEST_STATE', from: TAB_ID, id: crypto.randomUUID() });

  // ⭐ 收到 REQUEST_STATE 的标签页「把自己的状态回一份」
  //    (真实项目里更推荐「直接从服务端拉一次」——更可靠)
});

// 更稳的做法:⭐ 从「服务端」拉一次权威状态(本地同步只是「加速」)
// onVisible: () => queryClient.invalidateQueries()

六、面试延伸

  1. 「跨标签页同步有哪些方案?怎么选?」
    四种:① ⭐ BroadcastChannel(推荐)——同源的多个上下文共享一个频道,全双工、无存储副作用、可直接传结构化数据,性能好(⚠️ Safari 15.4+ / 不支持 IE,需要降级);② ⭐ storage 事件——兼容性最好,但「只带字符串」「不触发自己所在的标签页」「与真正的持久化写入耦合」;③ SharedWorker——多个标签页共享同一个 Worker 实例(可以做中心化状态),但兼容性与调试成本高;④ Service Worker 中转——已有 SW 时零成本,但语义不适合「实时同步」。选型:「BroadcastChannel 优先 + storage 事件降级」,并在客户端做能力检测(把它们包在同一个 Transport 接口后,业务代码不感知)。

  2. 「storage 事件有哪些『反直觉』的地方?」
    两个最重要的:① ⭐ 「自己改自己不触发」——storage 事件是「通知其他标签页『我改了』」,改动的那个标签页收不到自己的事件(⭐ 所以正确的心智是「本地先改(乐观),再靠广播通知别人」;很多 bug 就是「写了『监听事件→更新本地』,结果本标签页改完状态没更新」);② ⭐ 「值相同不触发」——如果两次写入内容完全一样,浏览器不会触发 storage(因为「值没变化」)→ 用它做广播时必须「写入后立即删除」或「加随机后缀」,且要意识到「它只该用于广播,不该与持久化耦合」。

  3. 「跨标签页同步有哪些真正的难点?」
    四个:① ⭐ 回声(Echo)——BroadcastChannel 可能「自己收到自己发的消息」→ 重复应用(⭐ 解法:每条消息带「发送者 tab id」,收到自己的就忽略;并用「消息 id 的 LRU 集合」防重复投递);② ⭐ 冲突——多个标签页同时写(解法:LWW(版本号/时间戳,「后写覆盖前写」,简单但会丢较早的写入,适合「偏好设置」)/CRDT/操作合并(把状态改成操作,适合「计数器、集合」)/Leader 单一写者(彻底避免冲突,适合「会写服务端的关键数据」));③ ⭐ 写放大——每次输入都广播会造成「消息风暴」(解法:节流合并 + 只广播变化的字段,且合并要「按字段合并」而不是「丢弃」);④ ⭐ 回环——「收到远端 → 改 store → 又广播」会导致无限来回广播(解法:区分「本地操作(会广播)」与「远端应用(不广播)」两条路径,或用 applyingRemote 标志)。

  4. 「为什么要做 Leader 选举?」
    因为「多个标签页各跑一份后台任务」是真实的性能灾难:① 5 个标签页 × 每 10 秒轮询 → 服务端压力 ×5;② 多个「心跳 / token 刷新」 → 可能互相把对方的 token 刷失效;③ 多个「定时上报埋点」 → 数据重复;④ 多个 WebSocket 连接 → 连接数 ×5。⭐ 解法是「选一个主标签页做后台任务,其它只做 UI」。实现方式(按可靠性):① ⭐ Web Locks API(最正确)——navigator.locks.request('leader', ...),浏览器保证「同一时刻只有一个持有者」,且「标签页关闭/崩溃时自动释放」(不需要心跳超时);② BroadcastChannel 抢锁(需要「心跳 + 超时」检测 leader 挂掉,容易「双 leader」或「无 leader」);③ localStorage 抢锁(有竞态窗口 + 同步阻塞,不推荐)。

  5. 「LWW 有什么问题?什么时候不该用它?」
    LWW(Last-Write-Wins) = 「用版本号/时间戳决定『谁赢』,后写的覆盖先写的」。问题:① ⭐ 会静默丢失写入——两个标签页几乎同时改同一字段时,较早的那次「消失」且用户没有感知;② ⭐ 「墙钟时间不可靠」——不同标签页/机器的时钟可能有偏差,「谁更新」的判断会出错(✅ 更严谨用 Lamport 时钟 / 混合逻辑时钟:收到远端版本时 local = max(local, remote) + 1);③ 不适合「需要累加/合并」的语义(如「+1」「集合增删」)。什么时候用:⭐ 「后写覆盖前写」语义的数据——主题、语言、已读标记、筛选条件。什么时候不用:「计数/累加」「集合增删」「文本编辑」——这些要用「操作合并(CRDT)」或「Leader 单一写者」。

  6. 「如果两个标签页同时编辑同一份内容,怎么办?」
    取决于「数据的性质」,分三档:① 无所谓(如「筛选条件」)→ LWW 就够;② ⭐ 需要保留双方的修改(如「草稿的多个字段」)→ 「按字段 LWW」(各自广播「自己改的字段」而不是「整个对象」,这样「A 改标题、B 改正文」都不会丢)+ 「字段级版本号」;③ ⭐ 同一段文本被双方编辑 → 需要「操作型 CRDT(如 Yjs 的 YText)」或「加锁 + 提示冲突」(⭐ 最简单可靠的做法是「明确告诉用户『另一个标签页也在编辑』并让一方只读」)。工程建议:⭐ 不要为了「技术优雅」去实现文本 CRDT——先用「按字段 LWW + 提示」,真的不够再上 CRDT 库(Yjs/Automerge)。

  7. 「跨标签页同步和『跨设备同步』有什么区别?」
    ⭐ 完全不同的量级:① 跨标签页——「同一个浏览器、同一个源」的多个上下文,用 BroadcastChannel/storage 事件,毫秒级、无需服务端;② 跨设备/跨浏览器——必须走服务端(WebSocket / SSE / 轮询 / Web Push,见 7.14),涉及「网络延迟、离线、鉴权、最终一致」。实践里的正确架构是「两者结合」:⭐ 服务端是权威源(source of truth),跨标签页同步是「本地加速」——例如:A 标签页下单成功 → ① 通过 BroadcastChannel 通知其它标签页「刷新购物车」(立刻、便宜);② 同时服务端的状态本来就变了,其它标签页「下次请求时也会拿到新的」。注意:⚠️ 「跨标签页同步」不能替代服务端一致性(它只在「同一个浏览器」内有效)。

  8. 「为什么『后台标签页』会让同步出问题?」
    因为浏览器会对后台标签页做节能节流:① 定时器被降频(setInterval 可能被降到「每分钟一次」甚至更慢);② requestAnimationFrame 停止;③ WebSocket/长连接可能被挂起或断开;④ CPU/网络优先级降低。后果:① 「后台标签页的轮询停了」→ 回到前台时数据是旧的(✅ 在 visibilitychange 变可见时主动拉一次);② 「后台标签页错过了 BroadcastChannel 消息」→ 状态不一致(✅ 同样在「恢复可见」时请求一次最新状态);③ 「后台标签页的定时上报丢了」(✅ 用「Leader 选举」让「前台的那个」负责)。⭐ 实践纪律:「永远不要假设后台标签页的定时器会准时触发」。

一句话速记

跨标签页同步的选型是「BroadcastChannel 优先 + storage 事件降级 + SharedWorker/SW 兜底」(把它们包在同一 Transport 接口后,业务层不感知);⭐ storage 事件的两个反直觉点:「自己改自己不触发」(所以「本地先改、广播只通知别人」)与「值相同不触发」(所以要「写入后立即删除」或加随机后缀);四个真难点:① ⭐ 回声(消息带 from tabId,忽略自己的 + 消息 id 去重)、② ⭐ 冲突(LWW(版本号,「后写覆盖前写」,适合偏好/已读;⚠️ 会静默丢写入、⚠️ 墙钟不可靠 → 用 Lamport 时钟) / 操作合并 CRDT(适合计数与集合) / Leader 单一写者(适合会写服务端的关键数据))、③ ⭐ 写放大(节流合并 + 只广播变化的字段)、④ ⭐ 回环(「收到远端→改 store→又广播」会无限来回 → 区分「本地操作」与「远端应用」两条路径,或用 applyingRemote 标志);⭐ Leader 选举用 Web Locks API(浏览器保证唯一持有者 + 崩溃自动释放,比「心跳抢锁」可靠),目的是避免「五个标签页各跑一个轮询/心跳」;⚠️ 后台标签页会被节流(定时器降频、连接挂起)→ 「恢复可见时主动拉一次」;⭐ 跨标签页同步 ≠ 跨设备同步(后者必须走服务端),服务端才是权威源,本地同步只是加速。

赞(0) 打赏
未经允许不得转载:陌上寒 » 3.14 跨标签页同步

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫