多标签页强提醒不重复打扰:从“弹框轰炸”到“共享待处理队列”的实战
场景:我在多标签页里“接力”处理紧急待办
这篇文章讨论的不是“消息列表怎么做”,而是紧急待办的强提醒体验应该如何落地。我的核心需求很明确:
- 紧急消息必须强制弹框提醒(不能靠用户自己去小铃铛里找)
- 弹框不能手动关闭,只能通过“去处理/已读”等业务动作逐条消解
- 刷新后仍要继续弹:只要还有“高优先级且未处理”的消息,就必须再次弹框
- 多标签页不重复打扰:同一时间只允许一个标签页弹;未处理的消息能跨标签页接力,不丢失 ✅
问题 1:多标签页重复强弹(“弹框轰炸”)💥
现象
- A 中点“去处理”打开 B
- B 打开后会立即执行轮询(而 A 里此时还有 3 条未处理)
- 于是 B 会再次强弹:同一批剩余 3 条被重复弹出 😵
一句话总结:两个入口(WS + 初始化轮询)叠加在“多标签页”上,会让强提醒被重复触发。
我认为合理的产品设计应该是什么样?🧩
我的判断标准很简单:既要“强提醒不遗漏”,也要“用户不被打断到崩溃”。
- 同一时刻只能有一个强提醒弹框(避免轰炸)✅
- 弹框容器支持多条消息(用户能逐条处理)✅
- 点击“去处理”后,新标签页应该进入处理模式:
- 不再重复强弹当前未处理的那一批(否则每开一个 tab 都弹一次)✋
- 但消息仍需保留在“小铃铛/待处理列表”里(避免漏掉)✅
- 当“处理标签页关闭或处理结束”,系统再允许其他标签页接力弹框 ✅
解决思路
先把“是否允许弹框”这件事独立出来:
用一个全局锁控制“同一时间只有一个标签页允许弹框” 👇
flowchart LR
M[紧急消息到达] --> L{全局锁存在?}
L -- 是 --> Q[不弹框/仅记录]
L -- 否 --> S[获得锁并弹框]
解决方案选择:锁放哪儿?锁归属怎么判?
要让“别的标签页不弹”很简单,但我还需要保证:当前弹框页可以继续追加新紧急消息。
这就引出了一个细节:我不仅要知道“有没有锁”,还要知道“锁属于谁” 👉
我当时的选型路径是一个很典型的逐步排除法(先快后稳 👍):
- sessionStorage:上手快,但“同标签页跳转仍共享”,A→B 会错判“我还是持锁页” ✋
- window(自定义 key):可跨页保存,但 window 全局属性容易被别的脚本覆盖 ⚠️
- Pinia(不持久化):与应用状态一致、可控、风险低 ✅
为什么 Pinia 不持久化?
- Pinia 的这个 key 本质是“临时归属标记”,只服务于当前运行时
- 如果持久化,浏览器异常关闭/崩溃导致未清理,会出现锁遗留,后续可能一直不弹强提醒 😵
最终方案(问题 1)
- localStorage:存“全局锁”本体(跨标签页共享)
- Pinia:存“当前标签页持有的锁 key”(仅当前标签页生效)
示例代码(与实现一致):
const urgentDialogActivePrefix = 'crm.urgent_dialog_active:';
export function setUrgentDialogActive() {
const store = useNotificationStore();
const existingKey = findUrgentDialogActiveKey();
if (existingKey) return existingKey;
try {
const key = `${urgentDialogActivePrefix}${Date.now()}`;
localStorage.setItem(key, '1');
store.setUrgentDialogActiveKey(key);
return key;
} catch {
return null;
}
}
export function isUrgentDialogActiveForCurrentTab() {
const store = useNotificationStore();
try {
const key = store.urgentDialogActiveKey;
if (!key) return false;
return localStorage.getItem(key) === '1';
} catch {
return false;
}
}
问题 2:关闭 A 后,B 只弹新消息,旧的 3 条“丢了”😵
现象
在问题 1 的锁机制生效后:
- B 不会重复弹框 ✅
- WS 的新紧急消息会继续 push 到 A 的弹框 ✅
- 但当 A 关闭后,B 再收到新消息时,只展示新来的 1 条 ❌
本质问题:弹框是“唯一入口”,但紧急消息的“待处理状态”没有被稳定地“先存起来”。一旦持锁页关闭,下一标签页如果只基于“新来的 WS 消息”触发弹框,就容易出现“旧的未处理没带上”的错觉。
解决思路
把“消息状态”从“弹框状态”里解耦出来:
弹框只是 UI,待处理列表才是关键。
这里我后来更偏向一个更轻量的实现:队列不跨标签页持久化,而是交给“页面加载必定会执行一次的轮询”来重建——
- 先轮询一次,把“高优先级且未处理”的消息塞进 Pinia 队列
- 轮询成功后再连接 WS
- 后面无论是轮询刷新还是 WS 推送,先把消息写入 Pinia 队列;能弹时一次性把队列里的都弹出来 ✅
解决方案选择:未处理队列放 localStorage 还是 Pinia?
这里的核心不是“哪个存储更强”,而是我们的事实源是什么:
既然页面加载(以及后续定时)都会轮询到“高优先级且未处理”的消息,那么队列完全可以由轮询在每个标签页内重建;此时把队列写进 localStorage 反而会引入额外风险。
- 方案 A:localStorage 存队列(跨标签页共享/持久化)
- 优点:跨标签页天然共享;刷新/崩溃后仍可恢复
- 代价:有空间上限(通常几 MB),队列稍大或字段稍多就可能触发
setItem失败;还要额外设计 TTL/容量上限/清理策略,否则容易“越积越多”
- 方案 B:Pinia 存队列(内存态,每 tab 自己维护)
- 优点:没有 localStorage 的序列化/配额风险;状态更新更直接、可控;与“页面加载立即轮询一次”的事实源一致
- 代价:队列不跨标签页共享,因此需要把“接力”交给轮询:持锁页关闭后,其他标签页通过轮询重建队列再弹框
我选择 Pinia 队列 + localStorage 只存锁:
队列的权威来源是“轮询返回的未处理紧急消息”,而不是浏览器本地持久化;这样做能把失败面缩到最小,同时仍能满足“接力不丢”的体验目标 ✅
最终方案(问题 2):先轮询后 WS + Pinia 队列 + 正确的执行顺序
关键点不在“有没有队列”,而在“先后顺序”:
- 先把轮询结果入队(页面加载立刻执行一次,先拿到“历史未处理”)
- 轮询成功后再连接 WS(避免 WS 抢跑导致“只弹新来的”)
- 任何来源的紧急消息都先入队,再判断锁(不持锁也要缓存)
- 能弹时直接渲染队列(一次性补齐旧的 + 新的) ✅
sequenceDiagram
participant Poll as 轮询(立即执行)
participant WS as WebSocket
participant Tab as 当前标签页
participant Q as Pinia(待处理队列)
participant Lock as localStorage(锁)
participant UI as 强提醒弹框
Poll->>Tab: 拉取未处理紧急消息 list
Tab->>Q: replacePending(list) ✅
Tab->>WS: connect() ✅
WS->>Tab: 收到紧急消息 item
Tab->>Q: upsertPending(item) ✅
Tab->>Lock: isLocked?
alt 被其他标签页持锁
Tab-->>UI: 不弹框,仅等待
else 可持锁/已持锁
Tab->>Lock: setLock()
Tab->>UI: render(Q.pendingList) ✅
end
示例代码(与实现一致):
const store = useNotificationStore();
const maybeOpenUrgentDialog = () => {
if (store.urgentPendingList.length === 0) return;
if (!isUrgentDialogActiveForCurrentTab() && isUrgentDialogActive()) return;
setUrgentDialogActive();
setUrgentDialogItems(store.urgentPendingList);
};
const handleUrgentIncoming = (item: NotificationMineItem) => {
store.upsertUrgentPending({ key: getUrgentNotificationKey(item), item });
maybeOpenUrgentDialog();
};
const fetchNotifications = async () => {
const list = await getNotificationList({ status: 0 });
store.replaceUrgentPending(
list
.filter((x) => isUrgentNotification(x) && !x.isRead)
.map((x) => ({ key: getUrgentNotificationKey(x), item: x })),
);
maybeOpenUrgentDialog();
startEcho();
};
最终效果(两类问题一起解决)🙌
- 多标签页不再重复强弹:只有一个标签页持锁展示弹框 ✅
- 紧急消息不会“被关掉的标签页带走”:轮询重建 + Pinia 队列兜底,能接力 ✅
- 新消息到来时会补齐历史未处理:B 会弹 3 条旧的 + 1 条新的 ✅
总结
这次问题本质上是“同一份紧急消息,在多标签页环境下如何做到不重复打扰又不遗漏”:
- 问题 1(重复弹框):用 localStorage 全局锁保证同一时刻只允许一个标签页弹框;锁归属用 Pinia 记录,避免误判
- 问题 2(接力丢历史):把“待处理紧急消息”从弹框组件里抽出来,改为 Pinia 队列;并通过先轮询后 WS的时序,确保“历史未处理”一定先入队,再叠加 WS 的实时增量
最终效果是:紧急消息仍然强制弹框、不可手动关闭、刷新后仍可通过轮询重建继续弹,同时多标签页不会被同一批消息反复轰炸。