共计 8437 个字符,预计需要花费 22 分钟才能阅读完成。
在上一篇文章 Service Worker 学习与实践(二)——PWA 简介中,已经讲到 PWA 的起源,优势与劣势,并通过一个简单的例子说明了如何在桌面端和移动端将一个 PWA 安装到桌面上,这篇文章,将通过一个例子阐述如何使用 Service Worker 的消息推送功能,并配合 PWA 技术,带来原生应用般的消息推送体验。
Notification
说到底,PWA 的消息推送也是服务端推送的一种,常见的服务端推送方法,例如广泛使用的轮询、长轮询、Web Socket 等,说到底,都是客户端与服务端之间的通信,在 Service Worker 中,客户端接收到通知,是基于 Notification 来进行推送的。
那么,我们来看一下,如何直接使用 Notification 来发送一条推送呢?下面是一段示例代码:
// 在主线程中使用
let notification = new Notification(‘ 您有新消息 ’, {
body: ‘Hello Service Worker’,
icon: ‘./images/logo/logo152.png’,
});
notification.onclick = function() {
console.log(‘ 点击了 ’);
};
在控制台敲下上述代码后,则会弹出以下通知:
然而,Notification 这个 API,只推荐在 Service Worker 中使用,不推荐在主线程中使用,在 Service Worker 中的使用方法为:
// 添加 notificationclick 事件监听器,在点击 notification 时触发
self.addEventListener(‘notificationclick’, function(event) {
// 关闭当前的弹窗
event.notification.close();
// 在新窗口打开页面
event.waitUntil(
clients.openWindow(‘https://google.com’)
);
});
// 触发一条通知
self.registration.showNotification(‘ 您有新消息 ’, {
body: ‘Hello Service Worker’,
icon: ‘./images/logo/logo152.png’,
});
读者可以在 MDN Web Docs 关于 Notification 在 Service Worker 中的相关用法,在本文就不浪费大量篇幅来进行较为详细的阐述了。
申请推送的权限
如果浏览器直接给所有开发者开放向用户推送通知的权限,那么势必用户会受到大量垃圾信息的骚扰,因此这一权限是需要申请的,如果用户禁止了消息推送,开发者是没有权利向用户发起消息推送的。我们可以通过 serviceWorkerRegistration.pushManager.getSubscription 方法查看用户是否已经允许推送通知的权限。修改 sw-register.js 中的代码:
if (‘serviceWorker’ in navigator) {
navigator.serviceWorker.register(‘/sw.js’).then(function (swReg) {
swReg.pushManager.getSubscription()
.then(function(subscription) {
if (subscription) {
console.log(JSON.stringify(subscription));
} else {
console.log(‘ 没有订阅 ’);
subscribeUser(swReg);
}
});
});
}
上面的代码调用了 swReg.pushManager 的 getSubscription,可以知道用户是否已经允许进行消息推送,如果 swReg.pushManager.getSubscription 的 Promise 被 reject 了,则表示用户还没有订阅我们的消息,调用 subscribeUser 方法,向用户申请消息推送的权限:
function subscribeUser(swReg) {
const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
swReg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
})
.then(function(subscription) {
console.log(JSON.stringify(subscription));
})
.catch(function(err) {
console.log(‘ 订阅失败: ‘, err);
});
}
上面的代码通过 serviceWorkerRegistration.pushManager.subscribe 向用户发起订阅的权限,这个方法返回一个 Promise,如果 Promise 被 resolve,则表示用户允许应用程序推送消息,反之,如果被 reject,则表示用户拒绝了应用程序的消息推送。如下图所示:
serviceWorkerRegistration.pushManager.subscribe 方法通常需要传递两个参数:
userVisibleOnly,这个参数通常被设置为 true,用来表示后续信息是否展示给用户。
applicationServerKey,这个参数是一个 Uint8Array,用于加密服务端的推送信息,防止中间人攻击,会话被攻击者篡改。这一参数是由服务端生成的公钥,通过 urlB64ToUint8Array 转换的,这一函数通常是固定的,如下所示:
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;
}
关于服务端公钥如何获取,在文章后续会有相关阐述。
处理拒绝的权限
如果在调用 serviceWorkerRegistration.pushManager.subscribe 后,用户拒绝了推送权限,同样也可以在应用程序中,通过 Notification.permission 获取到这一状态,Notification.permission 有以下三个取值,:
granted:用户已经明确的授予了显示通知的权限。
denied:用户已经明确的拒绝了显示通知的权限。
default:用户还未被询问是否授权,在应用程序中,这种情况下权限将视为 denied。
if (Notification.permission === ‘granted’) {
// 用户允许消息推送
} else {
// 还不允许消息推送,向用户申请消息推送的权限
}
密钥生成
上述代码中的 applicationServerPublicKey 通常情况下是由服务端生成的公钥,在页面初始化的时候就会返回给客户端,服务端会保存每个用户对应的公钥与私钥,以便进行消息推送。
在我的示例演示中,我们可以使用 Google 配套的实验网站 web-push-codelab 生成公钥与私钥,以便发送消息通知:
发送推送
在 Service Worker 中,通过监听 push 事件来处理消息推送:
self.addEventListener(‘push’, function(event) {
const title = event.data.text();
const options = {
body: event.data.text(),
icon: ‘./images/logo/logo512.png’,
};
event.waitUntil(self.registration.showNotification(title, options));
});
在上面的代码中,在 push 事件回调中,通过 event.data.text() 拿到消息推送的文本,然后调用上面所说的 self.registration.showNotification 来展示消息推送。
服务端发送
那么,如何在服务端识别指定的用户,向其发送对应的消息推送呢?
在调用 swReg.pushManager.subscribe 方法后,如果用户是允许消息推送的,那么该函数返回的 Promise 将会 resolve,在 then 中获取到对应的 subscription。
subscription 一般是下面的格式:
{
“endpoint”: “https://fcm.googleapis.com/fcm/send/cSEJGmI_x2s:APA91bHzRHllE6tNoEHqjHQSlLpcQHeiGr7X78EIa1QrUPFqDGDM_4RVKNxoLPV3_AaCCejR4uwUawBKYcQLmLpUrCUoZetQ9pVzQCJSomB5BvoFZBzkSnUb-ALm4D1lqwV9w_uP3M0E”,
“expirationTime”: null,
“keys”: {
“p256dh”: “BDOx1ZTtsFL2ncSN17Bu7-Wl_1Z7yIiI-lKhtoJ2dAZMToGz-XtQOe6cuMLMa3I8FoqPfcPy232uAqoISB4Z-UU”,
“auth”: “XGWy-wlmrAw3Be818GLZ8Q”
}
}
使用 Google 配套的实验网站 web-push-codelab,发送消息推送。
web-push
在服务端,使用 web-push-libs,实现公钥与私钥的生成,消息推送功能,Node.js 版本。
const webpush = require(‘web-push’);
// VAPID keys should only be generated only once.
const vapidKeys = webpush.generateVAPIDKeys();
webpush.setGCMAPIKey(‘<Your GCM API Key Here>’);
webpush.setVapidDetails(
‘mailto:example@yourdomain.org’,
vapidKeys.publicKey,
vapidKeys.privateKey
);
// pushSubscription 是前端通过 swReg.pushManager.subscribe 获取到的 subscription
const pushSubscription = {
endpoint: ‘…..’,
keys: {
auth: ‘…..’,
p256dh: ‘…..’
}
};
webpush.sendNotification(pushSubscription, ‘Your Push Payload Text’);
上面的代码中,GCM API Key 需要在 Firebase console 中申请,申请教程可参考这篇博文。
在这个我写的示例 Demo 中,我把 subscription 写死了:
const webpush = require(‘web-push’);
webpush.setVapidDetails(
‘mailto:503908971@qq.com’,
‘BCx1qqSFCJBRGZzPaFa8AbvjxtuJj9zJie_pXom2HI-gisHUUnlAFzrkb-W1_IisYnTcUXHmc5Ie3F58M1uYhZU’,
‘g5pubRphHZkMQhvgjdnVvq8_4bs7qmCrlX-zWAJE9u8’
);
const subscription = {
“endpoint”: “https://fcm.googleapis.com/fcm/send/cSEJGmI_x2s:APA91bHzRHllE6tNoEHqjHQSlLpcQHeiGr7X78EIa1QrUPFqDGDM_4RVKNxoLPV3_AaCCejR4uwUawBKYcQLmLpUrCUoZetQ9pVzQCJSomB5BvoFZBzkSnUb-ALm4D1lqwV9w_uP3M0E”,
“expirationTime”: null,
“keys”: {
“p256dh”: “BDOx1ZTtsFL2ncSN17Bu7-Wl_1Z7yIiI-lKhtoJ2dAZMToGz-XtQOe6cuMLMa3I8FoqPfcPy232uAqoISB4Z-UU”,
“auth”: “XGWy-wlmrAw3Be818GLZ8Q”
}
};
webpush.sendNotification(subscription, ‘Counterxing’);
交互响应
默认情况下,推送的消息点击后是没有对应的交互的,配合 clients API 可以实现一些类似于原生应用的交互,这里参考了这篇博文的实现:
Service Worker 中的 self.clients 对象提供了 Client 的访问,Client 接口表示一个可执行的上下文,如 Worker 或 SharedWorker。Window 客户端由更具体的 WindowClient 表示。你可以从 Clients.matchAll() 和 Clients.get() 等方法获取 Client/WindowClient 对象。
新窗口打开
使用 clients.openWindow 在新窗口打开一个网页:
self.addEventListener(‘notificationclick’, function(event) {
event.notification.close();
// 新窗口打开
event.waitUntil(
clients.openWindow(‘https://google.com/’)
);
});
聚焦已经打开的页面
利用 cilents 提供的相关 API 获取,当前浏览器已经打开的页面 URLs。不过这些 URLs 只能是和你 SW 同域的。然后,通过匹配 URL,通过 matchingClient.focus() 进行聚焦。没有的话,则新打开页面即可。
self.addEventListener(‘notificationclick’, function(event) {
event.notification.close();
const urlToOpen = self.location.origin + ‘/index.html’;
const promiseChain = clients.matchAll({
type: ‘window’,
includeUncontrolled: true
})
.then((windowClients) => {
let matchingClient = null;
for (let i = 0; i < windowClients.length; i++) {
const windowClient = windowClients[i];
if (windowClient.url === urlToOpen) {
matchingClient = windowClient;
break;
}
}
if (matchingClient) {
return matchingClient.focus();
} else {
return clients.openWindow(urlToOpen);
}
});
event.waitUntil(promiseChain);
});
检测是否需要推送
如果用户已经停留在当前的网页,那我们可能就不需要推送了,那么针对于这种情况,我们应该怎么检测用户是否正在网页上呢?
通过 windowClient.focused 可以检测到当前的 Client 是否处于聚焦状态。
self.addEventListener(‘push’, function(event) {
const promiseChain = clients.matchAll({
type: ‘window’,
includeUncontrolled: true
})
.then((windowClients) => {
let mustShowNotification = true;
for (let i = 0; i < windowClients.length; i++) {
const windowClient = windowClients[i];
if (windowClient.focused) {
mustShowNotification = false;
break;
}
}
return mustShowNotification;
})
.then((mustShowNotification) => {
if (mustShowNotification) {
const title = event.data.text();
const options = {
body: event.data.text(),
icon: ‘./images/logo/logo512.png’,
};
return self.registration.showNotification(title, options);
} else {
console.log(‘ 用户已经聚焦于当前页面,不需要推送。’);
}
});
});
合并消息
该场景的主要针对消息的合并。比如,当只有一条消息时,可以直接推送,那如果该用户又发送一个消息呢?这时候,比较好的用户体验是直接将推送合并为一个,然后替换即可。那么,此时我们就需要获得当前已经展示的推送消息,这里主要通过 registration.getNotifications() API 来进行获取。该 API 返回的也是一个 Promise 对象。通过 Promise 在 resolve 后拿到的 notifications,判断其 length,进行消息合并。
self.addEventListener(‘push’, function(event) {
// …
.then((mustShowNotification) => {
if (mustShowNotification) {
return registration.getNotifications()
.then(notifications => {
let options = {
icon: ‘./images/logo/logo512.png’,
badge: ‘./images/logo/logo512.png’
};
let title = event.data.text();
if (notifications.length) {
options.body = ` 您有 ${notifications.length} 条新消息 `;
} else {
options.body = event.data.text();
}
return self.registration.showNotification(title, options);
});
} else {
console.log(‘ 用户已经聚焦于当前页面,不需要推送。’);
}
});
// …
});
小结
本文通过一个简单的例子,讲述了 Service Worker 中消息推送的原理。Service Worker 中的消息推送是基于 Notification API 的,这一 API 的使用首先需要用户授权,通过在 Service Worker 注册时的 serviceWorkerRegistration.pushManager.subscribe 方法来向用户申请权限,如果用户拒绝了消息推送,应用程序也需要相关处理。
消息推送是基于谷歌云服务的,因此,在国内,收到 GFW 的限制,这一功能的支持并不好,Google 提供了一系列推送相关的库,例如 Node.js 中,使用 web-push 来实现。一般原理是:在服务端生成公钥和私钥,并针对用户将其公钥和私钥存储到服务端,客户端只存储公钥。Service Worker 的 swReg.pushManager.subscribe 可以获取到 subscription,并发送给服务端,服务端利用 subscription 向指定的用户发起消息推送。
消息推送功能可以配合 clients API 做特殊处理。
如果用户安装了 PWA 应用,即使用户关闭了应用程序,Service Worker 也在运行,即使用户未打开应用程序,也会收到消息通知。
在下一篇文章中,我将尝试在我所在的项目中使用 Service Worker,并通过 Webpack 和 Workbox 配置来讲述 Service Worker 的最佳实践。