多标签页强提醒不重复打扰:从“弹框轰炸”到“共享待处理队列”的实战

这篇文章讨论的不是"消息列表怎么做",而是紧急待办的强提醒
体验应该如何落地。我的核心需求很明确:

  • 紧急消息必须强制弹框提醒(不能靠用户自己去小铃铛里找)
  • 弹框不能手动关闭,只能通过"去处理/已读"等业务动作逐条消解
  • 刷新后仍要继续弹:只要还有"高优先级且未处理"的消息,就必须再次弹框
  • 多标签页不重复打扰:同一时间只允许一个标签页弹;未处理的消息能跨标签页接力,不丢失 ✅

问题 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 队列 + 正确的执行顺序

关键点不在"有没有队列",而在"先后顺序":

  1. 先把轮询结果入队(页面加载立刻执行一次,先拿到"历史未处理")
  2. 轮询成功后再连接 WS(避免 WS 抢跑导致"只弹新来的")
  3. 任何来源的紧急消息都先入队,再判断锁(不持锁也要缓存)
  4. 能弹时直接渲染队列(一次性补齐旧的 + 新的) ✅

示例代码(与实现一致):

复制代码
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 的实时增量

最终效果是:紧急消息仍然强制弹框、不可手动关闭、刷新后仍可通过轮询重建继续弹,同时多标签页不会被同一批消息反复轰炸。

相关推荐
Jackson@ML2 小时前
2026最新版Eclipse for Java安装使用指南
java·ide·eclipse
莫问前路漫漫2 小时前
JDK 核心实操指南:从安装配置到项目打包调试全流程
java·jdk
Getgit2 小时前
Linux系统的特点有哪些
java·linux·运维·网络·sql
APIshop2 小时前
Java获取item_get-获得某书商品详情接口
java·开发语言·python
不想上班只想要钱2 小时前
动态类名在 <swiper-slide 的复制项中没有起作用的解决方法
前端·vue.js
weixin_395448912 小时前
tidl_import_mul_rmfsd_psd_u8_3x480x544_bise_raw_dynamic.txt
java·服务器·前端
Henry Zhu1232 小时前
Qt Model/View架构详解(四):高级特性
开发语言·qt·架构
多多*3 小时前
图解Redis的分布式锁的历程 从单机到集群
java·开发语言·javascript·vue.js·spring·tomcat·maven
想用offer打牌3 小时前
2025年总结:一个树苗倔强生长
java·后端·开源·go