为用户订阅推送通知

Matt Gaunt

如需发送推送消息,您必须先征得用户许可,然后让用户的设备订阅推送服务。这需要使用 JavaScript API 获取 PushSubscription 对象,然后将其发送到您的服务器。

JavaScript API 可以直接管理此流程。本指南介绍了整个流程,包括检测功能、请求权限和管理订阅流程。

功能检测

首先,检查浏览器是否支持推送消息。您可以通过以下两种检查来确认是否支持推送:

  • 检查 navigator 对象上是否存在 serviceWorker
  • 检查 window 对象上是否存在 PushManager
if (!('serviceWorker' in navigator)) {
  // Service Worker isn't supported on this browser, disable or hide UI.
  return;
}

if (!('PushManager' in window)) {
  // Push isn't supported on this browser, disable or hide UI.
  return;
}

虽然浏览器对 Service Worker 和推送消息的支持在不断增加,但请务必检测这两项功能,并逐步增强您的应用。

注册 Service Worker

在检测到功能后,您会知道服务工作线程和推送消息是否受支持。接下来,注册您的 Service Worker。

注册服务工作器时,您需要告知浏览器服务工作器文件的位置。该文件是一个 JavaScript 文件,但浏览器会授予其对 Service Worker API(包括推送消息传递)的访问权限。具体来说,浏览器会在服务工作器环境中运行该文件。

如需注册 Service Worker,请调用 navigator.serviceWorker.register() 并传递文件路径。例如:

function registerServiceWorker() {
  return navigator.serviceWorker
    .register('/service-worker.js')
    .then(function (registration) {
      console.log('Service worker successfully registered.');
      return registration;
    })
    .catch(function (err) {
      console.error('Unable to register service worker.', err);
    });
}

此函数用于告知浏览器您的 service worker 文件的位置。在此示例中,服务工作线程文件位于 /service-worker.js。调用 register() 后,浏览器会执行以下步骤:

  1. 下载服务工作线程文件。

  2. 运行 JavaScript。

  3. 如果文件运行正常且没有错误,则 register() 返回的 promise 会解析。如果发生错误,promise 会被拒绝。

注意:如果 register() 拒绝,请在 Chrome 开发者工具中检查 JavaScript 是否有拼写错误或错误。

register() 解析时,它会返回 ServiceWorkerRegistration。您可以使用此注册信息来访问 PushManager API

PushManager API 浏览器兼容性

Browser Support

  • Chrome: 42.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 16.

Source

请求权限

注册服务工作线程并获得权限后,请征得用户同意,以便发送推送消息。

获取权限的 API 非常简单。不过,该 API 最近从接受回调更改为返回 Promise。由于您无法确定浏览器实现了哪个 API 版本,因此必须同时实现并处理这两个版本。

function askPermission() {
  return new Promise(function (resolve, reject) {
    const permissionResult = Notification.requestPermission(function (result) {
      resolve(result);
    });

    if (permissionResult) {
      permissionResult.then(resolve, reject);
    }
  }).then(function (permissionResult) {
    if (permissionResult !== 'granted') {
      throw new Error("We weren't granted permission.");
    }
  });
}

在上述代码中,对 Notification.requestPermission() 的调用会向用户显示一个提示:

在桌面版和移动版 Chrome 中显示的权限提示。

用户通过选择允许禁止或关闭权限提示与权限提示互动后,您会收到一个字符串形式的结果:'granted''default''denied'

在示例代码中,如果已授予权限,askPermission() 返回的 promise 会解析;否则,它会抛出错误并拒绝 promise。

处理用户点击屏蔽按钮的极端情况。如果发生这种情况,您的 Web 应用将无法再次向用户请求权限。用户必须在设置面板中更改应用的权限状态,才能手动解除对应用的屏蔽。请仔细考虑何时以及如何请求权限,因为如果用户点击阻止,则很难撤消该决定。

如果用户了解应用请求权限的原因,他们大多会授予权限。

本文档稍后将讨论一些热门网站如何请求权限。

使用 PushManager 订阅用户

注册服务工作线程并获得许可后,您可以通过调用 registration.pushManager.subscribe() 来订阅用户。

function subscribeUserToPush() {
  return navigator.serviceWorker
    .register('/service-worker.js')
    .then(function (registration) {
      const subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
        ),
      };

      return registration.pushManager.subscribe(subscribeOptions);
    })
    .then(function (pushSubscription) {
      console.log(
        'Received PushSubscription: ',
        JSON.stringify(pushSubscription),
      );
      return pushSubscription;
    });
}

调用 subscribe() 方法时,您需要传递一个包含必需参数和可选参数的 options 对象。

本部分介绍了您可以传递的选项。

userVisibleOnly 选项

当推送消息首次添加到浏览器时,开发者不确定是否可以在不显示通知的情况下发送推送消息。这通常称为静默推送,因为用户不知道后台发生了事件。

问题在于,开发者可以在用户不知情的情况下持续跟踪用户的位置信息。

为了避免这种情况并让规范作者考虑如何最好地支持此功能,我们添加了 userVisibleOnly 选项。传递值 true 是与浏览器达成的一项象征性协议,即 Web 应用每次收到推送消息时都会显示通知(即没有静默推送)。

必须传递值 true。如果您未添加 userVisibleOnly 键或传递 false,则会收到以下错误:

Chrome currently only supports the Push API for subscriptions that will result
in user-visible messages. You can indicate this by calling
`pushManager.subscribe({userVisibleOnly: true})` instead. See
[https://goo.gl/yqv4Q4](https://goo.gl/yqv4Q4) for more details.

Chrome 仅针对会生成用户可见消息的订阅支持 Push API。您可以通过调用 pushManager.subscribe({userVisibleOnly: true}) 来指明这一点。如需了解详情,请访问 https://goo.gl/yqv4Q4

看来,Chrome 中不会实现全面静默推送。相反,规范作者正在探索一种预算 API,该 API 可让 Web 应用根据 Web 应用使用情况发送一定数量的静默推送消息。

applicationServerKey 选项

本文档之前曾提及应用服务器密钥。推送服务使用应用服务器密钥来识别订阅了用户的应用,并确保向用户发送消息的是同一应用。

应用服务器密钥是您的应用特有的公钥和私钥对。请勿将应用的私钥告知他人,但可以随意分享公钥。

传递给 subscribe() 调用的 applicationServerKey 选项是应用的公钥。浏览器会在订阅用户时将此密钥传递给推送服务,从而使推送服务能够将应用的公钥与用户的 PushSubscription 相关联。

下图说明了这些步骤。

  1. 在浏览器中加载 Web 应用,然后调用 subscribe() 并传递您的公共应用服务器密钥。
  2. 然后,浏览器向推送服务发出网络请求,推送服务会生成一个端点,将此端点与应用的公钥相关联,并将该端点返回给浏览器。
  3. 浏览器将此端点添加到 subscribe() promise 返回的 PushSubscription 中。

图表:说明了如何在 `subscribe()` 方法中使用公共应用服务器密钥。

发送推送消息时,请创建一个包含使用应用服务器的私钥签名的信息的 Authorization 标头。当推送服务收到发送推送消息的请求时,它会通过查找与接收请求的端点关联的公钥来验证此带签名的 Authorization 标头。如果签名有效,推送服务会知道请求来自具有匹配私钥的应用服务器。这是一项安全措施,可防止他人向您应用的用户发送消息。

图表:说明了在发送消息时如何使用私有应用服务器密钥。

从技术上讲,applicationServerKey 是可选的。不过,Chrome 上最简单的实现需要它,其他浏览器未来可能也需要它。在 Firefox 上,此功能是可选的。

VAPID 规范定义了应用服务器密钥。当您看到对应用服务器密钥或 VAPID 密钥的引用时,请记住它们是相同的。

创建应用服务器密钥

您可以访问 web-push-codelab.glitch.me 或使用 web-push 命令行来生成公钥和私钥,如下所示:

    $ npm install -g web-push
    $ web-push generate-vapid-keys

只需为应用创建一次这些密钥,并确保私钥的私密性。

权限和 subscribe()

调用 subscribe() 会产生一个副作用。如果您的 Web 应用在您调用 subscribe() 时没有显示通知的权限,浏览器会为您请求权限。如果您的界面采用此流程,则此方法非常有用,但如果您想更好地控制界面(大多数开发者都希望如此),请使用本文档前面讨论的 Notification.requestPermission() API。

PushSubscription 概览

您调用 subscribe(),传递选项,并接收一个解析为 PushSubscription 的 promise。例如:

function subscribeUserToPush() {
  return navigator.serviceWorker
    .register('/service-worker.js')
    .then(function (registration) {
      const subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
        ),
      };

      return registration.pushManager.subscribe(subscribeOptions);
    })
    .then(function (pushSubscription) {
      console.log(
        'Received PushSubscription: ',
        JSON.stringify(pushSubscription),
      );
      return pushSubscription;
    });
}

PushSubscription 对象包含向相应用户发送推送消息所需的所有信息。如果您使用 JSON.stringify() 打印内容,则会看到以下内容:

    {
      "endpoint": "https://some.pushservice.com/something-unique",
      "keys": {
        "p256dh":
    "BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=",
        "auth":"FPssNDTKnInHVndSTdbKFw=="
      }
    }

endpoint 是推送服务的网址。如需触发推送消息,请向此网址发出 POST 请求。

keys 对象包含用于加密通过推送消息发送的消息数据的值。(本文档稍后将讨论消息加密。)

向服务器发送订阅

获得推送订阅后,将其发送到您的服务器。您可以自行决定如何发送,但建议使用 JSON.stringify() 从订阅对象中提取所有必要的数据。或者,您也可以手动组装相同的结果,例如:

const subscriptionObject = {
  endpoint: pushSubscription.endpoint,
  keys: {
    p256dh: pushSubscription.getKeys('p256dh'),
    auth: pushSubscription.getKeys('auth'),
  },
};

// The above is the same output as:

const subscriptionObjectToo = JSON.stringify(pushSubscription);

如需从网页发送订阅,请使用以下代码:

function sendSubscriptionToBackEnd(subscription) {
  return fetch('/api/save-subscription/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(subscription),
  })
    .then(function (response) {
      if (!response.ok) {
        throw new Error('Bad status code from server.');
      }

      return response.json();
    })
    .then(function (responseData) {
      if (!(responseData.data && responseData.data.success)) {
        throw new Error('Bad response from server.');
      }
    });
}

Node.js 服务器会收到此请求,并将数据保存到数据库以供日后使用。

app.post('/api/save-subscription/', function (req, res) {
  if (!isValidSaveRequest(req, res)) {
    return;
  }

  return saveSubscriptionToDatabase(req.body)
    .then(function (subscriptionId) {
      res.setHeader('Content-Type', 'application/json');
      res.send(JSON.stringify({data: {success: true}}));
    })
    .catch(function (err) {
      res.status(500);
      res.setHeader('Content-Type', 'application/json');
      res.send(
        JSON.stringify({
          error: {
            id: 'unable-to-save-subscription',
            message:
              'The subscription was received but we were unable to save it to our database.',
          },
        }),
      );
    });
});

有了服务器上的 PushSubscription 详细信息,您就可以随时向用户发送消息。

定期重新订阅,以免订阅过期

订阅推送通知时,您通常会收到 nullPushSubscription.expirationTime。从理论上讲,这意味着订阅永不过期。(相比之下,DOMHighResTimeStamp 表示确切的到期时间。)不过,在实际应用中,浏览器通常会允许订阅过期。例如,如果长时间未收到任何推送通知,或者浏览器检测到用户未使用具有推送通知权限的应用,则可能会发生这种情况。防止这种情况的一种模式是在每次收到通知时重新订阅用户,如以下代码段所示。这要求您发送通知的频率足够高,以防止浏览器自动使订阅过期。您应仔细权衡正当通知需求的利弊,避免仅为防止订阅过期而向用户发送非自愿的垃圾信息。最终,您不应试图规避浏览器为保护用户免受早已忘记的通知订阅而做出的努力。

/* In the Service Worker. */

self.addEventListener('push', function(event) {
  console.log('Received a push message', event);

  // Display notification or handle data
  // Example: show a notification
  const title = 'New Notification';
  const body = 'You have new updates!';
  const icon = '/images/icon.png';
  const tag = 'simple-push-demo-notification-tag';

  event.waitUntil(
    self.registration.showNotification(title, {
      body: body,
      icon: icon,
      tag: tag
    })
  );

  // Attempt to resubscribe after receiving a notification
  event.waitUntil(resubscribeToPush());
});

function resubscribeToPush() {
  return self.registration.pushManager.getSubscription()
    .then(function(subscription) {
      if (subscription) {
        return subscription.unsubscribe();
      }
    })
    .then(function() {
      return self.registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_VAPID_KEY_HERE')
      });
    })
    .then(function(subscription) {
      console.log('Resubscribed to push notifications:', subscription);
      // Optionally, send new subscription details to your server
    })
    .catch(function(error) {
      console.error('Failed to resubscribe:', error);
    });
}

常见问题解答

以下是一些常见问题:

您可以更改浏览器使用的推送服务吗?

否,浏览器会选择推送服务。正如本文档在讨论 subscribe() 调用时所说,浏览器会向推送服务发出网络请求,以检索构成 PushSubscription 的详细信息。

不同的推送服务是否使用不同的 API?

所有推送服务都使用相同的 API。

这种通用 API 称为 Web 推送协议,用于描述应用为触发推送消息而发出的网络请求。

如果您在桌面设备上为用户订阅了内容,那么该用户是否也会在手机上订阅相应内容?

不能。用户必须在想要接收消息的每个浏览器上注册推送消息服务。这还需要用户在每部设备上授予权限。

后续步骤

Codelab