我们做前端经常会收到产品需求“用户下单后要发个通知”、“有站内消息时浏览器最好弹一下”。以前一提到通知第一反应就是短信、邮件、推送SDK其实浏览器本身就支持一套原生的通知能力利用好它很多轻量级提醒需求完全不需要引入第三方服务。这套能力就是 Web Notification API配合 Service Worker 还能实现消息推送的离线提醒。这篇文章我会从零开始把浏览器通知功能的原理、权限申请、基础弹窗、事件处理、定时轮询、消息队列、兼容性问题这些全部过一遍全部是项目里实测过的方案和踩坑记录适合正在做前端开发的工程师或者对网页交互体验有要求的全栈同学参考。1. 浏览器通知功能概览与原理1.1 什么是 Web Notification API很多人分不清“浏览器通知”和“Web Push 推送”。简单说浏览器通知就是页面里通过Notification构造函数创建的一个系统级弹窗它不属于页面 DOM而是操作系统原生绘制的一个消息条。用户即使切到其他标签页或者把浏览器最小化只要浏览器进程还在运行通知就有机会弹出来。这套 API 非常独立你不需要注册任何第三方账号打开任意一个 HTTPS 页面申请权限后就能用。核心调用就几行if (Notification in window) { Notification.requestPermission().then(permission { if (permission granted) { new Notification(你好, { body: 这是一条测试通知 }); } }); }这是最简单的用法但真实项目里只做到这一步肯定不够。生产环境要考虑通知点击跳转、重复通知过滤、通知关闭后的统计、和后台任务的协同这些是我后面要重点讲的。1.2 权限模型与浏览器差异通知权限遵循“用户代理”的权限模型也就是说权限是“站点 浏览器”维度而不是“用户 页面”维度。同一个用户访问你的站点在 Chrome 里授权了不等于在 Firefox 里也授权了。同一个浏览器里访问不同二级域名权限通常也不互通。权限状态一共有三种default未询问、granted允许、denied拒绝。这里有一个容易踩的坑default状态看起来是“未决定”但一旦用户点了浏览器的地址栏前的图标手动改成了阻止那么浏览器内部的权限状态其实变成了denied但页面上用Notification.permission读到的值有可能仍然是default。这个问题在旧版 Chrome 里出现过现在虽然修了但要提醒一句不要拿Notification.permission default作为“可以再次弹权限请求”的唯一判断依据最好结合permissions.query({ name: notifications })来查。桌面端的 Safari 早期只支持 callback 风格不支持 Promise代码要做兼容。移动端 Chrome 对 Notification 的支持也有版本差异。这些细节我放到第 4 节单独讲。2. 基础实现从申请权限到弹出一条通知2.1 申请权限的正确姿势权限请求不能乱弹这是很多产品经理不理解、前端需要把关的地方。浏览器对权限请求有“信任阈值”一说——如果用户刚打开页面就强制弹权限拒绝率超过 80%。正确做法是把权限请求绑定在一个用户明确想要接收消息的动作上比如点击“开启消息提醒”按钮或者完成订阅之后。下面是封装好的权限申请函数我把多浏览器差异也处理进去了async function requestNotifyPermission() { // 老版本 Safari 用 callback if (typeof Notification.requestPermission ! function) { return Notification.permission; } // 新标准是 Promise const permission await Notification.requestPermission(); return permission; }注意requestPermission在每次调用时都会返回当前权限状态不一定保证弹窗。如果用户已经是granted直接拿状态返回。如果是denied那就不能再次弹窗。我们可以在 UI 上给用户显示一个“如何在浏览器设置中开启通知”的引导图而不是反复调用。在实际项目里我还会加一个“用户是否曾经拒绝过”的本地标记。用localStorage存一下比如notify_denied1避免刷新页面后又在某个交互动作里反复触发请求。这属于产品层面的保护但对用户体验影响非常大。2.2 创建通知与参数详解真正的业务通知不会只有一句话我们需要构造更丰富的通知内容。Notification构造函数接受两个参数title和options。options里值得关注的字段如下body正文内容最多显示两行超出部分会省略。icon通知条左侧的图标建议使用 192x192 以上的图片。badge在部分系统如 Android Chrome 上显示的小徽标建议是灰度透明 PNG。image稍大尺寸的展示图目前 iOS Safari 不支持。tag通知分组标记。同一个tag的新通知会替换旧通知不产生堆积。data附加任意数据点击通知时可以通过事件读取。requireInteraction布尔值设为true表示通知常驻不自动消失。但用户要手动关闭容易打扰人。actions通知上可以附带一些按钮操作如“查看”“忽略”目前移动端 Safari 不支持。一个实用的通知示例function showNotification({ title, body, url, tag }) { if (Notification.permission ! granted) return; const notify new Notification(title, { body, tag: tag || default-tag, icon: /static/img/logo-192.png, badge: /static/img/badge.png, data: { url: url || location.href }, requireInteraction: false, actions: [ { action: open, title: 查看详情 }, { action: later, title: 稍后处理 } ] }); return notify; }这里留意tag字段的意义。有些需求是“每一笔订单都要弹一次通知”那就不要设tag或者用不同tag。如果需求是“同一个会话内只显示最新的一条”就固定用一个tag。用错tag会导致通知被覆盖看起来像是“漏通知”了。2.3 事件处理与通知交互通知不是弹出来就完事了。用户点击通知、关闭通知后端很可能都需要记录。比如点击通知后要跳转到对应页面关闭通知后要停止声音播放或取消角标。这些都要通过事件监听来做。Notification实例上主要有四个事件show、click、close、error。const notify showNotification({ ... }); if (notify) { notify.addEventListener(click, (event) { event.preventDefault(); const { url } event.currentTarget.data; window.open(url, _blank); notify.close(); }); notify.addEventListener(close, () { // 通知关闭可以做埋点 trackNotifyClose(manual_close); }); notify.addEventListener(error, (err) { console.error(通知展示失败, err); }); }有一个坑在用户点击通知时浏览器会自动聚焦到当前页面并触发window.focus。如果你希望点击通知后新建标签页而不是替换当前页面记得在 click 处理函数里先preventDefault()再window.open。另外部分浏览器在通知关闭后close事件并不会在所有场景下都触发比如通过requireInteraction: true的通知用户点了系统层的“关闭”按钮close事件可能不会被触发。所以不要依赖这个事件做关键业务逻辑只能做辅助统计。3. 进阶实战定时通知与消息队列管理3.1 通过 Service Worker 实现离线通知页面必须处于打开状态才能用Notification吗不是的。借助 Service Worker 的showNotification()方法即使页面已经关闭只要浏览器后台还有 Service Worker 在运行仍然可以弹通知。前提是用户已经授予通知权限并且你注册了 Service Worker。Service Worker 可以在自己的sw.js里响应推送事件。如果是自定义的定时提醒我们常常在页面里向 Service Worker 发消息让后台去定时触发。先注册 SWif (serviceWorker in navigator) { navigator.serviceWorker.register(/sw.js).then((registration) { console.log(SW registered, registration.scope); }); }然后页面里给 SW 发消息navigator.serviceWorker.ready.then(registration { registration.showNotification(后台提醒, { body: 这是一个由 Service Worker 弹出的通知, icon: /static/img/logo-192.png, data: { url: /detail.html?id123 } }); });区别在于普通new Notification()只能在前台页面使用而 Service Worker 的showNotification()可以在后台调用。但普通页面里也需要用registration.showNotification()吗不强制。不过为了让代码统一我建议页面侧也尽量通过 SW 来弹通知因为后续如果要换成 Push API逻辑迁移成本会低很多。3.2 定时触发与逻辑设计“定时通知”最常见的问题是浏览器限制后台定时器的执行频率。如果你在页面里用setTimeout设置了一分钟后弹通知然后用户把页面挂起或者浏览器进入省电模式定时器可能会被冻结。语音唤醒或时间到了不弹就是这种情况。可靠做法是把定时任务放到 Service Worker 里。虽然 SW 也不是完全意义上的后台守护进程但它的生命周期比页面稳定很多。浏览器允许 SW 在注册后持续运行一小段时间并且在检测到事件比如notificationclick后被唤醒。如果只是纯粹“延时弹通知”可以用setTimeout写在 SW 里但不保证长时间断网或关机后仍能触发。如果需求是需要精确到秒级同时需要服务端参与那推荐方案是 Web Push 协议服务器控制推送时机浏览器收到推送后触发push事件。但 Web Push 需要 VAPID 密钥依赖额外后端这里不详细展开。下面我给出一个真实的项目示例用户设置“每30分钟提醒一次喝水”。我把这个需求拆成两部分服务端生成下一次提醒的时间戳并返回给前端前端在 localStorage 中存储这个时间戳然后在页面可见且定时器可用时用setInterval检查到点后调用 SW 弹通知。同时在页面隐藏时尽量依赖 SW 内部定时器。// 页面脚本 function scheduleWaterReminder(nextTs) { localStorage.setItem(waterNextTs, nextTs); const remain Math.max(0, nextTs - Date.now()); setTimeout(() { navigator.serviceWorker.ready.then(reg { reg.showNotification(喝水提醒, { body: 已经工作半小时了起来动一动吧。, tag: water }); }); }, remain); }这样的实现有一个不可避免的缺点如果用户把整个浏览器完全关掉那么定时通知肯定不会触发。目前纯粹的前端技术解决不了这个问题需要后端配合 Web Push。所以接到需求时第一件要和产品确认的事就是允许的最小触发精度和浏览器关闭场景下的要求是什么。3.3 多通知管理与用户配置通知一多就需要管理。用户可能今天不想收促销通知明天又想开了。你可以提供一个简单的设置面板把通知权限申请、全局开关、细分类型开关都放进去。我推荐用这三个字段控制通知展示notifyEnabled总开关默认 false用户手动开启。notifyTypes一个对象如{ order: true, message: true, promotion: false }。notifyTimes静默时段比如每天 22:00 到次日 8:00 不弹。展示逻辑放在一个统一的shouldNotify(type)函数里function shouldNotify(type) { if (!localStorage.getItem(notifyEnabled)) return false; const types JSON.parse(localStorage.getItem(notifyTypes) || {}); if (!types[type]) return false; const now new Date(); const hour now.getHours(); if (hour 22 || hour 8) return false; return Notification.permission granted; }当总开关为关闭时即使之前已经申请过权限也不要弹通知。因为用户关闭开关的意图是“暂时不想被打扰”而浏览器权限仍然有效。这里要注意Notification.permission只能表示权限是否允许不能表达“用户当前是否愿意接收某类内容”。这两者一定要分开存。多通知管理还有一个细节相同tag的通知会互斥更新。对消息中心这种场景如果你希望每条消息都独立展示就不要设tag。如果希望同类型只保留最后一条就固定tag。通知的tag值还可以控制关闭例如点击“查看全部”时把所有tag为message的通知全部关闭避免用户逐条关闭。4. 兼容性、常见坑与性能优化4.1 浏览器兼容性速查前端做任何功能都得先看兼容性。我整理了一份实战笔记基于近两年的主流浏览器版本浏览器Notification APIService Worker showNotification点击事件通知按钮 actionsChrome 桌面支持支持支持支持Edge 桌面支持支持支持支持Firefox 桌面支持支持支持支持Safari 桌面支持callback 风格部分支持新版支持支持不支持iOS Safari14.5 支持需要添加主屏幕14.5 受限支持支持不支持Android Chrome支持支持支持支持如果目标用户中有大量 iOS Safari不要指望actions能正常显示。在 iOS 上通知展示策略也有差异如果是普通网页添加了主屏幕通知行为会好一些直接用浏览器访问的页面通知条只展示在锁屏或企业证书环境下有限支持。做兼容性判断时不能只用Notification in window来检测因为 Service Worker 环境下并没有window。需要这样封装function isNotifySupported() { return typeof Notification ! undefined || typeof self ! undefined Notification in self; }在 SW 内部self.Notification和self.registration.showNotification可以配合使用。4.2 权限策略与移动端注意事项移动端的权限弹窗和桌面端不一样。Android Chrome 会直接弹系统授权框iOS Safari 只有把网页添加到主屏幕时才能较好地支持通知普通浏览器标签页内调用requestPermission在部分版本上会直接返回denied或静默失败。所以一个很现实的问题是移动端网页如果要玩通知最好引导用户“添加到主屏幕”否则体验天然受限。权限策略上还有一点Notification.requestPermission()只能由用户手势触发。如果你在页面加载后立刻异步调用浏览器会判定为“非用户手势”往往直接拒绝或者不展示授权框。用户手势包括 click、keydown 等不包括移动端的 touch 在部分浏览器上的默认事件所以最好绑定在click上。有个场景我遇到过用户在 iOS Safari 点击“开启通知”按钮页面跳到系统设置询问这个时候 App 会进入后台从系统设置返回页面时Notification.permission已经更新成granted了。这时候不要再调requestPermission直接读取即可。JavaScript 异步流程要考虑到这种中断情况可以在visibilitychange事件里做一次检查避免页面卡在 loading。4.3 常见问题排查下面我把实战中最常遇到的 7 个问题整理成速查表每个都是调试到崩溃才总结出来的现象可能原因解决方案点击“开启通知”后无反应页面不是 HTTPS 或 localhost检查站点协议非安全上下文无法使用权限弹窗没有出现浏览器限制自动弹窗或之前已选择过将权限申请绑定在按钮 click 事件上通知弹出来又立刻消失系统通知设置短自动销毁时间设置requireInteraction: true或者提醒用户调整系统设置通知无法点击跳转没有监听 click 事件或未调用 preventDefault给通知绑定 click 并处理 url删除 tag 相同的通知后新通知不显示相同 tag 的通知会覆盖旧通知换成不同的 tag 或移除 tagService Worker 弹通知失败未注册 SW或者注册响应比通知晚用navigator.serviceWorker.ready等待开发环境点击通知后打开到了不正确页面data.url使用了相对路径解析基准错误在通知的 data 中存储完整绝对 URL还有一个小坑new Notification()在部分浏览器尤其是老版本 Chrome中如果icon指向的图片无法加载会抛出异常。解决方法是提前 catchtry { const notify new Notification(标题, { icon: /bad.png }); } catch (e) { console.warn(通知创建失败, e); }当然最合理的是在创建前用图片预加载或直接校验图片地址可访问性。4.4 性能与用户体验优化通知功能虽然轻量但也会带来性能问题。主要集中在连续弹通知、图片加载、事件回调泄漏三个方面。连续弹通知会导致通知中心堆积用户会烦。合理的策略是合并比如三分钟内有 5 条站内信不要每条都弹而是弹一条“你有 5 条新消息”。这道逻辑简单粗暴地把同一tag的通知刷新为最新内容// 同一 tag 下通知会覆盖旧通知 function notifyWithMerge(title, body, tag, url) { navigator.serviceWorker.ready.then(reg { reg.showNotification(title, { body, tag, data: { url } }); }); }如果有不同 tag要自己做一个合并控制器先把通知存到内存队列如果两秒内还有新的通知就延迟展示最后一条并增加计数。这个方案我在这里提供一个伪代码思路let notifyQueue []; let timer null; function queueNotify(title, body, tag, url) { notifyQueue.push({ title, body, tag, url }); if (timer) return; timer setTimeout(() { const latest notifyQueue[notifyQueue.length - 1]; const count notifyQueue.length; // 若只有一条正常显示多条则显示合并文案 const finalBody count 1 ? 你收到 ${count} 条新消息 : body; navigator.serviceWorker.ready.then(reg { reg.showNotification(finalTitle, { body: finalBody, tag: latest.tag, data: { url: latest.url } }); }); notifyQueue []; timer null; }, 2000); }图片加载是另一个隐性问题。通知里引用的图片如果通过 HTTP 加载在某些浏览器上会被拦截因为通知 API 要求安全资源。建议所有图片都走 CDN 且为 HTTPS。图片尺寸不要超过 Chrome 限制的 512x512 或 4KB 体积不同版本限制不同否则加载失败或失真。可以在构建时用工具将大图压成适合通知展示的尺寸。关于事件回调泄漏每弹一条通知如果都给它绑一个 click 事件旧通知关闭时如果忘记移除监听器虽然浏览器会回收但在长时间运行的单页应用里还是建议统一由onnotificationclick全局处理而不是每条通知单独绑定。SW 里用全局监听self.addEventListener(notificationclick, (event) { const url event.notification.data.url; event.notification.close(); event.waitUntil( clients.matchAll({ type: window, includeUncontrolled: true }).then((clientList) { for (const client of clientList) { if (client.url url focus in client) { return client.focus(); } } return clients.openWindow(url); }) ); });这种方式避免了对每个Notification实例的重复绑定代码也更干净。还有一个很少人提但很重要的体验点通知内容中的title和body长度。Chrome 桌面端在标题超过 20 到 40 个字符时会截断正文超过两行也会省略。所以产品文案要自己控制在有效长度内不要依赖缩略。更不要把关键信息比如验证码放在正文末尾一定会被截断。5. 从 demo 到生产我实际接入后的一些体会做通知功能最容易被低估的是权限和用户预期管理。我见过不少项目上线一周后发现转化率低原因就是第一次访问就弹权限用户被吓跑。正确的姿势应该是先埋一个“引导气泡”解释通知会带来什么等用户点击了按钮再申请权限。这不仅是交互设计更是前端工程里要求我们为产品决策把关。还有一个体会调试通知功能时最好准备两台设备。一台桌面 Chrome一台 Android 真机因为 iOS Safari 的权限坑实在太多。测试的时候要在浏览器设置里重置站点权限否则改完代码再测试权限状态永远是旧的你根本不知道代码哪里出问题。最后再说一个小技巧把通知触发逻辑从 UI 里解耦出来做成一个独立模块。我们项目里就把shouldNotify、queueNotify、showNotifyBySW、handleNotificationClick都放在一个notify.js文件里页面组件只调用notifyModule.push({ type: message, ... })。这样后续如果要换成 Web Push只需要替换模块内部实现所有页面都不用动。维护起来轻松很多。通知功能本身不复杂但它牵扯到权限体系、SW 生命周期、系统交互、用户设置这些约定如果散落在各个组件里后期改一个字段名就能引发连环 bug。集中管理才是长期稳定的关键。