你的App消息推送为什么石沉大海?看Service Worker源码我终于懂了

大多数开发者对Push通知的理解,停留在"调用API发送消息"的表面。但我们很少深入思考:为什么用户关闭浏览器后还能收到通知?这背后的通信机制到底是什么?为什么你的推送打开率这么低?

这篇文章,我们会从源码级别剖析Service Worker、Push API和Notification API的协作原理,以及大厂(字节、阿里云)推荐的实现思路。

一个让人困惑的现象

你是否遇到过这样的情况:

  • 调用了Notification.requestPermission(),用户也点了允许,但推送消息要么没收到,要么收到了没人点

  • 看似完整的推送流程,在真实生产环境中就是"失效"

  • 同样的代码,字节跳动员工的App推送打开率是你的3倍

这不是巧合。问题的根源在于:大多数开发者对Push通知的实现停留在"Copy-Paste代码"阶段,从未理解它在浏览器层面的真实工作机制。

Push通知的真实工作流:看不见的三层架构

让我先画出完整的消息流转过程:

go 复制代码
┌─────────────────────────────────────────────────────────────────┐
│ 用户点击"允许通知"的那一刻,发生了什么?                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│ [浏览器进程]                    [Service Worker进程]            │
│      │                              │                          │
│   1. 加载SW.js              1. 独立的后台进程                    │
│      │                      2. 持久化存活                        │
│   2. 建立Push订阅            3. 监听push/notification事件      │
│      │                              │                          │
│      └──→ 获取VAPID密钥 ────────────┘                          │
│            │                                                   │
│            ├─→ 生成subscription对象                           │
│            │   {                                              │
│            │     endpoint: "https://fcm.google.com/...",     │
│            │     keys: { p256dh, auth }                       │
│            │   }                                              │
│            │                                                   │
│            └─→ 发送到服务器保存                               │
│                (关键!没有这一步就无法推送)                   │
│                                                                │
│ [推送服务器](第三方:Google FCM、Apple APNs等)               │
│      │                                                        │
│      └─→ 加密消息 ──→ 推送给用户设备 ──→ 触发SW push事件      │
│                                                                │
└─────────────────────────────────────────────────────────────────┘

这个图揭示了一个90%的开发者忽视的问题

Push订阅对象必须存储在服务器。 如果没有这一步,再完美的前端代码也无法工作。这正是为什么很多人"按教程做了但还是收不到推送"。

第一层:Service Worker注册 --- 隐形的守护进程

想象Service Worker就像你手机后台运行的应用。即使你关闭浏览器,它仍然可以收信息。

go 复制代码
// ✅ 标准的注册方式(99%的教程都是这样写)
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => {
      console.log('Service Worker作用域:', registration.scope);
      // 重要:记住这个registration对象,后续的push订阅需要它
    })
    .catch(error => {
      console.error('注册失败:', error);
    });
}

但这里有个坑: 上面的代码只是注册,并不能保证SW已经完全启动。如果你立刻去调用pushManager.subscribe(),很可能会报错。

正确做法是这样的:

go 复制代码
// ✅ 确保Service Worker已准备就绪
navigator.serviceWorker.ready.then(registration => {
  console.log('SW已准备好,可以安全地进行下一步');
  // 在这里进行push订阅操作
});

关键词:**ready不是register**。很多人混淆这两个概念,导致竞态条件问题。

为什么Service Worker这么重要?

让我们看看浏览器对SW的内存管理机制:

go 复制代码
浏览器生命周期管理:

[Page Active]          [Page Closed]       [Browser Closed]
     │                      │                    │
     ├─ 页面SW活跃          ├─ SW仍在运行       ├─ SW持久化
     │  (优先级最高)        │  (中等优先级)     │  保存状态
     │                      │                    │
     └─ 可监听所有事件      └─ 只监听push/     └─ 系统通知
                              notification事件    触发SW

这就是为什么只有Service Worker能接收来自推送服务的消息 。普通的JavaScript(即使在<script>标签里)是收不到的------因为页面关闭后,普通脚本随之销毁,但SW会被浏览器保活。

第二层:权限请求和Push订阅 --- 建立信任链

现在我们进入真正的Push通知核心------这一步很多人做错了。

go 复制代码
// ❌ 新手常见的错误做法
asyncfunction setupPushNotifications() {
// 问题1:没有检查权限状态就直接请求
const permission = await Notification.requestPermission();

if (permission === 'granted') {
    // 问题2:没等Service Worker真正ready
    const registration = await navigator.serviceWorker.register('/sw.js');
    
    // 问题3:VAPID密钥硬编码在前端(严重的安全问题)
    const vapidKey = 'BJ_public_key_hardcoded_here...';
    
    // 开始订阅
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(vapidKey)
    });
  }
}

上面的代码有3个严重问题。让我们一个一个修正:

问题1:权限状态检查

浏览器的权限模型很复杂,你需要理解这些状态:

go 复制代码
// ✅ 正确的权限检查流程
asyncfunction checkNotificationPermission() {
// 1. 首先检查浏览器是否支持
if (!('Notification'inwindow)) {
    return { supported: false, reason: '浏览器不支持Notification API' };
  }

// 2. 检查当前权限状态(不会触发权限弹窗)
const currentPermission = Notification.permission;

switch (currentPermission) {
    case'granted':
      return { granted: true, canSubscribe: true };
    
    case'denied':
      // 用户已经明确拒绝,再调用requestPermission()也无效
      return { granted: false, reason: '用户已拒绝,需要手动在浏览器设置中修改' };
    
    case'default':
      // 用户还没有做过任何选择,现在可以请求
      return { granted: false, canRequest: true };
  }
}

// 只有在default状态下,才值得调用requestPermission()
asyncfunction ensureNotificationPermission() {
if (Notification.permission === 'granted') {
    returntrue;
  }

if (Notification.permission === 'denied') {
    console.warn('用户已拒绝通知权限,无法恢复');
    returnfalse;
  }

// 只在'default'状态下请求
const permission = await Notification.requestPermission();
return permission === 'granted';
}

为什么要这么细致? 因为在生产环境中(比如字节跳动的某个中后台系统),你需要统计:

  • 有多少用户明确拒绝了通知?

  • 有多少用户还在default状态?

  • 反复请求权限对转化率的影响?

这些数据直接影响产品决策。

问题2:Service Worker真正就绪的时机

关键代码:

go 复制代码
// ✅ 正确的时序控制
asyncfunction subscribeToPushNotifications() {
// 第一步:确保权限已授予
const hasPermission = await ensureNotificationPermission();
if (!hasPermission) return;

// 第二步:确保SW已完全加载和激活
const registration = await navigator.serviceWorker.ready;
// ⚠️ 注意:这里用的是.ready,不是.register()的返回值

// 第三步:获取VAPID公钥(从服务器)
const vapidPublicKey = await fetchVapidPublicKeyFromServer();

// 第四步:转换VAPID密钥格式
const convertedKey = urlBase64ToUint8Array(vapidPublicKey);

// 第五步:真正的订阅操作
try {
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,  // 必须为true,表示通知必须对用户可见
      applicationServerKey: convertedKey
    });
    
    console.log('订阅成功,subscription对象:', subscription);
    
    // 第六步:发送subscription到服务器(最关键!)
    await sendSubscriptionToServer(subscription);
    
  } catch (error) {
    console.error('订阅失败:', error);
    handleSubscriptionError(error);
  }
}

// ✅ VAPID密钥转换函数(必须理解这一步)
function urlBase64ToUint8Array(base64String) {
// 为什么需要这个转换?
// VAPID公钥从服务器以Base64编码字符串的形式传输
// 但Push API需要Uint8Array格式

const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
    .replace(/-/g, '+')           // URL安全Base64 → 标准Base64
    .replace(/_/g, '/');          // 

const rawData = window.atob(base64);  // 解码为二进制字符串
returnUint8Array.from(
    [...rawData].map(char => char.charCodeAt(0))  // 转为字节数组
  );
}

问题3:VAPID密钥的安全性

这是一个很多教程都做错的地方。让我们对比一下:

go 复制代码
// ❌ 危险做法:硬编码在前端
const VAPID_PUBLIC_KEY = 'BJ_sPzT..._你的整个公钥';  // 暴露在源码里!

// ✅ 正确做法:从服务器动态获取
asyncfunction fetchVapidPublicKeyFromServer() {
const response = await fetch('/api/push/vapid-public-key', {
    headers: {
      'Content-Type': 'application/json',
      // 如果需要验证用户身份
      'Authorization': `Bearer ${userToken}`
    }
  });

if (!response.ok) {
    thrownewError('获取VAPID密钥失败');
  }

const { publicKey } = await response.json();
return publicKey;
}

为什么? 虽然VAPID公钥本身并不是"秘密"(名字里就有"公"),但如果硬编码在前端,意味着:

  1. 如果你需要轮换密钥,必须重新部署前端代码

  2. 源码泄露会暴露你使用的推送服务商和密钥ID

  3. 无法针对不同应用/环境使用不同的密钥

第三层:Service Worker中的Push事件处理 --- 真正的魔法发生地

现在我们进入sw.js(Service Worker脚本)。这才是Push通知的核心。

go 复制代码
// ✅ sw.js: Service Worker脚本

// 关键事件1:监听push事件
self.addEventListener('push', event => {
console.log('收到push消息:', event);

// 从push事件中提取数据
const pushData = event.data ? event.data.json() : null;

if (!pushData) {
    console.warn('Push消息为空,使用默认通知');
    return;
  }

const { title, body, icon, badge, url, actions, tag } = pushData;

const notificationOptions = {
    body: body,
    icon: icon || '/default-icon.png',
    badge: badge || '/default-badge.png',
    tag: tag || 'general-notification',  // 相同tag的通知会替换
    data: { url },                        // 存储自定义数据,通知点击时可获取
    actions: actions || [                 // 可选的操作按钮
      { action: 'open', title: '打开' },
      { action: 'close', title: '关闭' }
    ],
    // 新增的React 19友好选项
    requireInteraction: false,  // true表示必须用户手动关闭,不会自动消失
    vibrate: [200, 100, 200],   // 振动模式
    timestamp: Date.now()        // 时间戳,某些浏览器会显示
  };

// ⚠️ 关键:event.waitUntil() 确保Service Worker不会在操作完成前被杀死
  event.waitUntil(
    self.registration.showNotification(title, notificationOptions)
      .then(() => {
        console.log('通知显示成功');
      })
      .catch(error => {
        console.error('显示通知失败:', error);
      })
  );
});

// 关键事件2:监听notificationclick事件
self.addEventListener('notificationclick', event => {
console.log('用户点击了通知:', event.notification.tag);

// 首先关闭通知
  event.notification.close();

// 获取通知中存储的URL
const targetUrl = event.notification.data?.url || '/';

// ⚠️ 关键:event.waitUntil() 确保操作完成
  event.waitUntil(
    // 1. 查找已经打开的该应用窗口
    clients.matchAll({ type: 'window' })
      .then(windowClients => {
        // 2. 检查是否已有对应URL的窗口打开
        for (const client of windowClients) {
          if (client.url === targetUrl && 'focus'in client) {
            return client.focus();  // 有的话就聚焦
          }
        }
        
        // 3. 如果没有,打开新窗口
        if (clients.openWindow) {
          return clients.openWindow(targetUrl);
        }
      })
  );
});

// 关键事件3:监听notificationclose事件(可选但有用)
self.addEventListener('notificationclose', event => {
console.log('用户关闭了通知:', event.notification.tag);

// 在这里可以追踪用户没有点击的通知
// 对于分析推送效果很有帮助
  fetch('/api/analytics/notification-closed', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      notificationTag: event.notification.tag,
      timestamp: newDate().toISOString()
    })
  });
});

理解event.waitUntil()的重要性:

go 复制代码
没有waitUntil()时的时序:
┌──────────────────────────────────────┐
│ SW收到push事件                       │
├──────────────────────────────────────┤
│ │ 开始显示通知                       │
│ │ (异步操作)                         │
│ │                                    │
│ └─ SW可能被浏览器直接杀死! ❌      │
│    (浏览器认为SW已完成工作)         │
│    结果:通知显示失败                │
└──────────────────────────────────────┘

使用waitUntil()时的时序:
┌──────────────────────────────────────┐
│ SW收到push事件                       │
├──────────────────────────────────────┤
│ │ 开始显示通知                       │
│ │ (异步操作,被waitUntil包裹)       │
│ │                                    │
│ │ SW继续运行,直到Promise resolve  │
│ │ 通知成功显示 ✅                  │
│ │                                    │
│ └─ 然后SW才能被杀死                 │
└──────────────────────────────────────┘

第四层:服务器端的推送实现 --- 把消息真正送出去

前端做得再完美,如果服务器无法正确发送,一切都是白搭。这是大多数开发者的薄弱环节。

go 复制代码
// ✅ Node.js服务器端实现(使用web-push库)

const webPush = require('web-push');

// 1️⃣ 首先,设置VAPID密钥对
const vapidKeys = {
publicKey: 'BJ_sPzTWl..._公钥',      // 与前端使用的相同
privateKey: 'abc123..._私钥'          // ⚠️ 极其重要,不要泄露!
};

webPush.setVapidDetails(
'mailto:your-email@example.com',      // 可以是任何联系方式
  vapidKeys.publicKey,
  vapidKeys.privateKey
);

// 2️⃣ 从数据库获取用户的push subscription对象
asyncfunction pushNotificationToUser(userId, notification) {
try {
    // 从DB查询这个用户的订阅信息
    const subscription = await getSubscriptionFromDB(userId);
    
    if (!subscription) {
      console.log(`用户 ${userId} 没有订阅push通知`);
      return { success: false, reason: '未订阅' };
    }

    // 3️⃣ 发送通知(最关键的一步)
    const payload = JSON.stringify({
      title: notification.title,
      body: notification.body,
      icon: notification.icon,
      badge: notification.badge,
      url: notification.url,
      tag: notification.tag || `notification_${Date.now()}`
    });

    await webPush.sendNotification(subscription, payload);
    
    console.log(`✅ 通知已发送给用户 ${userId}`);
    return { success: true };

  } catch (error) {
    // 4️⃣ 错误处理(这很关键!)
    handlePushError(userId, error);
  }
}

// 4️⃣ 错误处理的详细逻辑
asyncfunction handlePushError(userId, error) {
console.error(`用户 ${userId} 推送失败:`, error.statusCode, error.message);

// 根据错误类型采取不同的措施
if (error.statusCode === 410) {
    // 410 Gone - subscription已过期,需要删除
    console.log('Subscription已过期,从DB删除');
    await deleteSubscriptionFromDB(userId);
  } 
elseif (error.statusCode === 401) {
    // 401 Unauthorized - VAPID密钥配置错误
    console.error('❌ VAPID密钥配置有误!');
  }
elseif (error.statusCode === 429) {
    // 429 Too Many Requests - 被限流,需要退避
    console.warn('推送服务被限流,稍后重试');
    // 实现指数退避重试
  }
else {
    // 其他错误,需要根据业务逻辑处理
    console.error('未知错误:', error);
  }
}

// 5️⃣ 批量推送的优化方案(大厂常用)
asyncfunction batchPushNotifications(notificationData) {
const allSubscriptions = await getAllSubscriptionsFromDB();

// 使用Promise.allSettled而不是Promise.all
// 这样单个失败不会导致整个批次中断
const results = awaitPromise.allSettled(
    allSubscriptions.map(subscription =>
      webPush.sendNotification(subscription, JSON.stringify(notificationData))
    )
  );

// 统计结果
let successCount = 0;
let failureCount = 0;

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      successCount++;
    } else {
      failureCount++;
      handlePushError(allSubscriptions[index].userId, result.reason);
    }
  });

console.log(`批量推送完成: 成功${successCount}, 失败${failureCount}`);
}

// 6️⃣ Express API端点示例
const express = require('express');
const app = express();

app.post('/api/push/send', async (req, res) => {
const { userId, title, body, url } = req.body;

// 验证权限(确保只有授权的用户/系统可以发送)
if (!isAuthorized(req)) {
    return res.status(403).json({ error: 'Unauthorized' });
  }

const result = await pushNotificationToUser(userId, {
    title,
    body,
    url,
    icon: '/default-icon.png'
  });

  res.json(result);
});

实战案例:某大厂的推送效果很好,为什么?

我看过某个产品的推送实现,他们做对了什么:

1. 细致的subscription管理

go 复制代码
// ✅ 不仅存储subscription对象,还记录元数据
asyncfunction saveSubscriptionWithMetadata(userId, subscription) {
constdocument = {
    userId,
    subscription,
    // 这些数据很重要!
    subscribedAt: newDate(),
    lastActivityAt: newDate(),
    isActive: true,
    userAgent: navigator.userAgent,
    platform: detectPlatform(),
    language: navigator.language
  };

await db.collection('push_subscriptions').insertOne(document);
}

为什么?这样他们可以:

  • 按平台/语言发送定向推送

  • 追踪subscription的健康度

  • 自动清理僵尸subscription

2. 推送前的双重验证

go 复制代码
// ✅ 发送前检查subscription是否仍然有效
asyncfunction isSubscriptionStillValid(subscription) {
try {
    // 尝试发送一个哑元通知(仅用于验证)
    await webPush.sendNotification(
      subscription,
      JSON.stringify({ test: true })
    );
    returntrue;
  } catch (error) {
    // 如果报410,说明已失效
    return error.statusCode !== 410;
  }
}

3. 智能的推送时机

go 复制代码
// ✅ 根据用户时区和活跃时间段发送
async function smartPush(userId, notification) {
  const userProfile = await getUserProfile(userId);
  const optimalTime = calculateOptimalPushTime(userProfile);
  
  // 不是立刻发送,而是在用户最可能看到的时间发送
  schedulePush(userId, notification, optimalTime);
}

这就是为什么大厂的推送打开率高。不是因为推送技术更好,而是推送策略更聪明。

常见坑点总结

问题 症状 解决方案
Subscription未保存到服务器 推送后无反应 确保sendSubscriptionToServer()在前端调用了
VAPID密钥配置错误 返回401错误 检查服务器的webPush.setVapidDetails()调用
没有使用event.waitUntil() 通知显示不稳定 pushnotificationclick事件中都要用
混淆.register().ready 竞态条件,随机失败 务必使用navigator.serviceWorker.ready
VAPID密钥硬编码在前端 源码泄露风险 从服务器动态获取
权限弹窗过早出现 用户直接拒绝 在适当的用户交互时机请求(如购物车页面)
没有处理subscription过期 推送积累失败,浪费资源 监听410错误并删除旧subscription

性能和安全建议

性能方面

  1. 使用useCallbackuseMemo缓存重型操作(如果你在React中使用)

    go 复制代码
    const subscribeToPush = useCallback(async () => {
      // 订阅逻辑...
    }, []); // 空依赖数组表示只初始化一次
  2. 批量推送时使用Promise.allSettled()而不是Promise.all()

  • 防止单个失败导致整个批次中断

  • 允许部分成功

  • 实现subscription缓存

    • 不要每次都重新创建subscription

    • 检查pushManager.getSubscription()的返回值

    安全方面

    1. VAPID私钥必须只在服务器端存储
    • 环境变量中,不要提交到git

    • 定期轮换

  • 验证推送请求的来源

    go 复制代码
    // 在API端点中检查授权
    app.post('/api/push/send', authenticateUser, (req, res) => {
      // 只有认证用户才能发送
    });
  • 限制推送频率

    go 复制代码
    // 防止滥用
    if (tooManyPushesInShortTime(userId)) {
      return res.status(429).json({ error: 'Too many requests' });
    }

诊断工具:如何排查推送问题?

如果推送不工作,按这个顺序诊断:

go 复制代码
// 1. 检查浏览器支持
console.log('✓ Notification API:', 'Notification'inwindow);
console.log('✓ Service Worker:', 'serviceWorker'in navigator);
console.log('✓ Push Manager:', 'PushManager'inwindow);

// 2. 检查Service Worker状态
navigator.serviceWorker.ready.then(reg => {
console.log('✓ Service Worker已激活');

// 3. 检查是否已订阅
  reg.pushManager.getSubscription().then(sub => {
    if (sub) {
      console.log('✓ 已订阅,subscription对象:', sub);
      console.log('  Endpoint:', sub.endpoint);
    } else {
      console.warn('✗ 未订阅,需要调用subscribe()');
    }
  });
});

// 4. 检查权限状态
console.log('权限状态:', Notification.permission);

// 5. 在浏览器DevTools中模拟push事件
// Chrome DevTools → Application → Service Workers → 点击"Push"

对比:Web Push vs App Push vs In-App Notifications

为什么有时候该选Web Push,有时候不应该?

维度 Web Push Native App Push In-App通知
工作范围 浏览器关闭仍可收到 任何时候(最可靠) 仅App打开时
用户打开率 15-25% 40-60% 60-80%(但基础小)
实现难度 中等(需要SW) 简单(Native API) 简单(Just JS)
适合场景 重要消息、实时更新 高转化场景 增强用户体验
中文市场现状 逐渐普及(PC为主) 主流 常见

我的建议: 如果目标用户主要在PC或电脑上,Web Push是关键。如果主要移动端,还是Native App Push效果更好。

总结:你应该记住什么?

  1. Push通知的三个关键角色:
  • 前端(注册SW,请求权限,建立subscription)

  • Service Worker(监听push和notification事件)

  • 服务器(存储subscription,发送消息)

  • 最常见的失败原因:

    • Subscription没有保存到服务器(导致服务器无法发送)

    • 没有正确处理异步时序(竞态条件)

    • VAPID配置错误

  • 生产环境必须做好:

    • Subscription生命周期管理(及时删除过期的)

    • 错误处理和重试机制

    • 推送时机优化(不是越快越好,而是越合适越好)

  • 性能优化的核心:

    • 不要频繁请求权限

    • 批量推送使用allSettled

    • 缓存subscription对象

    延伸思考

    • 如果你要在电商产品中做Push,应该在什么时机请求权限?→ 答案:购物车页面或结算页面,此时用户有需求

    • 为什么有些推送你永远收不到?→ 因为你的subscription可能已失效,服务器没有及时清理

    • Web Push在国内推广缓慢的根本原因是什么?→ 不是技术问题,而是用户习惯问题(国内用户习惯了App Push)

    推荐资源

    • Push API - MDN Web Docs

    • Service Worker规范

    • web-push npm库

    • VAPID密钥生成工具(谷歌官方)

    后记

    Web Push通知看似简单的"发送一条消息",实际上涉及浏览器、Service Worker、推送服务、加密、权限管理等多个复杂层面。

    理解这些细节的开发者,能写出健壮、高效的推送系统。 而那些"Copy-Paste教程代码"的开发者,到了真实场景中往往一筹莫展。

    如果你正在开发一个需要推送功能的产品,希望这篇文章能帮你避免大多数人踩过的坑。

    喜欢这篇深度技术分析吗?

    👉 关注**《前端达人》**,获取更多React、前端架构、系统设计等高质量原创内容

    👉 点赞 + 分享给更多需要的人,让更多开发者理解Push通知的本质

    👉 在评论区分享:*你在实现Push通知时遇到过哪个坑?*我们一起讨论解决方案!

相关推荐
咖啡の猫1 小时前
Python中的变量与数据类型
开发语言·python
汤姆yu1 小时前
基于springboot的电子政务服务管理系统
开发语言·python
小光学长1 小时前
基于ssm的宠物交易系统的设计与实现850mb48h(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
java·前端·数据库
编程大师哥1 小时前
vxe-table 透视表分组汇总及排序基础配置
java
全栈师1 小时前
C#中控制权限的逻辑写法
开发语言·c#
8***84821 小时前
spring security 超详细使用教程(接入springboot、前后端分离)
java·spring boot·spring
9***J6281 小时前
Spring Boot项目集成Redisson 原始依赖与 Spring Boot Starter 的流程
java·spring boot·后端
S***q1922 小时前
Rust在系统工具中的内存安全给代码上了三道保险锁。但正是这种“编译期的严苛”,换来了运行时的安心。比如这段代码:
开发语言·后端·rust
M***Z2102 小时前
SQL 建表语句详解
java·数据库·sql