作者:villainthr
摘自 前端小吉米
伴随着今年 Google I/O 大会的召开,一个很火的概念--Progressive Web Apps 诞生了。这代表着我们 web 端有了和原生 APP 媲美的能力。但是,有一个很重要的痛点,web 一直不能使用消息推送,虽然,后面提出了 Notification
API,但这需要网页持续打开,这对于常规 APP 实现的推送,根本就不是一个量级的。所以,开发者一直在呼吁能不能退出一款能够在网页关闭情况下的 web 推送呢?
现在,Web 时代已经到来!
为了做到在网页关闭的情况下,还能继续发送 Notification,我们就只能使用驻留进程。而现在 Web 的驻留进程就是现在正在大力普及的 Service Worker。换句话说,我们的想要实现断线 Notification 的话,需要用的技术栈是:
- Push
- Notification
- Service Worker
这里,我先一个简单的 demo 样式。
说实在的,我其实 TM 很烦的这 Noti。一般使用 PC 端的,也没见有啥消息弹出来,但是,现在好了 Web 一搞,结果三端通用。你如果不禁用的话,保不准天天弹。
SW(Service Worker) 我已经在前一篇文章里面讲清楚了。这里主要探究一下另外两个技术 Push
和 Notification
。首先,有一个问题,这两个技术是用来干嘛的呢?
Push && Notification
这两个技术,我们可以理解为就是 server 和 SW 之间,SW 和 user 之间的消息通信。
- push: server 将更新的信息传递给 SW
- notification: SW 将更新的信息推送给用户
可以看出,两个技术是紧密连接到一起的。这里,我们先来讲解一下 notification
的相关技术。
Notification
那现在,我们想给用户发送一个消息的话应该怎么发送呢?
代码很简单,我直接放了:
self.addEventListener('push', function(event) {
var title = 'Yay a message.';
var body = 'We have received a push message.';
var icon = '/images/icon-192x192.png';
var tag = 'simple-push-demo-notification-tag';
var data = {
doge: {
wow: 'such amaze notification data'
}
};
event.waitUntil(
self.registration.showNotification(title, {
body: body,
icon: icon,
tag: tag,
data: data
})
);
});
大家一开始看见这个代码,可能会觉得有点陌生。实际上,这里是结合 SW 来完成的。push
是 SW 接收到后台的 push 信息然后出发。当然,我们获取信息的主要途径也是从 event
中获取的。这里为了简便,就直接使用写死的信息了。大致解释一下 API。
- event.waitUntil(promise): 该方法是用来延迟 SW 的结束。因为,SW 可能在任何时间结束,为了防止这样的情况,需要使用 waitUntil 监听 promise,使系统不会在 promise 执行时就结束 SW。
- ServiceWorkerRegistration.showNotification(title, [options]): 该方法执行后,会发回一个 promise 对象。
不过,我们需要记住的是 SW 中的 notification 只是很早以前就退出的桌面 notification 的继承对象。这意味着,大家如果想要尝试一下 notification,并不需要手动建立一个 notification,而只要使用
// 桌面端
var not = new Notification("show note", { icon: "newsong.svg", tag: "song" });
not.onclick = function() { dosth(this); };
// 在 SW 中使用
self.registration.showNotification("New mail from Alice", {
actions: [{action: 'archive', title: "Archive"}]
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
if (event.action === 'archive') {
silentlyArchiveEmail();
} else {
clients.openWindow("/inbox");
}
}, false);
不过,如果你想设置自己想要的 note 效果的话,则需要了解一下,showNotification 里面具体每次参数代表的含义,参考 Mozilla,我们可以了解到基本的使用方式。如上,API 的基本格式为 showNotification(title, [options])
-
title: 很简单,就是该次 Not(Notification) 的标题
-
options: 这个而是一个对象,里面可以接受很多参数。
- actions[Array]:该对象是一个数组,里面包含一个一个对象元素。每个对象包含内容为:
- action[String]: 表示该 Not 的行为。后面是通过监听
notificationclick
来进行相关处理 - title[String]: 该 action 的标题
- icon[URL]: 该 action 显示的 logo。大小通常为 24*24
- action[String]: 表示该 Not 的行为。后面是通过监听
- actions[Array]:该对象是一个数组,里面包含一个一个对象元素。每个对象包含内容为:
actions 的上限值,通常根据 Notification.maxActions
确定。通过在 Not 中定义好 actions 触发,最后我们会通过,监听的 notificationclick
来做相关处理:
self.addEventListener('notificationclick', function(event) {
var messageId = event.notification.data;
event.notification.close();
// 通过设置的 actions 来做适当的响应
if (event.action === 'like') {
silentlyLikeItem();
}
else if (event.action === 'reply') {
clients.openWindow("/messages?reply=" + messageId);
}
else {
clients.openWindow("/messages?reply=" + messageId);
}
}, false);
- body[String]: Not 显示的主体信息
- dir[String]: Not 显示信息的方向,通常可以取:auto, ltr, or rtl
- icon[String]:Not 显示的 Icon 图片路径。
- image[String]:Not 在 body 里面附带显示的图片 URL,大小最好是 4:3 的比例。
- tag[String]:用来标识每个 Not。方便后续对 Not 进行相关管理。
- renotify[Boolean]:当重复的 Not 触发时,标识是否禁用振动和声音,默认为 false
- vibrate[Array]:用来设置振动的范围。格式为:[振动,暂停,振动,暂停...]。具体取值单位为 ms。比如:[100,200,100]。振动 100ms,静止 200ms,振动 100ms。这样的话,我们可以设置自己 APP 都有的振动提示频率。
- sound[String]: 设置音频的地址。例如:
/audio/notification-sound.mp3
- data[Any]: 用来附带在 Not 里面的信息。我们一般可以在
notificationclick
事件中,对回调参数进行调用event.notification.data
。
- sound[String]: 设置音频的地址。例如:
针对于推送的图片来说,可能会针对不同的手机用到的图片尺寸会有所区别,例如,针对不同的 dpi。
具体参照:
看下 MDN 提供的 demo:
function showNotification() {
Notification.requestPermission(function(result) {
if (result === 'granted') {
navigator.serviceWorker.ready.then(function(registration) {
registration.showNotification('Vibration Sample', {
body: 'Buzz! Buzz!',
icon: '../images/touch/chrome-touch-icon-192x192.png',
vibrate: [200, 100, 200, 100, 200, 100, 200],
tag: 'vibration-sample'
});
});
}
});
}
当然,简单 API 的使用就是上面那样。但是,如果我们不加克制的使用 Not,可能会让用户完全屏蔽掉我们的推送,得不偿失。所以,我们需要遵循一定的原则去发送。
推送原则
1、推送必须简洁
遵循时间,地点,人物要素进行相关信息的设置。
2、尽量不要让用户打开网页查看
虽然这看起来有点违背我们最初的意图。不过,这样确实能够提高用户的体验。比如在信息回复中,直接显示:XX回复:...
这样的格式,可以完全省去用户的打开网页的麻烦。
3、不要在 title 和 body 出现一样的信息
比如:
correct:
incorrect
- 不要推荐原生 APP
因为很有可能造成推送信息重复
- 不要写上自己的网址
因为,Not 已经帮你写好了
- 尽量让 icon 和推送有关联
没用的 icon:
实用的 icon:
推送权限
实际上,Not 并不全在 SW 中运行,对于设计用户初始权限,我们需要在主页面中,做出相关的响应。当然,在设置推送的时候,我们需要考虑到用户是否会禁用,这里影响还是特别大的。
我们,获取用户权限一般可以直接使用 Notification
上挂载的 permission
属性来获取的。
- defualt: 表示需要进行询问。默认情况是不显示推送
- denied: 不显示推送
- granted: 显示推送
简单的来说为:
function initialiseState() {
if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
return;
}
// 检查是否可以进行服务器推
if (!('PushManager' in window)) {
return;
}
// 是否被禁用
if (Notification.permission === 'denied') {
return;
}
if (Notification.permission === 'granted') {
// dosth();
return;
}
// 如果还处于默认情况下,则进行询问
navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
// 检查订阅
serviceWorkerRegistration.pushManager.getSubscription()
.then(function(subscription) {
// 检查是否已经被订阅
if (!subscription) {
// 没有
return;
}
// 有
// doSth();
})
.catch(function(err) {
window.Demo.debug.log('Error during getSubscription()', err);
});
});
}
我们在加载的时候,需要先进行检查一遍,如果是默认情况,则需要发起订阅的请求。然后再开始进行处理。
那,我们上面的那段代码该放在哪个位置呢?首先,这里使用到了 SW,这意味着,我们需要将 SW 先注册成功才行。实际代码应放在 SW 注册成功的回调中:
window.addEventListener('load', function() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./service-worker.js')
.then(initialiseState);
} else {
window.Demo.debug.log('Service workers aren\'t supported in this browser.');
}
});
为了更好的显示信息,我们还可以将授权代码放到后面去。比如,将 subscribe 和 btn 的 click 事件进行绑定。这时候,我们并不需要考虑 SW 是否已经注册好了,因为SW 的注册时间远远不及用户的反应时间。
例如:
var pushButton = document.querySelector('.js-push-button');
pushButton.addEventListener('click', function() {
if (isPushEnabled) {
unsubscribe();
} else {
subscribe();
}
});
我们具体看一下 subscribe 内容:
function subscribe() {
var pushButton = document.querySelector('.js-push-button');
pushButton.disabled = true;
navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
// 请求订阅
serviceWorkerRegistration.pushManager.subscribe({userVisibleOnly: true})
.then(function(subscription) {
isPushEnabled = true;
pushButton.textContent = 'Disable Push Messages';
pushButton.disabled = false;
return sendSubscriptionToServer(subscription);
})
});
}
说道这里,大家可能会看的云里雾里,这里我们来具体看一下 serviceWorkerRegistration.pushManager
具体含义。该参数是从 SW 注册事件回调函数获取的。也就是说,它是我们和 SW 交互的通道。该对象上,绑定了几个获取订阅相关的 API:
- subscribe(options) [Promise]: 该方法就是我们常常用来触发询问的 API。他返回一个 promise 对象.回调参数为 pushSubscription 对象。这里,我们后面再进行讨论。这里主要说一下 options 里面有哪些内容
- options[Object]
- userVisibleOnly[Boolean]:用来表示后续信息是否展示给用户。通常设置为 true.
- applicationServerKey: 一个 public key。用来加密 server 端 push 的信息。该 key 是一个 Uint8Array 对象。
- options[Object]
例如:
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: new Uint8Array([...])
});
- getSubscription() [Promise]: 用来获取已经订阅的 push subscription 对象。
- permissionState(options) [Promise]: 该 API 用来获取当前网页消息推送的状态 'prompt', 'denied', 或 'granted'。里面的 options 和 subscribe 里面的内容一致。
为了更好的体验,我们可以将两者结合起来,进行相关推送检查,具体的 load 中,则为:
window.addEventListener('load', function() {
var pushButton = document.querySelector('.js-push-button');
pushButton.addEventListener('click', function() {
if (isPushEnabled) {
unsubscribe();
} else {
subscribe();
}
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./service-worker.js')
.then(initialiseState);
} else {
window.Demo.debug.log('Service workers aren\'t supported in this browser.');
}
});
当然,这里面还会涉及其他的一些细节,我这里就不过多赘述了。详情可以查阅: Notification demo。
我们开启一个 Not 询问很简单,但关键是,如果让用户同意。如果我们一开始就进行询问,这样成功性的可能性太低。我们可以在页面加载后进行询问。这里,也有一些提醒原则:
- 通过具体行为进行询问
比如,当我在查询车票时,就可以让用户在退出时选择是否接受推送信息。比如,国外的飞机延迟通知网页:
- 让用户来决定是否进行推送
因为用户不是技术人员,我们需要将一些接口,暴露给用户。针对推送而言,我们可以让用户选择是否进行推送,并且,在提示的同时,显示的信息应该尽量和用户相关。
推送处理
web push 在实际协议中,会设计到两个 server,比较复杂,这里我们先来看一下。client 是如何处理接受到的信息的。
当 SW 接受到 server 传递过来的信息时,会先触发 push
事件。我们通常做如下处理:
self.addEventListener('push', function(event) {
if (event.data) {
console.log('This push event has data: ',event.data.text());
} else {
console.log('This push event has no data.');
}
});
其中,我们通过 server push 过来的 msg 通常是挂载到 event.data
里的。并且,该部署了 Response 的相关 API:
- text(): 返回 string 的内容
- json(): 返回 经过 json parse 的对象
- blob(): 返回 blob 对象
- arrayBuffer(): 返回 arrayBuffer 对象
我们知道 Service Worker 并不是常驻进程,有童鞋可能会问到,那怎么利用 SW 监听 push 事件呢?
这里就不用担心了,因为浏览器自己会打开一个端口监听接受到的信息,然后唤起指定的 SW(如果你的浏览器是关闭的,那么你可以洗洗睡了)。而且,由于这样随机关闭的机制,我们需要上述提到的 event.waitUntil
API 来帮助我们完成持续 alive SW 的效果,防止正在执行的异步程序被终止。针对于我们的 notification 来说,实际上就是一个异步,所以,我们需要使用上述 API 进行包裹。
self.addEventListener('push', function(event) {
const promiseChain = self.registration.showNotification('Hello, World.');
event.waitUntil(promiseChain);
});
当然,如果你想在 SW 里面做更多的异步事情的话,可以使用 Promise.all 进行包裹。
self.addEventListener('push', function(event) {
const promiseChain = Promise.all([ async1,async2 ]);
event.waitUntil(promiseChain);
});
之后,就是将具体信息展示推送给用户了。上面已经将了具体 showNotification
里面的参数有哪些。不过,这可能不够直观,我们可以使用一张图来感受一下:
(左:firefox,右:Chrome)
另外,在 showNotification options 里面,还有一些属性需要我们额外注意。
属性注意
tag
对于指定的 Not 我们可以使用 tag
来表明其唯一性,这代表着当我们在使用相同 tag
的 Not 时,上一条 Not 会被最新拥有同一个 tag 的Not 替换。即:
const title = 'First Notification';
const options = {
body: 'With \'tag\' of \'message-group-1\'',
tag: 'message-group-1'
};
registration.showNotification(title, options);
显示样式为:
接着,我显示一个不同 tag 的 Not:
const title = 'Second Notification';
const options = {
body: 'With \'tag\' of \'message-group-2\'',
tag: 'message-group-2'
};
registration.showNotification(title, options);
结果为:
然后,我使用一个同样 tag 的 Not:
const title = 'Third Notification';
const options = {
body: 'With \'tag\' of \'message-group-1\'',
tag: 'message-group-1'
};
registration.showNotification(title, options);
则相同的 tag 会被最新 tag 的 Not 替换:
Renotify
该属性是 Not 里面又一个比较尴尬的属性,它的实际应用场景是当有重复 Not 被替换时,震动和声音能不能被重复播放,但默认为 false。
那何为重复呢?
就是,上面我们提到的 tag 被替换。一般应用场景就是和同一个对象聊天时,发送多个信息来时,我们不可能推送多个提示信息,一般就是把已经存在的 Not 进行替换就 ok,那么这就是上面提到的因为重复,被替换的 Not。
一般我们对于这样的 Not 可以设置为:
const title = 'Second Notification';
const options = {
body: 'With "renotify: true" and "tag: \'renotify\'".',
tag: 'renotify',
renotify: true
};
registration.showNotification(title, options);
并且,如果你设置了 renotify
而没有设置 tag 的话,这是会报错的 !!!
silent
防止自己推送的 Not 发出任何额外的提示操作(震动,声音)。默认为 false。不过,我们可以在需要的时候,设置为 true:
const title = 'Silent Notification';
const options = {
body: 'With "silent: \'true\'".',
silent: true
};
registration.showNotification(title, options);
requireInteraction
对于一般的 Not 来说,当展示一定时间过后,就可以自行消失。不过,如果你的 Not 一定需要用户去消除的话,可以使用 requireInteraction
来进行长时间留存。一般它的默认值为 false。
const title = 'Require Interaction Notification';
const options = {
body: 'With "requireInteraction: \'true\'".',
requireInteraction: true
};
registration.showNotification(title, options);
交互响应
现在,你的 Not 已经显示给用户,不过,默认情况下,Not 本身是不会做任何处理的。我们需要监听用户,对其的相关操作(其实就是 click 事件)。
self.addEventListener('notificationclick', function(event) {
// do nothing
});
另外,通过我们在 showNotification 里面设置的 action
,我们可以根据其作出不同的响应。
self.addEventListener('notificationclick', function(event) {
if (event.action) {
console.log('Action Button Click.', event.action);
} else {
console.log('Notification Click.');
}
});
关闭推送
这是应该算是最常用的一个,只是用来提示用户的相关信息:
self.addEventListener('notificationclick', function(event) {
event.notification.close();
// Do something as the result of the notification click
});
打开一个新的窗口
这里,需要使用到我们的 service 里面的一个新的 API clients。
event.waitUntil(
// examplePage 就是当前页面的 url
clients.openWindow(examplePage)
);
这里需要注意的是 examplePage
必须是和当前 SW 同域名才行。不过,这里有两种情况,需要我们考虑:
- 指定的网页已经打开?
- 当前没网?
聚焦已经打开的页面
这里,我们可以利用 cilents 提供的相关 API 获取,当前浏览器已经打开的页面 URLs。不过这些 URLs 只能是和你 SW 同域的。然后,通过匹配 URL,通过 matchingClient.focus()
进行聚焦。没有的话,则新打开页面即可。
const urlToOpen = self.location.origin + examplePage;
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);
检测是否需要推送
另外,如果用户已经停留在当前的网页,那我们可能就不需要推送了,那么针对于这种情况,我们应该怎么检测用户是否正在网页上呢?
const promiseChain = ({
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) {
return self.registration.showNotification('Had to show a notification.');
} else {
console.log('Don\'t need to show a notification.');
}
});
event.waitUntil(promiseChain);
当然,如果你自己的网页已经被用户打开,我们同样也可以根据推送信息直接将信息传递给对应的 window。我们通过 clients.matchAll
获得的 windowClient
对象,调用 postMessage
来进行消息的推送。
windowClient.postMessage({
message: 'Received a push message.',
time: new Date().toString()
});
合并消息
该场景的主要针对消息的合并。比如,聊天消息,当有一个用户给你发送一个消息时,你可以直接推送,那如果该用户又发送一个消息呢?
这时候,比较好的用户体验是直接将推送合并为一个,然后替换即可。
那么,此时我们就需要获得当前已经展示的推送消息,这里主要通过 registration.getNotifications()
API 来进行获取。该 API 返回的也是一个 Promise 对象。
当然,我们怎么确定两个消息是同一个人发送的呢?这里,就需要使用到,上面提到的 Not.data 的属性。这是我们在 showNotification
里面附带的,可以直接在 Notification 对象中获取。
return registration.getNotifications()
.then(notifications => {
let currentNotification;
for(let i = 0; i < notifications.length; i++) {
// 检测已经存在的 Not.data.userName 和新消息的 userName 是否一致
if (notifications[i].data &&
notifications[i].data.userName === userName) {
currentNotification = notifications[i];
}
}
return currentNotification;
})
// 然后,进行相关的逻辑处理,将 body 的内容进行更替
.then((currentNotification) => {
let notificationTitle;
const options = {
icon: userIcon,
}
if (currentNotification) {
// We have an open notification, let's so something with it.
const messageCount = currentNotification.data.newMessageCount + 1;
options.body = `You have ${messageCount} new messages from ${userName}.`;
options.data = {
userName: userName,
newMessageCount: messageCount
};
notificationTitle = `New Messages from ${userName}`;
currentNotification.close();
} else {
options.body = `"${userMessage}"`;
options.data = {
userName: userName,
newMessageCount: 1
};
notificationTitle = `New Message from ${userName}`;
}
return registration.showNotification(
notificationTitle,
options
);
});
相当于从:
变为:
上面提到了在 SW 中使用,clients 获取窗口信息,这里我们先补充一下相关的知识。
Clients Object
我们可以将 Clients 理解为我们现在所在的浏览器,不过特殊的地方在于,它是遵守同域规则的,即,你只能操作和你域名一致的窗口。同样,Clients 也只是一个集合,用来管理你当前所有打开的页面,实际上,每个打开的页面都是使用一个 cilent object 进行表示的。这里,我们先来探讨一下 cilent object:
- Client.postMessage(msg[,transfer]): 用来和指定的窗口进行通信
- Client.frameType: 表明当前窗口的上下文。该值可以为: auxiliary, top-level, nested, 或者 none.
- Client.id[String]: 使用一个唯一的 id 表示当前窗口
- Client.url: 当前窗口的 url。
- WindowClient.focus(): 该方法是用来聚焦到当前 SW 控制的页面。下面几个也是 Client,不过是专门针对
type=window
的client。 - WindowClient.navigate(url): 将当前页面到想到指定 url
- WindowClient.focused[boolean]: 表示用户是否停留在当前 client
- WindowClient.visibilityState: 用来表示当前 client 的可见性。实际和
focused
没太大的区别。可取值为:hidden, visible, prerender, or unloaded
。
然后,Clients Object 就是用来管理每个窗口的。常用方法有:
- Clients.get(id): 用来获得某个具体的 client object
self.clients.get(id).then(function(client) {
// 打开具体某个窗口
self.clients.openWindow(client.url);
});
- Clients.matchAll(options): 用来匹配当前 SW 控制的窗口。由于 SW 是根据路径来控制的,有可能只返回一部分,而不是同域。如果需要返回同域的窗口,则需要设置响应的 options。
- includeUncontrolled[Boolean]: 是否返回所有同域的 client。默认为
false
。只返回当前 SW 控制的窗口。 - type: 设置返回 client 的类型。通常有:window, worker, sharedworker, 和 all。默认是
all
。
- includeUncontrolled[Boolean]: 是否返回所有同域的 client。默认为
// 常用属性为:
clients.matchAll({
type: 'window',
includeUncontrolled: true
}).then(function(clientList) {
for(var i = 0 ; i < clients.length ; i++) {
if(clientList[i].url === 'index.html') {
clients.openWindow(clientList[i]);
}
}
});
- Clients.openWindow(url): 用来打开具体某个页面
- Clients.claim(): 用来设置当前 SW 和同域的 cilent 进行关联。
Push
先贴一张 google 关于 web push 的详解图:
上述图,简单阐述了从 server 产生信息,最终到手机生成提示信息的一系列过程。
先说一下中间那个 Message Server。这是独立于我们常用的 Server -> Client 的架构,浏览器可以自己选择 push service,开发者一般也不用关心。不过,如果你想使用自己定制的 push serivce 的话,只需要保证你的 service 能够提供一样的 API 即可。上述过程为:
- 用于打开你的网页,并且,已经生成好用来进行 push 的
applicationServerKey
。然后,phone 开始初始化 SW。 - 用户订阅该网页的推送,此时会给 message server 发送一个请求,创建一个订阅,然后返回 message server 的相关信息。
- 浏览器获得 message server 的相关信息后,然后在发送一个请求给该网页的 server。
- 如果 server 这边检测到有新的信息需要推送,则它会想 message server 发送相关请求即可。
这里,我们可以预先看一下 message server 返回来的内容:
{
"endpoint": "https://random-push-service.com/some-kind-of-unique-id-1234/v2/",
"keys": {
"p256dh" : "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=",
"auth" : "tBHItJI5svbpez7KI4CCXg=="
}
}
endpoint
就是浏览器订阅的 message server 的地址。这里的 keys 我们放到后面讲解,主要就是用来进行 push message 的加密。
根据官方解释,Message Server 与用户将的通信,借用的是 HTTP/2 的 server push 协议。上面的图,其实可以表达为:
+-------+ +--------------+ +-------------+
| UA | | Push Service | | Application |
+-------+ +--------------+ | Server |
| | +-------------+
| Subscribe | |
|--------------------->| |
| Monitor | |
|<====================>| |
| | |
| Distribute Push Resource |
|-------------------------------------------->|
| | |
: : :
| | Push Message |
| Push Message |<---------------------|
|<---------------------| |
| | |
接下来,我们就需要简单的来看一下使用 Web Push 的基本原则。
Push 基本原则
- 首先,server 发送的 push msg 必须被加密,因为这防止了中间的 push service 去查看我们的推送的信息。
- 通过 server 发送的 msg 需要设置一个失效时间,以为 Web Push 真正能够作用的时间是当用户打开浏览器的时候,如果用户没有打开浏览器,那么 push service 会一直保存该信息直到该条 push msg 过期。
那么如果我们想让用户订阅我们的 push service 我们首先需要得到用户是否进行提示的许可。当然,一开始我们还需要判断一下,该用户是否已经授权,还是拒绝,或者是还未处理。这里,可以参考上面提到的推送权限一节中的 initialiseState
函数方法。
这里我们主要研究一下具体的订阅环节(假设用户已经同意推送)。基本格式为:
navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
const subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U'
)
};
return registration.pushManager.subscribe(subscribeOptions);
)}
.then(function(subscription) {
return subscription
})
这里有两个参数 userVisibleOnly
和 applicationServerKey
。这两个属性值具体代表什么意思呢?
userVisibleOnly
该属性可以算是强制属性(你必须填,而且只能填 true)。因为,一开始 Notification 的设计是 可以在用户拒绝的情况下继续在后台执行推送操作,这造成了另外一种情况,开发者可以在用户关闭的情况下,通过 web push 获取用户的相关信息。所以,为了安全性保证,我们一般只能使用该属性,并且只能为 true(如果,不呢?浏览器就会报错)。
applicationServerKey
前面说过它是一个 public key。用来加密 server 端 push 的信息。该 key 是一个 Uint8Array 对象,而且它 需要符合 VAPID 规范实际,所以我们一般可以叫做 application server keys
或者 VAPID keys
,我们的 server 其实有私钥和公钥两把钥匙,这里和 TLS/SSL 协商机制类似,不过不会协商出 session key,直接通过 pub/pri key 进行信息加/解密。不过,它还有其他的用处:
- 对于信息
- 进行加密/解密,增强安全性
- 对于 push service
- 保证唯一性,因为 subscribe 会将该 key 发送过去。在 push service 那边,会根据该 key 针对每次发送生成独一无二的 endpoint,然后根据该 endpoint 给某些指定用户信息 push message。
整个流程图为:
另外,该 key 还有一个更重要的用途是,当在后台 server 需要进行 push message,向 push service 发送请求时,会有一个 Authorization
头,该头的内容时由 private key 进行加密的。然后,push service 接受到之后,会根据配套的 endpoint 的 public key 进行解密,如果解密成功则表示该条信息是有效信息(发送的 server 是合法的)。
流程图为:
通过 subscribe() 异步调用返回的值 subscription
的具体格式为:
{
"endpoint": "https://some.pushservice.com/something-unique",
"keys": {
"p256dh": "BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=",
"auth":"FPssNDTKnInHVndSTdbKFw=="
}
}
简单说一下参数,endpoint 就是 push service 的 URL,我们的 server 如果有消息需要推送,就是想该路由发送请求。而 keys 就是用来对信息加密的钥匙。得到返回的 subscription
之后,我们需要发送给后台 server 进行存储。因为,每个用户的订阅都会产生独一无二的 endpoint,所以,我们只需要将 endpoint 和关联用户存储起来就 ok 了。
fetch('/api/save-subscription/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
})
接下来就到了 server 推送 msg 的环节了。
服务器推送信息
当服务器有新的消息需要推送时,就需要向 push service 发送相关的请求进行 web push。不过,这里我们需要了解,从服务器到 push service
的请求,实际上就是 HTTP 的 post method。我们看一个具体的请求例子:
POST /push-service/send/dbDqU8xX10w:APA91b... HTTP/1.1
Host: push.example.net
Push-Receipt: https://push.example.net/r/3ZtI4YVNBnUUZhuoChl6omU
TTL: 43200
Content-Type: text/plain;charset=utf8
Content-Length: 36
Authorization: WebPush
eyJ0eXAiOiJKV1QiLCJErtm.ysazNjjvW2L9OkSSHzvoD1oA
Crypto-Key:
p256ecdsa=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU\_RCPCfA5aq9ojSwk5Y2EmClBPsiChYuI3jMzt3ir20P8r\_jgRR-dSuN182x7iB
当然,变化的是里面推送的具体的 Headers 和 body 内容。我们可以看一下具体头部代表的意思:
头部参考
Header | Content |
---|---|
Authorization | 可以理解该头是一个 JSON Web Token,用来验证是否是真实的订阅 server |
Crypto-Key | 用来表示加密的 key。它由两部分组成:dh=publicKey,p256ecdsa=applicationServerKey。其中 p256ecdsa 就是由 pub key 加密的 base64 的 url |
Encryption | 它用来放置加盐秘钥。用来加密 payload |
Content-Type | 如果你没发送 payload 的话,那么就不用发送该头。如果发送了,则需要将其设置为 application/octet-stream。这是为了告诉浏览器我发送的是 stream data |
Content-Length | 用来描述 payload 的长度(没有 payload 的不用) |
Content-Encoding | 该头必须一直是 aesgcm 不论你是否发送 payload |
TTL (Time to Live) | 表示该 message 可以在 push service 上停留多长时间(为什么停留?因为用户没有打开指定浏览器,push service 发布过去)。如果 TTL 为 0,表示当有推送信息时,并且此时 push service 能够和用户的浏览器建立联系,则第一时间发送过去。否则立即失效 |
Topic | 该头实际上和 Notification 中的 tag 头类似。如果 server 先后发送了两次拥有相同 Topic 的 message 请求,如果前一条 topic 正在 pending 状态,则会被最新一条 topic 代替。不过,该 Topic 必须 <= 32 个字符 |
Urgency[实验特性] | 表示该消息的优先级,优先级高的 Notification 会优先发送。默认值为: default。可取值为: "very-low" |
返回的响应码
通常,push service 接受之后,会返回相关的状态码,来表示具体操作结果:
statusCode | Description |
---|---|
201 | 表示推送消息在 push service 中已经成功创建 |
429 | 此时,push service 有太多的推送请求,无法响应你的请求。并且,push service 会返回 Retry-After 的头部,表示你下次重试的时间。 |
400 | 无效请求,表示你的请求中,有不符合规范的头部 |
413 | 你的 payload 过大。最小的 payload 大小为 4kb |
发送过程
可以从上面头部看出,push service 需要的头很复杂,如果我们纯原生手写的话,估计很快就写烦了。这里推荐一下 github 里面的库,可以直接根据 app server key 来生成我们想要的请求头。这里,我们打算细节的了解一下每个头部内容产生的相关协议。
applicationServerKey
首先,这个 key 是怎么拿到的?需要申请吗?
答案是:不需要。这个 key 只要你符合一定规范就 ok。不过一旦生成之后,不要轻易改动,因为后面你会一直用到它进行信息交流。规则简单来说为:
- 它是 server 端生成 pub/pri keys 的公钥
- 它是可以通过
crypto
加密库,依照P-256
曲线,生成`ECDSA` 签名方式。 - 该 key 需要是一个 8 位的非负整型数组(Unit8Array)
简单 demo 为:
function generateVAPIDKeys() {
var curve = crypto.createECDH('prime256v1');
curve.generateKeys();
return {
publicKey: curve.getPublicKey(),
privateKey: curve.getPrivateKey(),
};
}
// 也可以直接根据 web-push 库生成
const vapidKeys = webpush.generateVAPIDKeys();
具体头部详细信息如下:
头部参考
Authorization
Authorization 头部的值(上面也提到了)是一个 JSON web token(简称为 JWT)。基本格式为:
Authorization: WebPush <JWT Info>.<JWT Payload>.<Signature>
实际上,该头涵盖了很多信息(手写很累的。。。)。所以,我们这里可以利用现有的一些 github 库,比如 jsonwebtoken。专门用来生成,JWT 的。我们看一下它显示的例子:
简单来说,上面 3 部分都是将对象通过 private key 加密生成的字符串。
info 代表:
{
"typ": "JWT",
"alg": "ES256"
}
用来表示 JWT 的加密算法是啥。
Payload 代表:
{
"aud": "https://some-push-service.org",
"exp": "1469618703",
"sub": "mailto:example@web-push-book.org"
}
其中
- aud 表示,push service 是谁
- exp(expire)表示过期时间,并且是以秒为单位,最多只能是一天。
- sub 用来表示 push service 的联系方式。
Signature 代表:
它是用来验证信息安全性的头。它是前面两个,JWT.info + '.' + JWT.payload
的字符串通过私有 key 加密的生成的结果。
Crypto-Key
这就是我们公钥的内容,简单格式为:
Crypto-Key: dh=<URL Safe Base64 Encoded String>, p256ecdsa=<URL Safe Base64 Public Application Server Key>
// 两个参数分别代表:
dh=publicKey,p256ecdsa=applicationServerKey
Content-Type, Length & Encoding
这几个头是涉及 payload 传输时,需要用到的。基本格式为:
Content-Length: <Number of Bytes in Encrypted Payload>
Content-Type: 'application/octet-stream'
Content-Encoding: 'aesgcm'
其中,只有 Content-Length
是可变的,用来表示 payload 的长度。
TTL,Topic & Urgency
这几个头上面已经说清楚了,我这里就不赘述了。
最后放一张关于 SW 的总结图: