1. 桌面通知弹窗到底是个什么东西
第一次接触 Notification API 是在做一个后台管理系统的时候。当时的需求很朴素:用户提交了一个耗时任务,比如批量导出数据、生成报表,处理可能要跑十几秒甚至几分钟。如果用户切到别的标签页去干别的事了,处理完了根本不知道,还得手动切回来刷新看结果。产品经理提了一句“能不能像桌面软件那样弹个提示”,于是我就去翻了浏览器的原生能力,找到了这个被很多人忽略的 Notification API。
说白了,Notification 就是浏览器提供的一套接口,让网页在获得用户授权之后,能够调用操作系统的通知中心,弹出一条系统级的消息提示。它长得不像网页里的那种 toast 或者弹窗,而是和系统里其他应用的通知一模一样,出现在屏幕角落或者通知中心里。哪怕你把浏览器最小化了,甚至切到了别的应用,这条通知依然能显示出来。这就是它和页面内提示最大的区别——它跳出了标签页的边界。
这个能力能解决的问题其实挺多的。在线聊天工具收到新消息、协同办公软件有人提到了你、网页版邮箱来了新邮件、后台任务跑完了、定时提醒到点了,这些场景都适合用桌面通知。它特别适合那些“用户不需要一直盯着页面,但结果出来又必须让用户知道”的场景。适合谁来学呢?我觉得只要你会写基本的 JavaScript,理解回调函数和 Promise,就能上手。它本身 API 非常简单,难点不在代码,而在于权限处理、兼容性细节和用户体验的把握。
我见过不少开发者第一次听说这个功能时一脸茫然,觉得“网页还能弹系统通知?”。确实,它不像 localStorage、fetch 这些 API 那么高频,但一旦用对了场景,体验提升是立竿见影的。接下来我就把这套东西从原理到实操,再到踩过的坑,完整地捋一遍。
2. 核心机制与权限模型拆解
2.1 为什么必须有用户授权这一步
Notification API 和普通 DOM 操作最大的不同,就是它涉及一个权限申请流程。你没法直接new Notification('hello')就弹出来,浏览器会先检查当前站点有没有获得通知权限。这个设计不是故意为难开发者,而是因为桌面通知的“侵入性”太强了。它出现在操作系统层面,会打断用户当前的工作,如果任何网页都能随便弹,那用户分分钟被垃圾通知淹没。
权限有三个状态:granted(已授权)、denied(已拒绝)、default(未决定)。只有granted的时候才能成功弹出通知。default状态下调用构造函数,浏览器会静默失败或者抛出错误,具体行为各浏览器略有差异。所以正确的做法是:先读Notification.permission,如果是default,再调用Notification.requestPermission()去主动申请。
这里有个很重要的经验:申请权限的时机非常关键。如果你一进页面就弹权限申请框,用户大概率会下意识点“拒绝”,因为他根本不知道你要通知什么。正确的做法是在用户做了一个明确的、和通知相关的操作之后再去申请。比如用户点击了“开启消息提醒”按钮,或者用户刚发送了一条消息、订阅了某个话题,这时候申请权限,用户是有心理预期的,通过率会高很多。
2.2 通知的构造与生命周期
拿到权限之后,创建通知就一行代码的事:
const notification = new Notification('任务完成', { body: '你的数据导出已经处理完毕,点击查看结果', icon: '/assets/icon.png', tag: 'export-task' });构造函数第一个参数是标题,第二个是配置对象。配置项里常用的有body(正文)、icon(图标)、tag(分组标识)、data(附带数据)、requireInteraction(是否要求用户交互后才消失)等。tag这个属性特别有用,相同 tag 的通知会互相替换,而不是堆叠出一堆。比如聊天消息,同一个会话用同一个 tag,新消息来了就替换旧的,不会刷屏。
通知对象有几个事件可以监听,最常用的是onclick和onclose。点击通知时触发onclick,你可以在这里做页面跳转、聚焦窗口等操作。onclose则在通知被关闭时触发。需要注意的是,通知本身是独立于页面的,它的生命周期由浏览器和操作系统管理,页面刷新或者关闭不一定能让通知消失,这点要心里有数。
2.3 和 Service Worker 的关系
纯页面里用 Notification 有个硬伤:页面一旦关闭,JS 就停了,自然也没法再发通知。如果你需要“页面关了还能收到提醒”,那就得靠 Service Worker 配合 Push API 了。Service Worker 在后台独立运行,可以接收推送消息,然后调用self.registration.showNotification()来弹通知。这条路径是 PWA(渐进式 Web 应用)实现离线通知的标准方案。
不过这里要区分清楚:本文主要聊的是页面内的 Notification API,也就是页面还活着的时候主动弹通知。Service Worker 那套涉及推送服务端、订阅管理等,复杂度高一个量级,适合有服务端推送需求的场景。如果你只是想在页面运行期间给用户提示,那纯 Notification 就够了,不用上 Service Worker。
3. 从零实现一个可用的通知模块
3.1 权限申请与状态管理
我一般会把权限相关的逻辑封装成一个独立模块,因为这块逻辑需要在多个地方复用。核心思路是:提供一个ensurePermission()方法,它返回一个 Promise,内部判断当前权限状态,该申请就申请,该拒绝就拒绝,最终 resolve 一个布尔值告诉调用方能不能发通知。
const NotifyManager = { async ensurePermission() { if (!('Notification' in window)) { console.warn('当前浏览器不支持桌面通知'); return false; } if (Notification.permission === 'granted') { return true; } if (Notification.permission === 'denied') { return false; } try { const result = await Notification.requestPermission(); return result === 'granted'; } catch (e) { return false; } } };注意requestPermission在新版浏览器里返回 Promise,但老版本是回调式的。为了兼容,可以判断返回值是不是 Promise,或者干脆用回调写法包一层。我实测下来,现在主流浏览器基本都支持 Promise 写法了,但如果你要兼容比较老的版本,加个判断更稳妥。
还有一个细节:Notification.permission是只读的,你没法手动改它。用户一旦拒绝,你只能引导他去浏览器设置里手动改回来。所以申请权限前一定要想清楚时机,别浪费掉唯一一次“第一印象”。
3.2 发送通知的完整封装
封装发送逻辑时,我会把常用配置做成默认值,调用方只需要传标题和正文就行。同时加上点击回调的处理,让通知点击后能聚焦到页面并跳转到指定位置。
async function sendNotification(title, options = {}) { const granted = await NotifyManager.ensurePermission(); if (!granted) return null; const defaultOptions = { icon: '/favicon.ico', badge: '/badge.png', tag: 'default', renotify: false }; const notification = new Notification(title, { ...defaultOptions, ...options }); notification.onclick = function () { window.focus(); if (options.onClick) { options.onClick(this); } this.close(); }; return notification; }这里renotify配合tag使用,控制相同 tag 的通知替换时是否再次提醒用户(震动、声音)。默认是 false,也就是静默替换。如果你希望新消息来了要“叮”一下,就设成 true。但别滥用,否则用户会烦。
3.3 在真实业务里接入
拿一个消息提醒的场景举例。假设页面通过 WebSocket 接收新消息,收到消息后判断当前页面是否可见,如果不可见就发桌面通知。
socket.onmessage = function (event) { const msg = JSON.parse(event.data); if (document.hidden) { sendNotification(`来自 ${msg.from} 的新消息`, { body: msg.content, tag: `chat-${msg.from}`, renotify: true, onClick: () => { location.hash = `#/chat/${msg.from}`; } }); } else { renderMessage(msg); } };document.hidden这个判断很关键。如果用户正盯着页面看,你再弹个系统通知就是多此一举,页面内提示就够了。只有用户切走了、页面不可见了,桌面通知才有价值。这个“按需通知”的原则能大幅降低对用户的打扰。
4. 兼容性、坑点与排查实录
4.1 各平台表现差异
Notification API 的兼容性整体不错,但细节上各平台有差异。桌面端 Chrome、Edge、Firefox 支持都很好,Safari 从某个版本开始也支持了,但表现略有不同。移动端的差异就大了:Android 上的 Chrome 支持,但 iOS 上的 Safari 长期不支持页面内 Notification,直到较新版本才逐步放开,而且限制更多。
| 平台 | 支持情况 | 注意事项 |
|---|---|---|
| 桌面 Chrome | 完整支持 | 通知样式跟随系统 |
| 桌面 Firefox | 完整支持 | 权限提示文案略有不同 |
| 桌面 Safari | 支持 | 需要用户手势触发权限申请 |
| Android Chrome | 支持 | 通知进入系统通知栏 |
| iOS Safari | 较新版本支持 | 限制较多,需实测 |
Safari 有个特殊要求:权限申请必须在用户手势(比如 click 事件)的同步调用栈里触发,否则会被拒绝。也就是说你不能在setTimeout或者异步回调里申请权限,必须在点击事件的同步代码里调用。这个坑我踩过,当时在 Promise 里申请,Safari 直接无视了。
4.2 常见问题速查
下面这张表是我在实际项目里遇到过的典型问题和处理方式,整理出来方便对照排查。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 调用后没反应 | 权限不是 granted | 先检查Notification.permission |
| 权限框不弹出 | 非用户手势触发 | 放到 click 等同步事件里 |
| 通知一闪而过 | 系统通知设置被关 | 引导用户检查系统通知权限 |
| 通知不显示图标 | 图标路径错误或跨域 | 用同源图片,检查路径 |
| 点击通知没反应 | 未绑定 onclick | 补上事件处理 |
| 通知重复堆叠 | 未设置 tag | 相同场景用同一 tag |
4.3 几个容易忽略的细节
第一个细节是HTTPS 限制。Notification API 在非安全上下文(http)下,部分浏览器会直接禁用。本地开发用 localhost 没问题,但线上必须是 https。这个在部署时容易忘,测试环境好好的,上线就失效,排查半天。
第二个细节是通知的持久性。默认情况下,通知会在几秒后自动消失。如果你希望它一直留着直到用户处理,设置requireInteraction: true。但这个属性不是所有浏览器都认,而且滥用会让用户觉得烦,慎用。
第三个细节是图标尺寸。不同系统对通知图标的尺寸要求不一样,太大太小都可能显示异常。一般建议用 128x128 或者 192x192 的 PNG,正方形,背景透明。用 SVG 的话部分平台不支持,还是 PNG 稳。
提示:申请权限前先判断
'Notification' in window,避免在不支持的环境里直接报错,影响后续代码执行。
5. 用户体验层面的取舍心得
5.1 什么时候该弹,什么时候不该弹
技术实现只是第一步,真正难的是判断“该不该弹”。我的原则是:只在用户明确关心、且当前无法通过页面内方式感知的时候才弹。用户正在看页面,页面内提示就够;用户切走了,桌面通知才有意义。用户主动订阅了某个话题,那相关更新可以弹;用户只是随便逛逛,别拿营销消息去骚扰。
还有一个频率控制的问题。如果短时间内大量通知涌出来,用户会直接关掉权限,甚至把站点拉黑。所以我会加一个节流逻辑,比如同一 tag 的通知在 N 秒内只弹一次,或者把多条消息合并成一条摘要通知。这个“合并通知”的思路在聊天场景特别有用:如果用户切走了,来了 10 条消息,与其弹 10 次,不如弹一条“你有 10 条新消息”。
5.2 权限被拒绝之后怎么办
用户拒绝权限是很常见的事,别把它当成失败。正确的做法是优雅降级:页面内提示照常工作,同时在合适的位置(比如设置页)放一个引导,告诉用户“如果想接收桌面提醒,可以在浏览器设置里开启”。不要反复弹权限申请框,浏览器也不会让你反复弹,反而会让用户反感。
我一般会在权限被拒绝后,把状态记到 localStorage 里,下次不再主动申请,只在用户主动点击“开启提醒”时才尝试引导。这样既尊重用户选择,又保留了挽回的余地。
5.3 和页面内提示的配合
桌面通知不是替代页面内提示,而是补充。我的做法是:页面可见时用页面内 toast,页面不可见时用桌面通知,两者共用同一套消息数据。这样无论用户在不在,都不会错过重要信息,同时也不会被重复打扰。这个“双通道”策略在实际项目里效果很好,用户反馈也正面。
最后分享一个小技巧:通知的data字段可以塞任意数据,点击通知时通过event.notification.data取出来,用来做精确的跳转和上下文恢复。比如把消息 ID、会话 ID 都塞进去,点击后直接定位到那条消息,体验会很顺滑。这个字段很多人不知道,但用好了能省不少事。