写在前面
IM 类应用最尴尬的失败不在后端,在右上角:用户正在页面里聊天,每条消息"叮"一个系统弹窗——用户正看着这个会话,通知却还在复述他刚刚亲眼看到的内容。
第二天的结局可以预测:用户在系统设置里关掉网站通知,从此真的 important 的提醒也看不到了。通知系统的成败,不在于"能不能弹",在于**“什么时候保持沉默”**。我的实现核心只有 48 行 TypeScript,这篇拆的就是这条决策管线。
一、先定义"不打扰":决策的输入
每个通知请求携带两包数据:payload(弹什么)和 context(当下的环境状态):
export interface AppNotificationPayload {
channel: NotificationChannel; // browser | email
topic: string; // 主题(用于去重分组)
title: string;
body: string;
route?: string; // 点击跳转路径
senderId?: string | number | null;
conversationId?: string | number;
}
export interface AppNotificationContext {
enabled: boolean; // 用户设置的总开关
focused: boolean; // 页面是否持有焦点
visible: boolean; // 标签页是否可见
mentionOnly?: boolean; // 用户设置的"仅@我提醒"
currentUserId?: string | number | null;
currentUserName?: string;
}
这份 context 就是"打扰程度"的全部变量。设计它的时候我犯过一次错:最初只有 enabled 一个开关,结果"上班全开、开会静音"这种诉求没有出口——用户唯一的选项是整个关掉。加上 mentionOnly 之后,通知设置才从"开/关"变成了三档:全开、仅@我、关闭。
focused 和 visible 是两个容易混为一谈的状态:焦点是"正在和我交互",可见是"用户看得到这个窗口"(切到另一个窗口但没最小化时,可见为真、焦点为假)。两个都要独立采集——半最小化的窗口虽然没了焦点,但弹出的系统通知用户是看得见的,这时候弹不弹是产品决策,采集不了就做不了决策。
二、决策管线:六个闸门
export async function dispatchNotification(payload: AppNotificationPayload, context: AppNotificationContext) {
if (payload.channel !== 'browser') return false;
if (!context.enabled || (context.focused && context.visible)) return false;
if (context.currentUserId != null && payload.senderId != null
&& String(context.currentUserId) === String(payload.senderId)) return false;
if (context.mentionOnly && context.currentUserName
&& !payload.body.includes(`@${context.currentUserName}`)) return false;
if (!await ensureBrowserNotificationPermission()) return false;
// ——到这里才真正弹——
}
六个 if 按顺序排成一条管线,任何一个返回 false,函数就以"没弹"结束。逐个说为什么存在:
闸门一(渠道): email 渠道走别的发送器,浏览器弹窗只管 browser。
闸门二(打扰判断): 用户关了通知,或者正盯着这个页面(focused && visible)——沉默。这是治好"自己弹自己"的关键:用户正在看会话时,消息已经渲染在屏幕上了,通知是纯粹的噪音。注意条件是 focused && visible 同时为真才静默——窗口可见但失焦时(用户在看别的文档),通知仍然要弹。
闸门三(自己人过滤): senderId === currentUserId 直接跳过。多端登录时,你在手机上发的消息,电脑端会收到 WebSocket 广播——不挡住这条回声,每个设备都会为"你自己说的话"弹通知。
闸门四(仅@我): mentionOnly 开启时,正文里没有 @我的名字 就不弹。这个匹配是字符串级的——简单,但够用;它的朴素是刻意的:@解析在前端消息渲染时已经做过一遍富文本处理,通知层只需要对最终文本做一次廉价检查。
闸门五(权限): 浏览器通知权限。这条留到最后一并说。
三、权限请求的时机哲学
export async function ensureBrowserNotificationPermission() {
if (!('Notification' in globalThis)) return false;
if (Notification.permission === 'granted') return true;
if (Notification.permission === 'denied') return false;
return await Notification.requestPermission() === 'granted';
}
权限处理有三个状态,三个都有对应的归宿:granted 放行;denied 直接放弃且永不再次询问;default(还没问过)才弹出浏览器授权框。
"denied 不再问"是硬纪律。 浏览器把授权框的弹出频率和用户耐心挂钩:拒绝之后再请求,要么被浏览器静默忽略,要么弹一个更碍事的恢复提示。更重要的是用户体验层面的判断——用户拒绝过一次,第二次弹框不是"再给一次机会",是骚扰。尊重第一次拒绝,把"想要通知"的用户引导到系统设置里手动开启,才是体面的做法。
授权框的弹出时机也有讲究: 不要在页面加载时就请求权限——用户还没理解这个应用是什么,就收到一个"xxx 想要发送通知"的系统弹窗,条件反射就是拒绝。我的做法是把它内联在第一次真正要发通知的时刻:用户此刻正在看一条新消息,弹框的上下文不言自明(“是啊我想知道以后有消息”),授权率自然高。
四、弹出之后:tag 去重与点击路由
真正弹出的那三行也有细节:
const notification = new Notification(payload.title, {
body: payload.body,
tag: payload.topic, // 同 topic 的新通知替换旧通知,不堆叠
data: { route: payload.route },
});
notification.onclick = () => {
window.focus();
if (payload.route && window.location.pathname !== payload.route) {
window.location.assign(payload.route);
}
};
tag 是系统级去重。 同一个 topic(如 im.message.created 加会话 ID)的新通知会替换而不是堆叠旧通知——用户离开一小时回来,看到的是"最新一条"而不是 47 条叠罗汉。这个字段是浏览器通知 API 里最被低估的存在。
点击行为要闭环。 onclick 做两件事:聚焦窗口、跳到消息所在的路由。且先判断当前路径——已经在目标页面就不重复跳转,避免丢失用户的滚动位置。通知的价值一半在提醒,另一半在"点击后正好落在该看的地方";弹了通知、点了却回到首页的体验,等于告诉用户别再点。
返回值也是接口的一部分。 每个闸门返回 false,真正弹出返回 true——调用方(WebSocket 消息处理器)可以据此做统计:今天弹了多少、静默了多少、静默的原因分布。通知系统一旦上线,"弹了多少"和"为什么没弹"同样值得被看见。
五、小结
三点体会:
- 通知的第一能力是沉默。 六个闸门里五个决定"不弹"——打扰矩阵(开关 × 焦点 × 可见 × @我 × 自己回声)才是设计的主体,
new Notification()只是最后一行。 - 设置要给用户中间档。 全开和全关之间必须有"仅@我"——二元的开关最终会把用户推向全关,而全关意味着这个渠道死了。
- 权限的礼貌就是权限的成功率。 denied 永不重问、授权框内联在有意义时机的时刻弹出——这些细节决定了通知渠道的长期存活率。
上线后看了一眼统计:工作时段约七成消息被"页面正聚焦"这个闸门静默——它们本来就是用户眼前的内容,通知管的是不在场的那些。48 行代码,管住了这个分寸。
默认评论
Halo系统提供的评论