利用BroadcastChannel配合本地心跳实现多标签页Master选举:各页生成唯一ID并定时广播心跳,通过超时剔除离线页,按ID字典序选出Master,支持故障转移与角色动态切换。

利用 BroadcastChannel 配合本地心跳机制实现多标签页“Master 选举”,核心思路是:让所有标签页通过广播通道交换状态,结合定时心跳(如每 2 秒发送一次)和超时判定(如 5 秒未收到某页心跳即视为离线),最终选出一个活跃且响应及时的标签页作为 Master。关键在于避免竞态、处理页面崩溃/关闭、以及快速故障转移。
心跳广播与身份标识设计
每个标签页启动时生成唯一 ID(如 crypto.randomUUID() 或时间戳+随机数),并立即通过 BroadcastChannel 发送一条含 ID 和当前时间戳的 heartbeat 消息。后续每 2–3 秒重复广播一次。消息结构建议为:
- type: "heartbeat"
- id: "tab_abc123"
- ts: Date.now()
- role: "candidate"(初始均为候选者)
不依赖服务端,纯前端自治;ID 必须本地持久化(如 localStorage),确保刷新后仍可用同一身份参与选举。
Master 判定与角色切换逻辑
每个标签页维护一个本地“活跃成员表”(Map
- 剔除
lastSeenTs 的条目(超时离线) - 若自身 ID 不在存活列表中 → 立即广播心跳并设为 candidate
- 若存活列表非空,取
id字典序最小者作为当前 Master - 若自身 ID 等于当前 Master ID,且尚未声明角色 → 广播
{ type: "become-master", id }并切换为 master 角色 - 若监听到他人广播
become-master,且其 ID 更小 → 主动降级为 follower,并停止执行 Master 专属逻辑(如轮询、弹窗控制等)
边界情况防护措施
真实场景中需应对页面意外终止、网络延迟、同步漂移等问题:
-
页面卸载前广播 offline:监听
beforeunload,发送{ type: "offline", id },让其他页及时清理状态(注意:此事件不可靠,仅作优化,不能替代超时机制) - 避免“假死 Master”霸占角色:Master 自身也必须持续广播心跳;一旦其他页连续 2 次未收到,就触发重选,不等待自身确认
- 首次加载竞争**:多个页几乎同时打开时,可能短暂出现多个自称 Master。靠字典序 + 时间戳 + 心跳频率压制,几秒内收敛
- 跨源限制注意:BroadcastChannel 要求同源(协议+域名+端口完全一致),iframe 子页面需同源才可加入同一 channel
代码落地关键片段示意
初始化与监听:
const channel = new BroadcastChannel('master-election');
let myId = localStorage.getItem('tabId') || crypto.randomUUID();
localStorage.setItem('tabId', myId);
let members = new Map(); // id → lastSeenTs
let currentRole = 'candidate';
<p>function broadcast(msg) {
try { channel.postMessage(msg); } catch (e) { /<em> 忽略离线 channel </em>/ }
}</p><p>channel.addEventListener('message', e => {
const { data } = e;
if (data.type === 'heartbeat') {
members.set(data.id, data.ts);
} else if (data.type === 'become-master' && data.id !== myId) {
if (currentRole === 'master') {
currentRole = 'follower';
onRoleChange('follower');
}
}
});</p><p>// 心跳发送(用 setInterval,不用 requestIdleCallback 避免延迟)
setInterval(() => {
broadcast({ type: 'heartbeat', id: myId, ts: Date.now(), role: currentRole });
}, 2500);</p><p>// 主循环:每秒评估角色
setInterval(() => {
const now = Date.now();
// 清理过期成员
for (const [id, ts] of members.entries()) {
if (now - ts > 5000) members.delete(id);
}
// 选举:取字典序最小的存活 id
const aliveIds = Array.from(members.keys()).sort();
const nextMaster = aliveIds[0];
if (nextMaster && nextMaster === myId && currentRole !== 'master') {
currentRole = 'master';
broadcast({ type: 'become-master', id: myId });
onRoleChange('master');
} else if (nextMaster && nextMaster !== myId && currentRole === 'master') {
currentRole = 'follower';
onRoleChange('follower');
}
}, 1000);</p>

















