通知中心要把后端推来的热点变动即时呈现给用户,核心是「低延迟送达 + 不重复 + 可回溯」。在 AI 热点监控工具里,后端用大模型跑完内容分析、判定某条话题热度越线后,经由 WebSocket 把事件推到前端面板;通知中心负责接住这条流、做优先级排序、弹窗提醒、落库成历史。下文按传输选型、分层架构、状态管理、离线补偿四块给出可直接复用的设计。这套设计不追求花哨交互,先把「不漏报、不重复、随时可查」三件事做扎实。
一、通知中心的职责边界
通知中心不是单纯弹窗。它同时承担四件事:实时接收推送、按优先级决定呈现方式、维护未读状态、把历史持久化供回溯。把这几件事拆开,才能避免「消息一多就卡、刷新就丢、重复弹三条」的常见病。
| 职责 | 要做的事 | 做错的代价 |
|---|---|---|
| 实时接收 | 维持长连接、解析推送事件 | 断连后热点漏报 |
| 优先级呈现 | 紧急弹 toast、普通进列表 | 重要告警被淹没 |
| 未读管理 | 维护计数与已读状态 | 角标失准、用户焦虑 |
| 历史持久化 | 本地与远端双写 | 刷新后记录清零 |
二、传输层选型:WebSocket 还是 SSE 还是轮询
热点监控工具前后端本来就靠 WebSocket 做双向通道(前端也发订阅、心跳、控制指令),所以通知复用同一连接更省资源。下表给出三类方案的取舍。
| 维度 | WebSocket | SSE | 长轮询 |
|---|---|---|---|
| 方向 | 全双工 | 服务端→客户端 | 服务端→客户端 |
| 协议 | 独立 WS 握手 | 基于 HTTP | 基于 HTTP |
| 自动重连 | 需手写 | 浏览器内置 | 需手写 |
| 复杂度 | 中 | 低 | 低 |
| 适合场景 | 聊天、协同、实时面板 | 纯推送流 | 老旧代理兜底 |
SSE 实现简单、自带断线续传(Last-Event-ID),纯下发场景优先选它;但本工具的连接已经承载订阅与心跳,再开一条 SSE 反而增加连接数。WebSocket 在连接建立后协议开销极小,适合高频、双向的实时面板。长轮询只在网络环境限制 WS 时作兜底。
2.1 连接与心跳的基本形态
下面这段 TypeScript 给出一个带指数退避重连的连接管理器,心跳用于剔除假死连接。
type HotEvent = {
id: string;
topic: string;
score: number;
level: 'urgent' | 'normal' | 'silent';
ts: number;
};
class NotifySocket {
private ws?: WebSocket;
private retry = 0;
private timer?: number;
connect(url: string, token: string) {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.retry = 0;
this.ws!.send(JSON.stringify({ type: 'auth', token }));
};
this.ws!.onmessage = (e) => this.onMessage(e);
this.ws!.onclose = () => this.reconnect(url, token);
}
private onMessage(e: MessageEvent) {
const data = JSON.parse(e.data) as HotEvent;
window.dispatchEvent(new CustomEvent('hot-event', { detail: data }));
}
private reconnect(url: string, token: string) {
if (this.retry >= 6) return;
const delay = 1000 * Math.pow(2, this.retry);
this.retry += 1;
this.timer = window.setTimeout(() => this.connect(url, token), delay);
}
}
三、通知中心的分层架构
把「收到事件」到「用户看到」拆成五步,每一层只做一件事,方便单测与排错:
- 接入层:持有 WebSocket,把原始消息转成标准
HotEvent并广播到全局事件总线; - 去重层:按事件
id和时间窗合并同主题连发,避免「同一话题刷出十条」; - 优先级层:urgent 立即弹 toast 并播提示音,normal 进列表并涨角标,silent 只更新计数;
- 状态层:用单一归一化 store 保存全部通知,未读数由其派生,不单独存计数;
- 持久层:写入
localStorage与服务端,刷新或重连后补齐错过的记录。
3.1 用单一 store 派生状态
多数通知中心出 bug,是因为「未读数」「已读集合」「列表」三处各存一份、相互脱节。正确做法是只存原始列表,其余全部计算出来。
type State = { items: HotEvent[]; };
function unreadCount(s: State): number {
return s.items.filter((i) => !i.read).length;
}
function markRead(s: State, id: string): State {
return {
items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)),
};
}
四、离线补偿与跨标签页同步
用户断网时,后端把未送达事件标为 pending;前端重连后,先按服务端 lastEventId 拉取缺口,再切回实时流。去重靠事件 id 不重复,重连补发与实时推送落到同一合并逻辑,天然不重复。
多标签页场景下,一个标签里点了「已读」,其他标签要立刻同步。用 BroadcastChannel 在同源标签间广播状态变更,省去一轮服务端往返:
const chan = new BroadcastChannel('notify');
function localMarkRead(id: string) {
store = markRead(store, id);
chan.postMessage({ type: 'read', id });
}
chan.onmessage = (e) => {
if (e.data.type === 'read') store = markRead(store, e.data.id);
};
标签页不在焦点时,再调用浏览器通知接口做系统级提醒,但权限申请必须绑定到用户点击行为,不能在首屏自动弹,否则容易被浏览器直接拒绝。
五、两个易踩的坑
优先级缺失会把关键告警压进长列表底部。给每类事件定级,urgent 走强提醒、silent 只落库,用户才不会被低频系统消息刷屏。另一个坑是状态多源存储:未读数一旦手动维护,就会和真实列表漂移。始终从单一列表派生计数,是稳妥的做法。
六、推送体的精简与呈现
后端推到前端的通知体要做边缘裁剪,别把原始大段内容塞进一条消息。只下发标题、热度分、来源链接与等级,正文详情等用户点开再按需拉取,单条体积变小,连接更稳、解析更快。
toast 的呈现也要有节制。桌面端在右下角竖排堆叠,非紧急类五秒后自动消失,紧急类常驻直到用户处理;同屏至多展示三条,超出部分直接进通知中心列表,避免遮挡操作区。声音策略与免扰绑定等级:只有 urgent 才响铃,silent 全程静默;用户能在设置里关掉某一类的提示音,服务端下发前先读这份偏好,做到「同一条事件、不同人不同打扰强度」。
点击行为必须闭环。无论点 toast 还是点开列表项,都跳到对应话题详情页,并把通知 id 带过去触发已读回写,保证角标、列表、详情三处状态一致。通知中心因此不只是「收消息的盒子」,而是把告警、回溯、已读闭环串起来的入口。
常见问题(FAQ)
Q1:通知中心该用 WebSocket 还是 SSE?
已建 WebSocket 就复用它;纯下发且求简单,优先选 SSE。
Q2:刷新页面后历史会丢吗?
本地 store 落 localStorage,重连再补服务端缺口,不会丢。
Q3:多标签页怎么同步已读?
用 BroadcastChannel 同源广播状态变更,免服务端往返。