想象你常去的咖啡馆出了新品,店员说:“回头给你寄张优惠券。”可你不住在店里,信怎么到你手上?现实世界靠的是邮局——寄信人写好地址,邮局分拣、运输,最后投进你家的信箱,哪怕你不在家。Web 世界里也藏着一座电子邮局:网站的服务器是寄信人,负责运输的是“推送邮局”,你手机里住着一位全年无休的看门人,专门替你签收信件;最后,那封摊开在你眼前的信纸,就是浏览器弹出的通知。更妙的是,这封信从寄出起就锁在加密信封里,连邮递员都偷看不走。本文将把这座信息邮局从寄信、运输、投递到拆信的每个环节拆开讲透,读完你就能为自己的应用铺一条“人未到、消息先到”的邮路。
掌握这套“信息邮局”,是 PWA 与 AI 应用工程师的必修课:模型跑完的异步结果、个性化推荐、系统异常告警,都能第一时间送到用户手边,让 AI 从“等你来问”变成“主动来找你”。
目录
可视化演示
演示Web Push推送服务注册、订阅生命周期、消息载荷加密与通知交互,理解权限策略与跨浏览器兼容。
本节配有交互动效(进入视口后自动加载,约 3KB)。
推送流程 服务器 Service Worker srv→not 端到端推送管道
概念辨析:邮递员与信,是两回事
常去咖啡馆寄信,你得先分清两样东西:一个是骑着车跑腿的邮递员,一个是你手里拆开的那封信。Web 世界里,Push(推送)就是那个邮递员,负责把消息从服务器“主动”送到浏览器;Web Notification 则是你眼前展开的信纸,负责把内容展示给用户。它们是两个独立 API,却总被初学者混为一谈。
为什么需要分工?因为“投递”和“展示”的时机完全不同。Push 消息可以在页面关闭、浏览器退到后台时悄悄抵达;而展示必须发生在“用户看得到”的界面层。于是浏览器设计了一个中间人——Service Worker,它常驻后台,负责签收推送消息,再调用 Notification API 把内容弹出来。
完整链路是:应用服务器把消息 POST 到推送服务,推送服务按订阅地址投递到用户设备,浏览器唤起 Service Worker 的 push 事件,事件回调里调用 self.registration.showNotification(...) 展示通知。三者各司其职:Push 管“怎么送到”,Service Worker 管“何时接收”,Notification 管“如何呈现”。
所以下次有人说“我收到了推送”,你可以追问一句:“是邮递员到了,还是信纸已经摊开了?”只有 Push 投递加上 Notification 展示首尾衔接,才构成完整的通知体验——这也是 PWA 离线和后台场景下最常用的组合拳。
- Push 投递服务器将消息交给推送服务,如同寄信人把信交给邮局分拣。
- Service Worker 中转浏览器后台进程按订阅唤醒,如同看门人常年守在门口签收信件。
- showNotification 展示调用浏览器本地 API 弹出通知,如同把信纸摊开在桌上。
订阅生命周期:先登记地址,邮路才能通
邮局寄信有个前提:它得先知道你的地址。Web Push 里,这个“登记地址”的动作叫订阅(subscribe)。用户首次打开页面,应用调用 pushManager.subscribe(),浏览器便生成一个独一无二的 PushSubscription 对象——这就是你的“电子门牌号”。
这个门牌号包含三件套:endpoint 是投递端点,相当于“XX路XX号”;p256dh 是公钥,相当于信箱的锁;auth 是认证密文,相当于备用钥匙。应用拿到订阅后要立刻 POST 给自己的服务器保存。服务器推送消息时,就靠 endpoint 作为收件地址,靠 p256dh 加密内容。
有生就有灭。用户可能关停账号、清除站点数据,浏览器也可能回收失效订阅。取消订阅时前端调用 subscription.unsubscribe(),同时必须请求服务器删除对应记录;服务器推送时若收到 404/410 响应,说明地址已废弃,也应主动清理。订阅还会过期(expirationTime),需要监听并自动“续订”,保持邮路畅通。
把订阅想象成一份“寄信契约”再合适不过:前端退订而服务端不删,就像你搬了家却忘了通知杂志社,广告照样寄往空屋,白费纸张。真正的工程实践里,收到 410 状态码主动清理订阅表,是每个推送系统必须写进代码里的“邮差自觉”。
- subscribe 创建浏览器生成订阅对象,如同邮局发给你一个专属信箱。
- persist 持久化把 endpoint 与密钥发给应用服务器存档,如同把新地址抄进通讯录。
- unsubscribe 取消用户主动退订,门牌号作废。
- cleanup 服务器同步服务端删除记录,否则信件会不断投往空屋。
消息加密机制:给信里加一道双人锁
寄机密信件时,你会担心邮递员偷看。最简单的办法是买一把“双人锁”:只有你和收信人各持一把钥匙,才能共同打开铁盒,途经的邮递员只能看到盒子,看不到一个字。Web Push 的加密正是基于这个智慧——推送服务可以送信,但永远读不了信。
加密的第一步是身份认证。应用服务器用 VAPID 协议生成一对公钥/私钥:私钥在服务器上对 JWT 令牌签名,推送服务验证签名后确认“寄信人是真的”,再放行。没有这一步,任何人都能冒用你的名义给用户推送垃圾信息,相当于伪造寄件人地址。
第二步是密钥协商与加密。服务器取订阅里的 p256dh 公钥,与自己的私钥做 ECDH 椭圆曲线协商,得到一个双方共享的会话密钥;再用 AES-128-GCM 对消息载荷加密。数据到达设备后,Service Worker 用同样的共享密钥解密,再展示通知。整个过程推送服务只能转发密文,无法读取内容,实现端到端保密。
记住:推送服务是“邮局”,可以看信封上的地址,却永远不该看到信里的内容。凡是把消息明文交给推送服务的做法,都相当于把情书贴在明信片上寄出——用户数据和隐私安全,全靠这一层加密守护。
- VAPID 签名服务器签发 JWT 证明身份,如同信封盖上骑缝章,防止冒名寄信。
- ECDH 协商双方私钥加对方公钥算出共享密钥,如同两把钥匙同时插入才能开锁。
- AES 加密用共享密钥加密消息体,中间节点只能看到乱码。
- 解密展示Service Worker 收到密文后用同一共享密钥反向还原。
通知交互流程:信到了,然后呢?
信送到你手上之后,剧情才刚刚开始:你可能拆开细读(点击),可能顺手夹进书里(留存),也可能看都不看丢进垃圾桶(关闭)。Web Notifications 用事件机制把这三类反应一一记录下来,让开发者能精准回应。
应用在 Service Worker 里调用 showNotification 时,可以指定 title、body、icon 等外观,还能塞一个 data 字段,把“这封信来自哪个订单”之类的上下文带进去。用户点击通知时,浏览器触发 notificationclick 事件,回调里通过 clients.matchAll() 找到已打开的页面窗口并调用 focus() 聚焦;如果没有窗口,则 clients.openWindow() 新开页面直达详情。
通知还可以带 actions 按钮(如“查看”与“忽略”),点击不同按钮会携带不同的 action 值,供回调区分处理。实践中常用 tag 字段对通知分组(同一 tag 的通知会合并替换),用 renotify 控制是否再次提醒,避免满屏轰炸;展示时机则应选在用户可能注意到的时刻,避开深夜打扰。notificationclose 事件则负责“用户手动划掉通知”这类清理动作。
交互设计上,好通知像一扇门:用户点一下就能到达想去的地方。如果通知点击后只是“哦我知道了”,甚至点了没反应,用户很快会把它划掉,你的邮路也就彻底失宠了——所以 data 带上文、action 给选项、tag 做分类,是三件必须养成的习惯。
- notificationclick用户点击通知,事件携带通知对象与按钮标识。
- focus 聚焦找到已有窗口并拉起到前台,如同推门进入已经开着的房间。
- openWindow 开窗没有窗口时新开页面直达目标页。
- close 清理按钮点击后主动关闭通知,避免残留打扰。
权限策略:别在人家门口硬塞广告
想象一个推销员,见面第一秒就掏出喇叭喊“办卡吗”——你大概率关门。但如果他先在店门口放一杯免费试饮,等你尝了觉得不错,再问“要不要留个地址寄优惠券”,成交率就高得多。通知权限的请求,正是这个“要地址”的瞬间。
浏览器规定 Notification.requestPermission() 必须在用户手势(例如点击按钮)触发的代码路径里调用,才会弹出权限询问框。权限状态只有三种:granted(已授权)、denied(已拒绝)、default(未询问)。一旦用户选择“拒绝”,浏览器不再弹出询问框——这就像邮局把那个推销员拉进了黑名单。
所以工程上要讲究策略:先用站内引导让用户理解“订阅后能得到什么”,再在恰当时机(如用户完成一次有价值操作后)请求权限;被拒绝后不要反复轰炸,而是提供“重新开启”的站内说明,引导用户去浏览器设置中手动打开。收到推送后还要监测送达率,如果用户长期不点击,应主动降低推送频率,避免“邮件疲劳”。
权限的本质是“用户把家门钥匙交给你”的信任决定。尊重这个决定,在它不点头时不硬塞、被拒绝后不纠缠、拿到授权后克制地使用,你的邮路才能长久畅通——这也是为什么产品设计里“请求权限的时机”往往比“推送的内容”更决定成败。
- 状态读取任何时刻用 Notification.permission 查询三态。
- 手势触发在用户 click 事件回调里发起请求,如同挑对方心情好的时候递名片。
- 结果分支根据返回值决定是订阅还是走降级引导。
跨浏览器兼容:各国有各国的邮规
出国寄信前你得研究当地邮规:有的国家要贴邮票、有的要填税号、有的周末不送。浏览器世界同样如此:Chrome 用自己的 FCM 做推送服务,Firefox 用 Mozilla 自建服务,Safari 直到 16.4 才完整支持 Web Push,而且要求用户先把站点加入程序坞或主屏幕。无视这些差异,你的“邮路”可能在某个浏览器上直接断掉。
工程上的第一道防线是特性检测:注册 Service Worker 前先确认 'serviceWorker' in navigator,再确认 'PushManager' in window 与 'Notification' in window。三者全部为真才走推送流程,否则降级为站内消息、邮件等替代方案,保证核心通知能力不断档。
具体差异要逐个击破:旧版 Chrome 需要 manifest 里的 gcm_sender_id;Firefox 与 Chrome 的订阅端点格式不同,服务器要用统一的 web-push 库封装;Safari 的权限请求 UI 与桌面端不同,需要额外的“添加到主屏幕”引导。调试时,Chrome DevTools 的 Application 面板可以手动触发 push 事件、查看订阅详情、模拟离线,是排查“信为什么没送到”的利器。
兼容性的心法是“先查路况、再定路线”:能走大路(标准 API)就走大路,不能走就抄小路(降级方案),永远别让用户因为浏览器不同而收不到信。把特性检测写成应用的第一道关卡,把降级方案做成最后的底线,这才是一个成熟工程师该有的邮局思维。
- 逐项检测三个能力缺一不可,如同出远门先查三样证件。
- 分支处理能力齐全走推送,否则走降级通道。
- 统一封装服务端用 web-push 库适配各家端点格式。
AI 应用场景
AI 智能触达引擎:让模型决定何时寄信
阶段:个性化推荐 / 用户触达
解决的问题:传统推送“定时轰炸”,用户烦不胜烦、卸载率飙升。借助用户行为序列训练点击率预测模型,AI 算出每位用户未来 1 小时内的最佳接收窗口与最可能感兴趣的内容主题,再由 Web Push 在最佳时刻投递,送达率、打开率显著提升。
具体案例:某内容社区上线“智能推送”:模型给每条待推送内容打分,深夜只推“助眠电台”类内容;用户在工作时段收到行业早报;连续 7 天不点击的用户自动降频,改为每周摘要。上线后通知打开率提升 38%,卸载率下降 12%。
大模型异步任务完成通知:模型跑完了,喊你一声
阶段:异步任务编排 / 任务状态回调
解决的问题:文生图、视频生成、语音合成等大模型任务耗时数秒到数分钟,用户关掉页面去干别的事,结果出来却无人告知。通过后端任务回调触发 Web Push,结果生成后第一时间通知用户,无需用户一直盯着进度条。
具体案例:某 AI 绘画站让用户提交“生成 4 张插画”任务后切到后台,模型跑完服务端调用推送服务,用户手机弹出“你的画作已出炉”,点击直达下载页。单次任务流失率下降 25%。
AI 异常监控告警:模型当保安,告警靠推送
阶段:智能运维 / 实时告警
解决的问题:业务指标异常发现滞后,值班人员错过黄金响应期。AI 异常检测模型对时序指标流式打分,一旦偏离基线立即通过 Web Push 推送告警,PWA 方式让值班员无需安装 App 也能实时收到。
具体案例:某电商数据团队部署 AI 告警:模型监测到支付接口成功率跌破阈值,5 秒内推送“支付链路异常,成功率 92%,请立即查看”,值班员点通知直达监控大盘,平均故障恢复时间缩短 40%。
Python 演示
# -*- coding: utf-8 -*-"""Simulate the Web Push pipeline: permission, subscribe, encrypt, deliver, unsubscribe.""" class PushClient: def __init__(self, user_id): self.user_id = user_id self.subscription = None # 已登记的收信地址 self.permission = 'default' # 权限三态: granted/denied/default def ask_permission(self, yes=True): if yes: self.permission = 'granted' else: self.permission = 'denied' return self.permission def subscribe(self, endpoint, public_key, auth_secret): if self.permission != 'granted': raise RuntimeError('No permission, cannot subscribe') self.subscription = { 'endpoint': endpoint, 'p256dh': public_key, 'auth': auth_secret, } return self.subscription def unsubscribe(self): self.subscription = None class PushService: """Forward node between the app server and the browser.""" def __init__(self): self.registry = {} def save_subscription(self, user_id, sub): self.registry[user_id] = sub def send(self, user_id, message, server_key): sub = self.registry.get(user_id) if sub is None: raise LookupError('Subscription missing, no receiver') ciphertext = encrypt(message, sub, server_key) return deliver(ciphertext, sub, server_key) def _seed(sub, server_key): raw = server_key + sub['p256dh'] + sub['auth'] return sum(ord(c) for c in raw) & 0xFF def encrypt(message, sub, server_key): seed = _seed(sub, server_key) # 密钥协商得到共享密钥 return ''.join(chr(ord(c) ^ seed) for c in message) def deliver(ciphertext, sub, server_key): seed = _seed(sub, server_key) plain = ''.join(chr(ord(c) ^ seed) for c in ciphertext) return 'delivered: %s (cipher length: %d)' % (plain, len(ciphertext)) def main(): server_key = 'vapid-server-private-key' alice = PushClient('alice') print('1) request permission ->', alice.ask_permission(True)) sub = alice.subscribe( 'https://push.example.com/endpoint-alice-001', 'alice-p256dh-public-key', 'alice-auth-secret', ) svc = PushService() svc.save_subscription('alice', sub) print('2) subscribed ->', sub['endpoint']) print('3) server delivers:', svc.send('alice', 'Hello Web Push!', server_key)) alice.unsubscribe() svc.registry.pop('alice', None) try: svc.send('alice', 'Nobody home', server_key) except LookupError as exc: print('4) after unsubscribe ->', exc) if __name__ == '__main__': main()
if self.permission != 'granted':权限是订阅的前置条件,就像先拿到开门许可才能登记地址;权限不足直接抛错。self.subscription = {记录订阅三件套:endpoint 是收信地址,p256dh 是加密公钥,auth 是认证密文。svc.save_subscription('alice', sub)服务端持久化订阅,把新地址抄进通讯录,否则推送时找不到人。sub = self.registry.get(user_id)推送前先查订阅表,查无此号直接退信。raise LookupError('Subscription missing, no receiver')没有订阅还硬发消息,就像往空屋寄信,白费力气。return sum(ord(c) for c in raw) & 0xFF模拟从双方密钥派生共享会话密钥(真实实现用 ECDH + HKDF)。return ''.join(chr(ord(c) ^ seed) for c in message)模拟对称加密:用共享密钥逐字符异或生成密文。plain = ''.join(chr(ord(c) ^ seed) for c in ciphertext)模拟接收端用同一密钥解密还原消息。svc.registry.pop('alice', None)服务端同步删除订阅记录,防止继续向废弃地址投递。
常见误区
误区Push 通知和 Web Notification 不都是浏览器弹通知吗?我以为它们是同一个东西,学了 Notification API 就等于会推送了。
正解两者是完全不同的两个 API:Push 是“投递通道”,负责把服务器消息送到浏览器后台;Notification 是“展示层”,负责把内容弹给用户,中间还得靠 Service Worker 衔接。用比喻说,Push 是邮递员,Notification 是信纸——邮递员不拿信纸就无货可送,信纸不靠邮递员就不会自己跑腿,两者缺一不可。
为何常见:从用户视角看,“收到推送”和“看到通知”发生在同一个瞬间,两个名字又都带“通知”字样,很容易误以为是一个 API。
误区用户点了允许,我就等于拿到了一张无限次数的推送饭票,想发多少发多少,什么时候发都行。
正解订阅只是“地址登记”,不代表用户可以无限承受打扰。工程上还要考虑内容频率、用户活跃度、点击率等;用户长期不点击或主动关闭通知时,应主动降频甚至停止。而且推送服务有配额与节流策略,滥用会导致域名被限制,就像邮局会把乱寄广告的人列入黑名单。
为何常见:把“权限”理解成“所有权”,忽略了尊重用户注意力的产品伦理——用户允许你寄信,不等于允许你寄垃圾。
误区用户取消订阅?我在浏览器里调一下 subscription.unsubscribe() 就完事了,服务器不用管。
正解取消订阅是“三端联动”:浏览器端取消 + 应用服务器删除记录 + 推送服务侧识别失效端点。如果只在前端取消,服务器仍会向已失效的 endpoint 发消息,收到 404/410 不仅浪费资源,还会影响推送服务的信誉评分,相当于搬家后旧地址还在杂志社的通讯录里。
为何常见:只盯着自己写的页面代码,忽视了订阅信息早已同步到服务器这条“邮路”——本地退订只是撕掉了自己手里的那份契约。
误区消息反正要过浏览器,推送服务又是官方提供的,直接明文发送没啥问题吧?
正解推送服务本质上是一个中间代理,理论上能看到转发的所有内容。Web Push 标准强制使用 VAPID 身份认证 + ECDH 密钥协商 + AES-128-GCM 加密,正是为了让中间节点“能送信、不能读信”。安全设计应遵循“永远假设中间链路不可信”,就像即使邮局是国家的,机密信件你也会上锁。
为何常见:看到“官方服务”字样就放下戒备,忽略了“官方能送信”与“官方能读信”是两件完全不同的事。
面试题
Q1. 「推送」和「通知」在 PWA 中的关系是什么?
- A.两者是完全相同的一件事
- B.推送负责投递通知负责展示← 正确答案
- C.通知负责投递推送负责展示
- D.Push 与 Notification 互不相关
答案:B。Push 管“送到”,Notification 管“展示”:前者是服务器经过推送服务投递到浏览器后台的通道,后者是浏览器本地弹出通知的 API,二者由 Service Worker 衔接。选 A 混淆了传输与展示,选 C 把分工颠倒,选 D 则忽略了二者的协作关系。
Q2. 推送消息到达浏览器时由哪个对象接收?
- A.页面里的业务 JavaScript
- B.浏览器后台 Service Worker← 正确答案
- C.浏览器插件或外部扩展程序
- D.推送服务直接展示通知给用户
答案:B。推送到达时页面可能根本没打开,浏览器会唤醒注册过的 Service Worker 来处理 push 事件;页面 JS 无法在后台接收。插件并非标准接收者,推送服务只负责运输、不负责展示。
Q3. PushSubscription 中哪个字段相当于收信地址?
- A.p256dh 公钥字段
- B.auth 认证信息字段
- C.endpoint 投递端点地址← 正确答案
- D.expirationTime 过期时间
答案:C。endpoint 是浏览器为订阅生成的专属 URL,服务器向它 POST 消息即可投递,正是“收信地址”。p256dh 是加密用公钥,auth 是派生密钥用的认证密文,expirationTime 记录过期时间,都不是地址本身。
Q4. VAPID 协议在 Web Push 中的作用是什么?
- A.对推送消息内容做端到端加密保护
- B.验证应用服务器的合法身份与来源← 正确答案
- C.压缩消息体以降低网络传输开销
- D.将消息从 HTTP 升级成 WebSocket
答案:B。VAPID(Voluntary Application Server Identification)让应用服务器用私钥对 JWT 签名,推送服务验证后确认是真实的应用方,防止冒名推送。它不负责加密内容、不压缩、不改变传输协议——载荷加密靠的是 ECDH + AES-GCM。
Q5. 用户取消订阅后正确的清理动作是哪些?
- A.浏览器端调用 subscription.unsubscribe← 正确答案
- B.从应用服务器删除该用户订阅记录← 正确答案
- C.删除本机浏览器缓存与全部历史记录
- D.因订阅已取消无需做任何额外操作
答案:AB。取消订阅是“三端联动”:浏览器 unsubscribe 释放本地订阅,服务器删除记录停止投递;推送服务侧的旧 endpoint 会在投递时报 404/410,由服务器收到后清除。C 是过度操作,D 会留下脏数据导致空投递。
Q6. Notification.permission 可能存在哪三种取值?
- A.granted、denied 与 default 三种状态← 正确答案
- B.allow、block 与 pending 三种状态
- C.yes、no 与 maybe 三种暂存状态
- D.subscribed 与 unsubscribed 两种状态
答案:A。Notification API 规定的权限三态是 granted / denied / default,其中 default 表示尚未询问、等同未授权。allow/block 是浏览器设置面板的显示词,yes/no/maybe 与 subscribed/unsubscribed 都是对权限模型的错误记忆。
Q7. 用户点击通知后触发哪个事件处理页面跳转?
- A.push 事件负责接收服务器推送
- B.notificationclose 事件负责关闭通知
- C.notificationclick 事件代表用户点击行为← 正确答案
- D.message 事件负责处理消息队列任务
答案:C。用户点击通知会触发 notificationclick 事件,回调中拿到通知对象与 data/action 后聚焦窗口或打开新页面。push 事件是消息到达时触发,notificationclose 是用户划掉通知时触发,message 是 postMessage 通道的事件,都不负责点击后的跳转。
Q8. Web Push 标准下消息加密采用何种算法组合?
- A.使用 RSA 算法对消息进行签名保护
- B.采用 ECDH 协商并经 AES-GCM 加密← 正确答案
- C.消息以 Base64 编码后明文传输即可
- D.使用 MD5 散列后混入随机数来传输
答案:B。标准 Web Push 加密链路是 ECDH 做密钥协商 + AES-128-GCM 对称加密消息载荷;VAPID 负责身份签名而非内容加密。RSA 签名或 MD5 散列都不是 Web Push 载荷加密的标准做法,Base64 更是只编码不保密。
Q9. 哪些信号代表服务器应判定订阅已失效?
- A.用户在浏览器端取消订阅并完成了同步← 正确答案
- B.推送服务返回 404 表示端点已不存在← 正确答案
- C.推送服务返回 410 表示端点已废弃← 正确答案
- D.服务器推送后收到 201 创建成功响应码
答案:ABC。订阅失效的信号包括:用户本地退订并同步、推送服务返回 404(端点不存在)、410(端点已废弃),服务器收到后应清理记录。201 表示成功投递,恰恰说明订阅仍然有效。
Q10. 关于 Web Push 投递机制,下列说法哪些正确?
- A.推送服务尽力而为且可离线暂存消息← 正确答案
- B.消息载荷存在约 4KB 的大小上限← 正确答案
- C.页面必须保持打开状态才能接信
- D.推送等同于双向实时常驻长连接
答案:AB。Web Push 是“尽力而为”的异步投递:设备离线时推送服务可在 TTL 内暂存消息,载荷上限约 4KB,且不需要页面保持打开——Service Worker 在后台接收。它也不是常驻双向连接,浏览器会合并抑制通知,所以不能与 WebSocket 画等号。