
一句话:跨标签页同步的技术选型是「
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()
六、面试延伸
- 「跨标签页同步有哪些方案?怎么选?」
四种:① ⭐BroadcastChannel(推荐)——同源的多个上下文共享一个频道,全双工、无存储副作用、可直接传结构化数据,性能好(⚠️ Safari 15.4+ / 不支持 IE,需要降级);② ⭐storage事件——兼容性最好,但「只带字符串」「不触发自己所在的标签页」「与真正的持久化写入耦合」;③SharedWorker——多个标签页共享同一个 Worker 实例(可以做中心化状态),但兼容性与调试成本高;④Service Worker中转——已有 SW 时零成本,但语义不适合「实时同步」。选型:「BroadcastChannel优先 +storage事件降级」,并在客户端做能力检测(把它们包在同一个 Transport 接口后,业务代码不感知)。 -
「
storage事件有哪些『反直觉』的地方?」
两个最重要的:① ⭐ 「自己改自己不触发」——storage事件是「通知其他标签页『我改了』」,改动的那个标签页收不到自己的事件(⭐ 所以正确的心智是「本地先改(乐观),再靠广播通知别人」;很多 bug 就是「写了『监听事件→更新本地』,结果本标签页改完状态没更新」);② ⭐ 「值相同不触发」——如果两次写入内容完全一样,浏览器不会触发storage(因为「值没变化」)→ 用它做广播时必须「写入后立即删除」或「加随机后缀」,且要意识到「它只该用于广播,不该与持久化耦合」。 -
「跨标签页同步有哪些真正的难点?」
四个:① ⭐ 回声(Echo)——BroadcastChannel可能「自己收到自己发的消息」→ 重复应用(⭐ 解法:每条消息带「发送者 tab id」,收到自己的就忽略;并用「消息 id 的 LRU 集合」防重复投递);② ⭐ 冲突——多个标签页同时写(解法:LWW(版本号/时间戳,「后写覆盖前写」,简单但会丢较早的写入,适合「偏好设置」)/CRDT/操作合并(把状态改成操作,适合「计数器、集合」)/Leader 单一写者(彻底避免冲突,适合「会写服务端的关键数据」));③ ⭐ 写放大——每次输入都广播会造成「消息风暴」(解法:节流合并 + 只广播变化的字段,且合并要「按字段合并」而不是「丢弃」);④ ⭐ 回环——「收到远端 → 改 store → 又广播」会导致无限来回广播(解法:区分「本地操作(会广播)」与「远端应用(不广播)」两条路径,或用applyingRemote标志)。 -
「为什么要做 Leader 选举?」
因为「多个标签页各跑一份后台任务」是真实的性能灾难:① 5 个标签页 × 每 10 秒轮询 → 服务端压力 ×5;② 多个「心跳 / token 刷新」 → 可能互相把对方的 token 刷失效;③ 多个「定时上报埋点」 → 数据重复;④ 多个 WebSocket 连接 → 连接数 ×5。⭐ 解法是「选一个主标签页做后台任务,其它只做 UI」。实现方式(按可靠性):① ⭐ Web Locks API(最正确)——navigator.locks.request('leader', ...),浏览器保证「同一时刻只有一个持有者」,且「标签页关闭/崩溃时自动释放」(不需要心跳超时);② BroadcastChannel 抢锁(需要「心跳 + 超时」检测 leader 挂掉,容易「双 leader」或「无 leader」);③ localStorage 抢锁(有竞态窗口 + 同步阻塞,不推荐)。 -
「LWW 有什么问题?什么时候不该用它?」
LWW(Last-Write-Wins) = 「用版本号/时间戳决定『谁赢』,后写的覆盖先写的」。问题:① ⭐ 会静默丢失写入——两个标签页几乎同时改同一字段时,较早的那次「消失」且用户没有感知;② ⭐ 「墙钟时间不可靠」——不同标签页/机器的时钟可能有偏差,「谁更新」的判断会出错(✅ 更严谨用 Lamport 时钟 / 混合逻辑时钟:收到远端版本时local = max(local, remote) + 1);③ 不适合「需要累加/合并」的语义(如「+1」「集合增删」)。什么时候用:⭐ 「后写覆盖前写」语义的数据——主题、语言、已读标记、筛选条件。什么时候不用:「计数/累加」「集合增删」「文本编辑」——这些要用「操作合并(CRDT)」或「Leader 单一写者」。 -
「如果两个标签页同时编辑同一份内容,怎么办?」
取决于「数据的性质」,分三档:① 无所谓(如「筛选条件」)→ LWW 就够;② ⭐ 需要保留双方的修改(如「草稿的多个字段」)→ 「按字段 LWW」(各自广播「自己改的字段」而不是「整个对象」,这样「A 改标题、B 改正文」都不会丢)+ 「字段级版本号」;③ ⭐ 同一段文本被双方编辑 → 需要「操作型 CRDT(如 Yjs 的 YText)」或「加锁 + 提示冲突」(⭐ 最简单可靠的做法是「明确告诉用户『另一个标签页也在编辑』并让一方只读」)。工程建议:⭐ 不要为了「技术优雅」去实现文本 CRDT——先用「按字段 LWW + 提示」,真的不够再上 CRDT 库(Yjs/Automerge)。 -
「跨标签页同步和『跨设备同步』有什么区别?」
⭐ 完全不同的量级:① 跨标签页——「同一个浏览器、同一个源」的多个上下文,用BroadcastChannel/storage事件,毫秒级、无需服务端;② 跨设备/跨浏览器——必须走服务端(WebSocket / SSE / 轮询 / Web Push,见 7.14),涉及「网络延迟、离线、鉴权、最终一致」。实践里的正确架构是「两者结合」:⭐ 服务端是权威源(source of truth),跨标签页同步是「本地加速」——例如:A 标签页下单成功 → ① 通过 BroadcastChannel 通知其它标签页「刷新购物车」(立刻、便宜);② 同时服务端的状态本来就变了,其它标签页「下次请求时也会拿到新的」。注意:⚠️ 「跨标签页同步」不能替代服务端一致性(它只在「同一个浏览器」内有效)。 -
「为什么『后台标签页』会让同步出问题?」
因为浏览器会对后台标签页做节能节流:① 定时器被降频(setInterval可能被降到「每分钟一次」甚至更慢);②requestAnimationFrame停止;③ WebSocket/长连接可能被挂起或断开;④ CPU/网络优先级降低。后果:① 「后台标签页的轮询停了」→ 回到前台时数据是旧的(✅ 在visibilitychange变可见时主动拉一次);② 「后台标签页错过了 BroadcastChannel 消息」→ 状态不一致(✅ 同样在「恢复可见」时请求一次最新状态);③ 「后台标签页的定时上报丢了」(✅ 用「Leader 选举」让「前台的那个」负责)。⭐ 实践纪律:「永远不要假设后台标签页的定时器会准时触发」。
一句话速记
跨标签页同步的选型是「
BroadcastChannel优先 +storage事件降级 +SharedWorker/SW 兜底」(把它们包在同一 Transport 接口后,业务层不感知);⭐storage事件的两个反直觉点:「自己改自己不触发」(所以「本地先改、广播只通知别人」)与「值相同不触发」(所以要「写入后立即删除」或加随机后缀);四个真难点:① ⭐ 回声(消息带fromtabId,忽略自己的 + 消息 id 去重)、② ⭐ 冲突(LWW(版本号,「后写覆盖前写」,适合偏好/已读;⚠️ 会静默丢写入、⚠️ 墙钟不可靠 → 用 Lamport 时钟) / 操作合并 CRDT(适合计数与集合) / Leader 单一写者(适合会写服务端的关键数据))、③ ⭐ 写放大(节流合并 + 只广播变化的字段)、④ ⭐ 回环(「收到远端→改 store→又广播」会无限来回 → 区分「本地操作」与「远端应用」两条路径,或用applyingRemote标志);⭐ Leader 选举用 Web Locks API(浏览器保证唯一持有者 + 崩溃自动释放,比「心跳抢锁」可靠),目的是避免「五个标签页各跑一个轮询/心跳」;⚠️ 后台标签页会被节流(定时器降频、连接挂起)→ 「恢复可见时主动拉一次」;⭐ 跨标签页同步 ≠ 跨设备同步(后者必须走服务端),服务端才是权威源,本地同步只是加速。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!