这篇文章讨论的不是"消息列表怎么做",而是紧急待办的强提醒
体验应该如何落地。我的核心需求很明确:
- 紧急消息必须强制弹框提醒(不能靠用户自己去小铃铛里找)
- 弹框不能手动关闭,只能通过"去处理/已读"等业务动作逐条消解
- 刷新后仍要继续弹:只要还有"高优先级且未处理"的消息,就必须再次弹框
- 多标签页不重复打扰:同一时间只允许一个标签页弹;未处理的消息能跨标签页接力,不丢失 ✅
问题 1:多标签页重复强弹("弹框轰炸")💥
现象
- A 中点"去处理"打开 B
- B 打开后会立即执行轮询(而 A 里此时还有 3 条未处理)
- 于是 B 会再次强弹:同一批剩余 3 条被重复弹出 😵
一句话总结:两个入口( WS
+ 初始化轮询)叠加在"多标签页"上,会让强提醒被重复触发。
我认为合理的产品设计应该是什么样?🧩
我的判断标准很简单:既要"强提醒不遗漏",也要"用户不被打断到崩溃"。
- 同一时刻只能有一个强提醒弹框(避免轰炸)✅
- 弹框容器支持多条消息(用户能逐条处理)✅
- 点击"去处理"后,新标签页应该进入处理模式 :
- 不再重复强弹当前未处理的那一批(否则每开一个 tab 都弹一次)✋
- 但消息仍需保留在"小铃铛/待处理列表"里(避免漏掉)✅
- 当"处理标签页关闭或处理结束",系统再允许其他标签页接力弹框 ✅
解决思路
先把"是否允许弹框"这件事独立出来:
用一个全局锁控制"同一时间只有一个标签页允许弹框" 👇

解决方案选择:锁放哪儿?锁归属怎么判?
要让"别的标签页不弹"很简单,但我还需要保证:当前弹框页可以继续追加新紧急消息 。
这就引出了一个细节:我不仅要知道"有没有锁",还要知道"锁属于谁" 👉
我当时的选型路径是一个很典型的逐步排除法(先快后稳 👍):
- sessionStorage:上手快,但"同标签页跳转仍共享",A→B 会错判"我还是持锁页" ✋
- window(自定义 key):可跨页保存,但 window 全局属性容易被别的脚本覆盖 ⚠️
- Pinia**(不持久化)** :与应用状态一致、可控、风险低 ✅
为什么 Pinia 不持久化?
- Pinia 的这个 key 本质是"临时归属标记",只服务于当前运行时
- 如果持久化,浏览器异常关闭/崩溃导致未清理,会出现锁遗留 ,后续可能一直不弹强提醒 😵
最终方案(问题 1)
- localStorage:存"全局锁"本体(跨标签页共享)
- Pinia:存"当前标签页持有的锁 key"(仅当前标签页生效)
示例代码(与实现一致):
const urgentDialogActivePrefix = 'crm.urgent_dialog_active:';
/**
* 尝试为当前标签页获取"强提醒弹框全局锁"并返回锁 key。
* - 若已有任意标签页持锁:直接返回已存在的 key(当前 tab 不会成为持锁方)
* - 若不存在任何锁 key:写入 localStorage 并把 key 记录到当前 tab 的 Pinia 中
*/
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;
}
}
/**
* 判断"当前标签页是否为持锁方"(即:Pinia 中记录的 key 在 localStorage 仍存在)。
* 用于支持"持锁页可以持续追加/刷新弹框内容",同时避免其他标签页误判自己持锁。
*/
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,待处理列表才是关键。
所以思路很简单:把"未处理的紧急消息"先存起来(形成一个待处理队列),而不是把它们只"绑"在弹框组件上。
无论消息来自轮询还是 WS,都先进入这个队列;当某个标签页允许弹框时,再一次性把队列里的未处理消息渲染出来 ✅
问题就变成了:这个"待处理队列"应该存在哪里呢?
解决方案选择:未处理队列放 localStorage 还是 Pinia?
这里的核心不是"哪个存储更强",而是我们的事实源是什么:
既然页面加载(以及后续定时)都会轮询到"高优先级且未处理"的消息,那么队列完全可以由轮询在每个标签页内重建;此时把队列写进 localStorage 反而会引入额外风险。
- 方案 A:localStorage 存队列(跨标签页共享/持久化)
- 优点:跨标签页天然共享;刷新/崩溃后仍可恢复
- 代价:有空间上限 (通常几 MB),队列稍大或字段稍多就可能触发
setItem失败;还要额外设计 TTL/容量上限/清理策略,否则容易"越积越多"
- 方案 B:Pinia 存队列(内存态,每 tab 自己维护)
- 优点:没有 localStorage 的序列化/配额风险;状态更新更直接、可控;与"页面加载立即轮询一次"的事实源一致
- 代价:队列不跨标签页共享,因此需要把"接力"交给轮询:持锁页关闭后,其他标签页通过轮询重建队列再弹框
我选择 Pinia 队列 + localStorage 只存锁 :
队列的权威来源是"轮询返回的未处理紧急消息",而不是浏览器本地持久化;这样做能把失败面缩到最小,同时仍能满足"接力不丢"的体验目标 ✅
最终方案(问题 2):先轮询后 WS + Pinia 队列 + 正确的执行顺序
关键点不在"有没有队列",而在"先后顺序":
- 先把轮询结果入队(页面加载立刻执行一次,先拿到"历史未处理")
- 轮询成功后再连接 WS(避免 WS 抢跑导致"只弹新来的")
- 任何来源的紧急消息都先入队,再判断锁(不持锁也要缓存)
- 能弹时直接渲染队列(一次性补齐旧的 + 新的) ✅

示例代码(与实现一致):
const store = useNotificationStore();
/**
* 尝试打开/更新强提醒弹框(严格按代码判断顺序):
* - 如果当前没有待处理紧急消息:直接 return
* - 如果"当前 tab 没有持锁"且"已经存在任意锁":直接 return(避免多 tab 重复弹)
* - 否则:写入/确认锁,并把当前待处理队列一次性渲染进弹框
*/
const maybeOpenUrgentDialog = () => {
if (store.urgentPendingList.length === 0) return;
if (!isUrgentDialogActiveForCurrentTab() && isUrgentDialogActive()) return;
setUrgentDialogActive();
setUrgentDialogItems(store.urgentPendingList);
};
/**
* 处理"紧急消息实时到达"(如 WS 推送):
* - 先写入/更新 Pinia 待处理队列(确保消息不会因不持锁而丢)
* - 再尝试触发弹框(由锁机制决定是否真正弹出)
*/
const handleUrgentIncoming = (item: NotificationMineItem) => {
store.upsertUrgentPending({ key: getUrgentNotificationKey(item), item });
maybeOpenUrgentDialog();
};
/**
* 页面初始化/定时轮询:拉取"未处理紧急消息"作为权威来源重建队列,并按顺序启动 WS。
* 关键时序:先 replace 队列 -> 再 maybeOpen -> 再 connect WS(避免"只弹新消息")。
*/
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(); // 初始化
WebSocket 连接};
最终效果(两类问题一起解决)🙌
- 多标签页不再重复强弹:只有一个标签页持锁展示弹框 ✅
- 紧急消息不会"被关掉的标签页带走":轮询重建 + Pinia 队列兜底,能接力 ✅
- 新消息到来时会补齐历史未处理:B 会弹 3 条旧的 + 1 条新的 ✅
总结
这次问题本质上是"同一份紧急消息,在多标签页环境下如何做到不重复打扰 又不遗漏":
- 问题 1(重复弹框) :用 localStorage 全局锁 保证同一时刻只允许一个标签页弹框;锁归属用 Pinia 记录,避免误判
- 问题 2(接力丢历史) :把"待处理紧急消息"从弹框组件里抽出来,改为 Pinia 队列 ;并通过先轮询后 WS的时序,确保"历史未处理"一定先入队,再叠加 WS 的实时增量
最终效果是:紧急消息仍然强制弹框、不可手动关闭、刷新后仍可通过轮询重建继续弹,同时多标签页不会被同一批消息反复轰炸。