前置硬性条件:站点必须部署 HTTPS,http 协议环境无法启用 WebPush,本地 localhost 调试不受该限制。


整套网页推送一共涉及五个部分:前端页面、Service‑Worker、VAPID密钥、PHP服务端、厂商推送网关。

  1. 用户访问HTTPS网站,浏览器申请通知权限,生成订阅对象 Subscription。
  2. 前端把订阅信息 endpointauthp256dh 提交给后端,持久化存入数据库。
  3. PHP后端使用VAPID密钥加密推送内容,通过POST请求发送到浏览器对应的厂商推送网关:
    • iPhone Safari PWA:转发到 Apple APNs 苹果推送服务
    • 安卓端Chrome:转发到 Google FCM 谷歌推送服务
  4. 厂商网关完成消息中转,下发到用户手机设备。
  5. 设备接收消息分两种场景:
    • 锁屏、应用后台挂起:由系统直接弹出通知,不会执行 JS 代码。
    • 网页处于前台打开:触发 Service‑Worker 的 push 事件,可以执行自定义 JS 逻辑,比如播放提示音。

重要逻辑:服务端不会直接访问用户手机,所有消息都经过苹果、谷歌的服务中转。

一个域名对应一套独立密钥,禁止多个站点共用同一套密钥。

通过composer安装web‑push依赖,执行命令生成密钥对:

composer require minishlink/web-push
./vendor/bin/web-push generate-vapid-keys

执行后输出公私钥:

Public Key: BLabxxxx
Private Key: xxxxx
  • 公钥:交给前端JS使用
  • 私钥:保存在PHP后端,绝对不能对外泄露

iPhone 将网页添加到桌面PWA,强依赖这个配置文件。通知栏上 from XXX 的来源文字,读取的就是文件里的 name / short_name

注意:修改名称之后,iPhone 需要删掉旧的桌面PWA图标,重新添加到主屏幕,并且重新授权订阅通知,改动才会生效。

manifest.json 示例:

{
    "name": "消息助手",
    "short_name": "消息助手",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#ffffff",
    "theme_color": "#2563eb",
    "icons": [
        {
            "src": "/icon-192.png",
            "sizes": "192x192",
            "type": "image/png"
        }
    ]
}

在网页 HTML 的 head 标签引入配置:

<link rel="manifest" href="/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="消息助手">

页面提供按钮,用户点击后完成 ServiceWorker 注册、通知权限申请、订阅,再将订阅信息提交后端保存。

<!DOCTYPE html>
<html lang="zh‑CN">
<head>
<meta charset="UTF‑8">
<link rel="manifest" href="/manifest.json">
<meta name="apple‑mobile‑web‑app‑capable" content="yes">
<meta name="apple‑mobile‑web‑app‑title" content="消息助手">
</head>
<body>
<button id="subBtn">开启消息通知</button>
<script>
// 替换为自己的VAPID公钥
const VAPID_PUBLIC_KEY = "你的VAPID公钥";

// base64格式转Uint8Array工具函数
function urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
    const rawData = window.atob(base64);
    return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}

async function subscribePush() {
    if(!('serviceWorker' in navigator) || !('PushManager' in window)){
        alert("你的浏览器不支持WebPush");
        return;
    }
    // 注册service‑worker
    const swReg = await navigator.serviceWorker.register('/service-worker.js');
    await swReg.ready;
    // 创建推送订阅
    const subscription = await swReg.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
    });
    // 将订阅数据POST提交后端存储
    await fetch('/save-sub.php',{
        method:"POST",
        headers:{"Content‑Type":"application/json"},
        body:JSON.stringify(subscription)
    });
    alert("订阅成功!可以接收推送");
}

document.querySelector("#subBtn").addEventListener("click",subscribePush);
</script>
</body>
</html>

文件必须放在网站根目录,否则 ServiceWorker 作用域失效,推送无法正常触发。

// 监听推送消息
self.addEventListener('push', (event) => {
    let payload = {};
    if(event.data){
        payload = event.data.json();
    }
    const title = payload.title || "新消息通知";
    const options = {
        body: payload.body || "您收到一条新消息",
        icon: "/icon-192.png"
    };
    event.waitUntil(self.registration.showNotification(title, options));
});

// 点击通知跳转页面
self.addEventListener('notificationclick', (event) => {
    event.notification.close();
    event.waitUntil(clients.openWindow('/'));
});

接收前端上传的 subscription 数据,实际业务中把 endpointauthp256dh 存入数据库,示例仅做输出演示。

<?php
$raw = file_get_contents('php://input');
$subscription = json_decode($raw,true);

// 业务逻辑:把subscription里面 endpoint、keys[auth]、keys[p256dh]写入数据库

echo json_encode([
    "code"=>0,
    "msg"=>"保存订阅成功",
    "data"=>$subscription
]);

依赖 minishlink/web‑push 库,读取数据库中的订阅信息,构建消息并发起推送。

<?php
require __DIR__.'/vendor/autoload.php';
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;

$vapid = [
    'publicKey'  => "你的VAPID公钥",
    'privateKey' => "你的VAPID私钥",
];
$auth = [
    'VAPID' => $vapid
];

$webPush = new WebPush($auth);

// 从数据库查询获取的单条订阅记录
$subData = [
    "endpoint"=>"xxx",
    "keys"=>[
        "auth"=>"xxx",
        "p256dh"=>"xxx"
    ]
];
$subscription = Subscription::create($subData);

// 推送消息体
$payload = json_encode([
    "title" => "新消息通知",
    "body"  => "您收到一条新消息"
]);

$webPush->queueNotification($subscription,$payload);

// 批量执行推送,打印结果
foreach ($webPush->flush() as $report) {
    $endpoint = $report->getEndpoint();
    if ($report->isSuccess()) {
        echo "推送成功 {$endpoint} \n";
    } else {
        echo "推送失败: {$report->getReason()} \n";
    }
}
  1. 只有添加到主屏幕成为PWA应用,锁屏后台才能收到离线推送。仅仅打开Safari网页标签,锁屏后无法接收离线推送。
  2. 通知栏强制展示 from XXX,来源取自 manifest.json 的 name 字段,推送 payload 无法修改也不能隐藏该行文字。
  3. 不支持自定义通知铃声,sound 参数会被APNs直接丢弃,只能使用系统默认铃声。
  4. 如果手动上滑彻底关闭PWA进程,后台推送将无法送达。
  5. 修改manifest里应用名称后,要删除桌面图标,重新添加PWA并重新订阅通知权限。

WebPush 底层依赖谷歌FCM服务,国内网络无法访问FCM。

  1. 网页或者PWA处于前台运行状态:推送可以正常弹窗。
  2. 锁屏、切后台、浏览器被系统省电策略杀掉进程:无法收到离线推送,纯网页没有任何方案可以绕过该限制。
  3. 仅Chrome、Edge、Kiwi浏览器支持WebPush;UC、夸克、QQ浏览器均不兼容。
  4. 搭载GMS的海外安卓设备,整套代码直接运行,没有来源提示文字,系统层面可以自定义通知铃声。
  1. 用户群体以iPhone为主:直接使用这套PWA+WebPush方案。国内安卓做降级处理,用户打开网页读取未读消息。
  2. 如果要求国内安卓锁屏离线接收推送:纯网页/PWA实现不了,需要打包APK,使用uni‑app这类框架接入Uni‑Push2.0等厂商推送SDK。

遇到推送异常,逐项核对下面的点:

  1. 线上站点没有配置HTTPS;
  2. service‑worker.js 文件不在网站根目录;
  3. VAPID公私钥配置错误,密钥跨域名复用;
  4. iPhone没有把网页添加到主屏幕PWA;
  5. iPhone开启专注模式、勿扰模式或者静音;
  6. 国内国行安卓设备,无GMS谷歌服务,锁屏离线推送必然失效。

欢迎留言