一个运营在浏览器里开了两个标签页处理同一批数据:A 标签把一条工单改成"已处理",B 标签页面还停在"待处理",他又点了一次,结果重复触发了一次操作。
这类问题的根因很朴素:每个标签页是独立的 JavaScript 运行环境、各有一份内存状态,默认互不知道对方;接口本身无状态,页面不会因为别处改了数据就自动刷新。本文不做"跨标签页通信有几种方式"的盘点,而是从一次真实事故出发,给一套能保证业务正确的多标签页同步与编辑互斥方案。
一、事故现场:多标签页为什么会把数据改乱
常见有三种。第一种是状态不同步导致重复操作,A 标签已把记录处理掉,B 标签仍显示旧状态,运营再点一次,重复发通知或重复确认。第二种是登录态不同步,一个标签退出登录,另一个标签还能继续操作。第三种是并发编辑丢失更新,两个标签同时编辑同一条记录,后保存的把先保存的内容整个覆盖。
共同点是:页面把自己内存里的状态当成了最新事实,而实际上别的标签、甚至别的人已经改过数据。
二、先选通道:BroadcastChannel 为主、storage 事件兜底
这里只给选型结论。BroadcastChannel 是同源下专门的跨标签广播,接口直观、现代浏览器普遍支持,作为主通道;localStorage 的 storage 事件在某个标签写入 localStorage 时由其他同源标签触发,兼容性好,兼作兜底,并能借 localStorage 持久化共享状态。两者封装成统一总线,不支持 BroadcastChannel 时自动降级:
function createBus(onMessage) {
var tabId = 'tab_' + Math.random().toString(36).slice(2);
var bc = null;
if (typeof BroadcastChannel !== 'undefined') {
bc = new BroadcastChannel('app_sync');
bc.onmessage = function (e) {
if (e.data.source !== tabId) onMessage(e.data);
};
} else {
window.addEventListener('storage', function (ev) {
if (ev.key !== 'app_sync') return;
var data = JSON.parse(ev.newValue);
if (data.source !== tabId) onMessage(data);
});
}
function post(type, payload) {
var msg = {
type: type, payload: payload, source: tabId, ts: Date.now() };
if (bc) {
bc.postMessage(msg);
} else {
localStorage.setItem('app_sync', JSON.stringify(msg));
}
}
return {
post: post, tabId: tabId };
}
接收方一律忽略自己发出的消息,避免回环。
三、状态同步:一处改了,其他标签怎么一致更新
约定一个原则:权威数据以后端为准,标签之间只广播"哪条记录变了、新版本是多少",不把完整数据在标签间传来当权威,否则某个标签的错误状态会被扩散给所有页面。
一次操作成功后,广播变更事件,其他标签按版本决定是否重新拉取:
var localVersion = {
};
var bus = createBus(function (data) {
if (data.type === 'record_changed') {
var p = data.payload;
if ((localVersion[p.id] || 0) < p.version) {
localVersion[p.id] = p.version;
refetchRecord(p.id);
}
}
});
function afterUpdateSuccess(id, version) {
localVersion[id] = version;
bus.post('record_changed', {
id: id, version: version });
}
带上版本号是为了防乱序:消息到达顺序未必和发生顺序一致,只有收到比本地更新的版本才采纳,过期消息直接丢弃,避免旧状态把新状态覆盖回去。列表类数据收到相关变更后,标记失效并重新查询。
四、编辑互斥:两个标签同时编辑同一条记录怎么办
光同步显示还不够,要防止丢失更新,分两层。
提交时用乐观锁兜底,记录带版本号,更新条件带上版本,影响行数为 0 说明已被他人修改,提示刷新后再编辑:
UPDATE business_record
SET content = #{content}, version = version + 1
WHERE id = #{id} AND version = #{version};
编辑过程再加一层软锁做体验提醒。进入编辑时登记"哪个标签、哪条记录、心跳时间",其他标签进入同一记录时提示该记录正在另一标签编辑;标签关闭或崩溃可能留下死锁,靠心跳时间过期自动释放:
function acquireEditLock(recordId) {
var key = 'edit_lock_' + recordId;
var now = Date.now();
var raw = localStorage.getItem(key);
if (raw) {
var lock = JSON.parse(raw);
if (now - lock.heartbeat < 5000 && lock.tab !== bus.tabId) {
return false; // 另一标签正在编辑
}
}
localStorage.setItem(key, JSON.stringify({
tab: bus.tabId, heartbeat: now }));
return true;
}
setInterval(function () {
// 周期性给自己持有的锁续心跳
Object.keys(localStorage).forEach(function (k) {
if (k.indexOf('edit_lock_') === 0) {
var lock = JSON.parse(localStorage.getItem(k));
if (lock.tab === bus.tabId) {
lock.heartbeat = Date.now();
localStorage.setItem(k, JSON.stringify(lock));
}
}
});
}, 2000);
软锁只是提醒,最终正确性仍然依赖提交时后端的乐观锁和约束。
五、登录态联动:一个标签退出,其他标签立即下线
登录态变化必须广播。一个标签登出后广播 logout,其他标签收到即清除本地登录态并跳转登录页,避免"一个已下线、另一个仍可操作"。反过来,一个标签完成登录或续期,可把共享 token 写入 localStorage,其他标签通过 storage 事件同步。
多标签下 token 刷新还要协调,避免几个标签同时刷新、互相把对方的新 token 顶掉。做法是用 localStorage 设一个刷新标记,由抢到的单个标签负责刷新,再把结果广播给其他标签,抢不到的等待结果即可。
六、降级与边界
隐私模式禁用存储、或通信通道不可用时,退回到"每次操作前重新拉取、提交时走乐观锁",放弃实时同步,但保住正确性。storage 事件只在其他标签触发,当前标签自己写入是收不到的,不要用它驱动当前页逻辑。BroadcastChannel 不持久化,标签重开后的状态以 localStorage 和后端为准。所有消息带时间戳和版本,做去重与防乱序处理。
七、踩坑清单
- 把完整状态在标签间当权威传递,会扩散不一致,权威归后端,只广播变更与版本。
- 只做显示同步、不做提交乐观锁,仍会丢失更新或重复操作。
- 编辑锁不设心跳过期,标签关闭后形成死锁。
- 一个标签登出、其他标签无感知,登录态要广播联动。
- 多个标签同时刷新 token 会互相顶掉,由单标签负责并广播。
- 不处理消息乱序,旧版本会覆盖新版本,按版本号判定。
八、工程落地建议
封装一个统一的跨标签协同模块,对内提供消息总线、持久化共享状态、编辑锁三件能力,业务页面只订阅自己关心的记录 ID。登录态联动和提交乐观锁设为默认必选,保障安全与正确;实时状态同步作为增强,在通信不可用时自动降级关闭。
九、复盘清单
- 是否采用 BroadcastChannel 加 storage 兜底并完成封装。
- 状态广播是否只传变更与版本、权威是否归后端。
- 编辑是否具备心跳软锁与提交乐观锁。
- 登录、登出是否多标签联动,token 刷新是否单点协调。
- 是否有通信不可用的降级,消息是否去重防乱序。
常见问题
后台开两个标签,一个改了状态另一个不更新还重复操作,怎么办?
用 BroadcastChannel(storage 事件兜底)在操作成功后广播"哪条记录变了及版本",其他标签按更高版本重新拉取;提交时再用版本乐观锁兜底,防止重复操作和丢失更新。
两个标签同时编辑同一条记录,后保存覆盖先保存,怎么解决?
进入编辑时登记带心跳的软锁提醒他人,提交时用记录版本做条件更新,失败说明已被修改,提示刷新后再编辑;标签关闭靠心跳过期释放锁。
一个标签退出登录,别的标签还能操作,这正常吗?
不正常也不应允许。登出要广播,其他标签收到后清除登录态并跳转登录;token 刷新由单个标签负责并广播结果,避免多标签互相顶掉。
多标签页问题本质上是一个小型的分布式一致性问题,把"显示同步、编辑互斥、登录联动"三件事拆开处理,就能覆盖绝大多数数据事故。本文为前端工程实践分享,具体实现以项目实际运行环境为准。