别让通知变成骚扰:浏览器通知的决策管线

SailTrack
2026-08-16
点 赞
0
热 度
3
评 论
0
  1. 首页
  2. 后端开发
  3. 别让通知变成骚扰:浏览器通知的决策管线

写在前面

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 消息处理器)可以据此做统计:今天弹了多少、静默了多少、静默的原因分布。通知系统一旦上线,"弹了多少"和"为什么没弹"同样值得被看见。

五、小结

三点体会:

  1. 通知的第一能力是沉默。 六个闸门里五个决定"不弹"——打扰矩阵(开关 × 焦点 × 可见 × @我 × 自己回声)才是设计的主体,new Notification() 只是最后一行。
  2. 设置要给用户中间档。 全开和全关之间必须有"仅@我"——二元的开关最终会把用户推向全关,而全关意味着这个渠道死了。
  3. 权限的礼貌就是权限的成功率。 denied 永不重问、授权框内联在有意义时机的时刻弹出——这些细节决定了通知渠道的长期存活率。

上线后看了一眼统计:工作时段约七成消息被"页面正聚焦"这个闸门静默——它们本来就是用户眼前的内容,通知管的是不在场的那些。48 行代码,管住了这个分寸。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数