当今时代,应用需要与用户互动才能加强用户黏性并避免用户流失,虽然我们可以在用户离开后通过邮件等方式来发送一些有价值的信息,但这并不能完全引起用户的注意,且无法与应用进行互动。这正是推送通知所要解决的问题,它最大的特点是即使没有打开应用(或浏览器),用户依旧能够收到通知内容,并通过点击通知进入应用进行事务处理。这种类似原生应用的体验为加强互动体验并保证用户留存提供了可能,也必将成为颠覆 Web 的入口,本章我们将一起探讨它的基本使用。

# 基本流程

+-------+           +--------------+       +-------------+
|  UA   |           | Push Service |       | Application |
+-------+           +--------------+       |   Server    |
    |                      |               +-------------+
    |      Subscribe       |                      |
    |--------------------->|                      |
    |       Monitor        |                      |
    |<====================>|                      |
    |                      |                      |
    |          Distribute Push Resource           |
    |-------------------------------------------->|
    |                      |                      |
    :                      :                      :
    |                      |     Push Message     |
    |    Push Message      |<---------------------|
    |<---------------------|                      |
    |                      |                      |

上图摘自:tools.ietf.org/html/draft-…

如图所示,推送通知由三部分组成:

  • UA:客户端。
  • Push Service:一般由浏览器服务商提供,比如 chrome 和 firefox 自己的 Push Service。
  • Application Server:服务端,开发者自己提供。

其工作流程为:

Subscribe:浏览器通过询问(如下图)的方式让用户选择是否允许显示通知,如允许则向 Push Service 发起订阅请求,订阅成功后返回 PushSubscription 对象。

  • Monitor:订阅成功后,Push Service 将保持与客户端的联系,主要作用是将服务端推送的消息发送到客户端。
  • Distribute Push Resource:订阅成功后,客户端需要将 PushSubscription 对象中的验证信息发送给服务端,并在服务端进行保存。
  • Push Message:服务端推送的消息并不是直接发给客户端的,而是发给 Push Service,后者对消息进行校检后,再将消息推送给客户端。

以上便是推送通知的工作流程,由于使用过程中我们基本上不会对 Push Service 进行干预,因此接下来我们仅对客户端以及服务端的使用进行阐述说明。

# 订阅通知

# 客户端

上文我们讨论了,使用推送通知的第一步便是订阅,其中客户端主要代码如下:

<script>
  function urlB64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);
    for (let i = 0; i < rawData.length; ++i) {
      outputArray[i] = rawData.charCodeAt(i);
    }
    return outputArray;
  }
  function getApplicationServerKey() {
    return urlB64ToUint8Array(
      'BLW2Nfw3ylyUdwNqAreIPYbemxnxQ7ZTZSIJIHxrgw_xOiUP9enenF5JIHX8KXY8BZpzuGN_0mCehb2XEqms3hg'
    );
  }
  window.addEventListener('load', function() {
    if ('serviceWorker' in navigator && 'PushManager' in window) {
      navigator.serviceWorker.register('./sw.js').then(function(registration) {
        registration.pushManager.getSubscription().then(function(subscription) {
          if (subscription) {
            console.log('通知已注册....');
            return;
          }
          registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: getApplicationServerKey()
          }).then(function(subscription) {
            fetch('/subscribe', {
              headers: {
                'content-type': 'application/json'
              },
              method: 'POST',
              body: JSON.stringify(subscription)
            }).then(function(response) {
              return response.json();
            }).then(function() {
              console.log('通知注册成功……');
            }).catch(function() {
              subscription.unsubscribe();
              console.log('通知注册失败……');
            })
          });
        });
      });
    }
  });
</script>

上述代码中,我们首先判断当前环境下 Service WorkerPushManager 是否可用,如可用则按照以下步骤进行处理:

  • 注册 Service Worker
  • Service Worker 注册成功后,我们通过调用 registration.pushManager 对象中的 getSubscription 方法来检测用户是否已经订阅,如订阅直接返回,否则进行下一步。
  • 通过调用 registration.pushManager 对象中的 subscribe 方法进行订阅,其接收的参数选项为:
    • userVisibleOnly:布尔值,表示返回的推送订阅将只能被用于对用户可见的消息,该属性值必须为 true,否则会抛出以下异常
    • applicationServerKey:Uint8Array 类型,服务端用来向客户端应用发送消息的公钥。我们可以使用本章配套示例 中的 yarn generage-keys 生成相应的公私钥。
  • 订阅成功后,我们将返回的 PushSubscription 对象 subscription 信息发送到服务端,服务端存储该信息以便将来发送信息使用。其中发送到服务端的主要数据格式如下:
    • endpoint:浏览器为每个订阅者生成的唯一 URL,便于 Push Service 确定向哪个客户端发送通知。
    • expirationTime:订阅的有效时间,只读属性,值一般为 null
    • keys:用于加密消息数据,属性有 authp256dh

以上便是客户端的订阅过程,接下来我们看一下服务端的注册流程。

# 服务端

这里我们选用 Node.js 以及 web-push 来实现服务端的逻辑,主要代码如下:

const Router = require('koa-router');
const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:hzlhu.dargon@gmail.com', // 值为 URL 或 'mailto:' 格式信息
  'BLW2Nfw3ylyUdwNqAreIPYbemxnxQ7ZTZSIJIHxrgw_xOiUP9enenF5JIHX8KXY8BZpzuGN_0mCehb2XEqms3hg', // 公钥
  'LBj1P1XVRmIir5zxSAGQMvLdwxC87hU6tZYJzxO6NQ4' // 私钥
);

let subscription = null

const router = new Router();
router
  .post('/subscribe', ctx => {
    console.log('\nThe subscribe request is triggered...');
    subscription = ctx.request.body;
    ctx.body = { status: true };
  });

通过代码可以发现服务端的注册流程非常简单,首先通过调用 webpushsetVapidDetails 方法来设置 VAPID 信息,其中密钥的生成参见上文中的 yarn generage-keys 命令,而后我们在请求 POST /subscribe 中将客户端上传的 PushSubscription信息保存起来即可(注:出于演示的目的,此处仅保存在全局变量中,生产环境应保存在数据库或其他持久存储中去)。

# 发送通知

# 服务端

const webpush = require('web-push');
const router = new Router();

let subscription = null;
const languages = ['C++', 'Java', 'JavaScript', 'Swift', 'Kotlin', 'Rust'];

function pushMessage(data) {
  webpush.sendNotification(subscription, JSON.stringify(data), { proxy: 'http://127.0.0.1:1087' }).then(response => {
    console.log('\nThe data send successfully:', JSON.stringify(data));
  }).catch(err => {
    console.log('\nThe data send failed:', err);
  });
}

router
  .post('/push', ctx => {
    console.log('\nThe push request is triggered...');
    pushMessage({
      message: languages[Math.min(languages.length - 1, Math.floor(Math.random() * 10))],
      type: 'vote'
    });
    ctx.body = { status: true };
  });

上述代码中,我们通过响应来自客户端的 POST /push 请求来发送随机的编程语言投票信息,去掉数据的准备以及其他一些代码,与发送通知相关的便是 webpush.sendNotification 方法的调用,参数从左到右以次为:

  • 客户端注册的 PushSubscription 信息。
  • 要发送的消息,消息内容只能为字符串或 Buffer
  • 参数信息,这里通过设置代理来解决 Google 服务在国内无法访问的问题,其他属性参见:web-push 文档

# 客户端

此时服务端已经发送了通知,接下来就让我们看看客户端是如何做出响应的,主要代码如下:

// sw.js 文件

self.addEventListener('push', function (event) {
  const data = event.data.json();
  const title = 'Push & Notification Demo';
  console.log('触发通知响应事件:', data);
  if (data.type === 'subscribe') {
    event.waitUntil(
      self.registration.showNotification(title, {
        body: data.message,
        icon: './icon.png',
      })
    );
  } else if (data.type === 'vote') {
    event.waitUntil(
      self.registration.showNotification(title, {
        body: data.message,
        icon: './icon.png',
        actions: [
          { action: 'like', title: '👍 喜欢' },
          { action: 'unlike', title: '👎 不喜欢' }]
      })
    );
  }
});

self.addEventListener('notificationclick', function (event) {
  event.notification.close();
  console.log('触发通知点击事件:');
  if (event.action === 'like') {
    console.log(`你对 ${event.notification.body} 投了赞成票`);
  } else if (event.action === 'unlike') {
    console.log(`你对 ${event.notification.body} 投了反对票`);
  }
});

我们通过监听 Service Workerpush 事件来监听来自服务端的推送通知,在该例中,我们通过调用 self.registration.showNotification 方法显示横幅来进行响应,效果如下:

无论我们点击横幅中的任一地方,都会触发 notificationclick 事件,在该事件的监听回调中,我们首先关闭通知,然后根据点击所触发的动作来做出不同的响应。

# 取消订阅

用户可以通过改变浏览器的设置(如下图)来取消订阅,但有些时候我们可能需要通过编程的方式来取消某个用户的订阅,其实现代码如下:

function unsubscribe() {
  if ('serviceWorker' in navigator && 'PushManager' in window) {
    navigator.serviceWorker.ready.then(function(registration) {
      registration.pushManager.getSubscription().then(function(subscription) {
        if (subscription) {
          subscription.unsubscribe();
        }
      }
    });
  }
}

上述代码中,我们首先需要通过 registration.pushManager.getSubscription 检查当前用户是否已经订阅,如果已经订阅(subscription 对象不为空),便调用 subscription.unsubscribe 方法来完成取消订阅操作。

# 总结

本章中,我们对推送通知的机制以及使用进行了详细的介绍,通过该机制,即使离开了页面,用户依旧能够获得新的应用更新,这为提高用户交互体验并保证用户留存提供了可能。至此,我们完成了 PWA 底层技术(Manifest 配置文件、Service Worker、离线存储、后台同步、推送通知)的系统学习,正是这些技术的有效结合,才使得当今 Web 应用与原生应用的差异逐步淡化成为可能。下一部分中,我们将通过一个完整的案例对这些技术进行综合应用,以便大家更好地掌握 PWA。

示例代码:github.com/nanjingboy/…

阅读全文