通过通知推送让 PWA 可重用

拥有本地缓存能力来实现离线应用是一个强大的特性。允许用户在主屏幕上安装Web应用程序则显得更了不起。但与其单纯依赖用户手动打开应用,我们可以做得更多的东西,我们可以利用消息和通知推送来自动重启应用并随时提供新的内容。

两个API,一个目标

推送 API通知 API是两个相互独立的API,但当你的应用想实现一些有趣实用的功能时他们可以配合得很好。推送API可以实现从服务端推送新的内容而不需要客户端发起请求,它是由应用的service worker来实现的。通知功能则可以通过service worker来向用户展示一些新的信息,或者至少提醒用户应用已经更新了某些功能。

这些工作是在浏览器外部实现的,跟service worker一样,所以即使应用被隐藏到后台甚至应用已经被关闭了,我们仍然能够推送更新或者推送通知给用户。

通知

让我们先从通知API开始,它能够脱离推送API单独工作,但两者结合起来会非常的有用,我们先单独看看它能做什么。

请求授权

为了能够显示通知,我们需要先请求用户的授权。最佳的实践经验告诉我们,我们应该引导用户点击一个按钮的时候弹出一个授权窗口,而不是立刻显示通知(授权之前无法显示通知,译者注):

var button = document.getElementById("notifications");
button.addEventListener('click', function(e) {
    Notification.requestPermission().then(function(result) {
        if(result === 'granted') {
            randomNotification();
        }
    });
});

下面的图片展示了通过系统的通知服务显示一个授权窗口:

Notification of js13kPWA.

当用户确定接收通知,我们得到应用就可以获得推送通知的功能。用户的授权的结果有三种,default,granted 或者denied,当用户没有做出选择的时候,授权结果会返回defalut,另外两种结果分别是用户选择了授权或者拒绝授权。

一旦用户选择授权,这个授权结果对通知API和推送API两者都有效

创建一个通知

我们的示例应用通过可用的数据来创建通知 —— 随机选择一个游戏数据来填充通知的主体:用游戏的名称来作为通知的标题,通知的主体会提及游戏的作者,用游戏的图片来作为通知的图标:

function randomNotification() {
    var randomItem = Math.floor(Math.random()*games.length);
    var notifTitle = games[randomItem].name;
    var notifBody = 'Created by '+games[randomItem].author+'.';
    var notifImg = 'data/img/'+games[randomItem].slug+'.jpg';
    var options = {
        body: notifBody,
        icon: notifImg
    }
    var notif = new Notification(notifTitle, options);
    setTimeout(randomNotification, 30000);
}

上述代码每个三十秒会创建一个通知,直到用户开始觉得通知有点烦人并手动关闭掉它为止。(对于一个真正的app,这种通知的频率必须进行严格的控制,它的内容也要更加有用才行)通知API的优势在于它使用了操作系统的通知功能。这意味着即使用户当前并没有在使用我们的应用,我们的通过也能够展示,他看起来跟一个原生应用发出的通知差不多。

推送

推送比通知要复杂一些,我们需要从服务端订阅一个服务,之后服务端会推送数据到客户端应用。应用的Service Worker将会接收到从服务端推送的数据,这些数据可以用来做通知推送,或者实现其他的需求。

这个技术还处在非常初级的阶段,一些运行中的例子使用了谷歌云的消息推送平台,但他们正在被重写以支持 VAPID (Voluntary Application Identification),它为你的应用提供了一层额外的安全防护。你可以检查一下这个例子Service Workers Cookbook examples,尝试用Firebase,配置一个消息推送服务,或者构建自己的推送服务(例如使用Node.js).

之前提到,为了接收到推送的消息,你需要有一个service worker,这部分的基础知识我们已经在文章 通过 Service workers 让 PWA 离线工作 里面解释过。在service worker 内部,存在一个消息推送服务订阅机制。

registration.pushManager.getSubscription() .then( /* ... */ );

一旦用户订阅服务,他们就能接收到服务器推送的通知。

从服务端的角度来看,出于安全的目的,这整个过程必须使用非对称加密技术进行加密 —— 允许用户不安全的使用应用程序来发送推送消息会是一个很糟糕的主意。你可以通过阅读Web推送数据加密测试页里面的详细信息来保护你的服务器。当用户订阅服务时,服务器会储存所有的接收到的信息以便在后续需要的时候能将信息推送出去。

为了能够接收到推送的消息,我们需要在Service Worker文件里面监听push事件:

self.addEventListener('push', function(e) { /* ... */ });

这些数据将会被接收后通过通知的方式立刻展现给用户。举个例子,这个功能可以用来提醒用户一些事情,或者让他们知道我们的应用更新了一些新的内容并且已经可用。

推送例子

推送需要服务端参与工作,但我们没办法将服务端的例子包含进 js13kPWA这个应用里面,因为应用托管在github上,而github只提供静态页面托管。Service Worker Cookbook 这个页面上有详细的说明,demo请看 Push Payload Demo.

T这个demo包含三个文件:

让我们来一起探索一下这些代码

index.js

index.js以注册service worker开始:

navigator.serviceWorker.register('service-worker.js')
.then(function(registration) {
  return registration.pushManager.getSubscription()
  .then(async function(subscription) {
      // 注册部分
  });
})
.then(function(subscription) {
    // 订阅部分
});

这个service worker比我们在js13kPWA demo看到的I要稍微复杂一些,在这部分代码里,注册完成之后,我们使用registration对象来发起订阅,然后使用subscription对象来结束这整个流程。

注册部分的代码看起来大致是这个样子:

if(subscription) {
    return subscription;
}

如果用户已经完成订阅,我们直接返回subscription对象并且跳转到I订阅部分。如果没有,我们会初始化一个新的subscription对象:

const response = await fetch('./vapidPublicKey');
const vapidPublicKey = await response.text();
const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);

app会从服务器请求一个公钥并且把响应结果转化成文本,最后它还需要转化成一个Uint8Array(为了兼容chome)。如果你想学习更多关于VAPID的内容可以阅读这篇博客 Sending VAPID identified WebPush Notifications via Mozilla’s Push Service

app现在可以使用PushManager 来订阅新用户。这个方法支持传递两个参数 ——第一个是userVisibleOnly: true  ,意思是发送给用户的所有通知对他们都是可见的,第二个是 applicationServerKey ,这个参数包含我们之前从服务端取得并转化的VAPID key。

return registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: convertedVapidKey
});

现在我们把注意力转移到订阅部分 —— app首先使用Fetch subscription以json的方式发送给服务器。

fetch('./register', {
    method: 'post',
    headers: {
        'Content-type': 'application/json'
    },
    body: JSON.stringify({
        subscription: subscription
    }),
});

接着注册按钮绑定了一个函数

document.getElementById('doIt').onclick = function() {
    const payload = document.getElementById('notification-payload').value;
    const delay = document.getElementById('notification-delay').value;
    const ttl = document.getElementById('notification-ttl').value;

    fetch('./sendNotification', {
        method: 'post',
        headers: {
            'Content-type': 'application/json'
        },
        body: JSON.stringify({
            subscription: subscription,
            payload: payload,
            delay: delay,
            ttl: ttl,
        }),
    });
};

当按钮被点击的时候, fetch 会请求服务器根据给定的参数发送通知, payload 参数包含通知里面要显示的文本,delay 参数定义了通知将会延迟展示的秒数,ttl 是 time-to-live 的缩写,用来设置通知在服务器上的存活时间,依然是以秒为单位。

接着,我们进入下一个JavaScript文件

server.js

服务端的代码是用Node.js编写的,它需要被托管在合适的地方。这部分内容已经可以用一个完全独立的主体再写一篇文章了。所以这里我们只提供一个简单的概览。

web-push 模块被用来配置 VAPID 密钥,如果它尚未可用,你可以选择性的配置他们。

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

if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) {
  console.log("You must set the VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY "+
    "environment variables. You can use the following ones:");
  console.log(webPush.generateVAPIDKeys());
  return;
}

webPush.setVapidDetails(
  'https://serviceworke.rs/',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

接着,一个模块定义并导出了所有应用需要处理的路由:获取 VAPID 公钥,注册,发送通知等等。你可以看到来自 index.js 的变量在这里被用到:payload, delayttl

module.exports = function(app, route) {
  app.get(route + 'vapidPublicKey', function(req, res) {
    res.send(process.env.VAPID_PUBLIC_KEY);
  });

  app.post(route + 'register', function(req, res) {

    res.sendStatus(201);
  });

  app.post(route + 'sendNotification', function(req, res) {
    const subscription = req.body.subscription;
    const payload = req.body.payload;
    const options = {
      TTL: req.body.ttl
    };

    setTimeout(function() {
      webPush.sendNotification(subscription, payload, options)
      .then(function() {
        res.sendStatus(201);
      })
      .catch(function(error) {
        console.log(error);
        res.sendStatus(500);
      });
    }, req.body.delay * 1000);
  });
};

service-worker.js

最后我们要看得文件是service worker。

self.addEventListener('push', function(event) {
    const payload = event.data ? event.data.text() : 'no payload';
    event.waitUntil(
        self.registration.showNotification('ServiceWorker Cookbook', {
            body: payload,
        })
    );
});

它做的所有事就是监听 push 事件,创建payload 变量,这个变量包含了来自event.data的文本(如果data是空的,则会新建一个‘no payload’字符串),然后一直等到通知推送给用户为止。

如果你想知道他们具体是怎么处理的,请随意探索Service Worker Cookbook 里面剩下的例子—— Github上面提供了完整的源代码。那里有大量的示例展示了各种用法,包括web推送,缓存策略,性能,离线运行等等 。