用户订阅【译】

Posted by Neil Ning on 2023-07-21
翻译

前言

该文章翻译自web.dev的Notifications系列文章,本篇文章原文链接点击这里

消息推送的第一步是获得用户授权,获得用户授权后我们可以得到PushSubscription对象,JavaScript API的调用方式简单直接,让我们一起来看下具体如何实现。

相关功能检测

首先我们要检查当前浏览器是否支持消息推送,包括以下两个API的检查:

  1. 检查navigator对象的serviceWorker属性
  2. 检查window对象的PushManager属性
1
2
3
4
5
6
7
8
9
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和push都支持之后,下一步就是注册service worker。注册service worker过程就是告诉浏览器service worker文件的位置。该文件是一个普通的JavaScript文件,但是浏览器允许在这个文件内访问包括推送在内的service worker API,更准确的说,浏览器会在service worker环境运行该JS文件。
我们调用navigator.serviceWorker.register()来注册service worker:

1
2
3
4
5
6
7
8
9
10
11
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文件是/service-worker.js,调用register函数之后浏览器在背后完成了如下几个步骤:

  1. 下载service worker文件
  2. 运行该JS文件
  3. 如果运行正常没有错误,promise将会正常resolve,否则promise将会被reject

如果register reject,可以在Chrome DevTools中检查有没有代码拼写错误。如果register正常resolve,将会返回ServiceWorkerRegistration对象,后续我们要通过该对象访问PushManager API

获取权限

注册service worker完成之后,下一步就是要获取消息推送的相关权限。获取权限的API相对简单,下面的例子将回调函数改为返回一个Promise对象(原因点击这里),之所以这么实现是因为我们无法知道当前浏览器实现了哪个版本的API,所以我们同时处理了这两种情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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(),调用该方法会展示一个提示框给用户:
prompt.jpg

用户点击允许、阻止或者直接关闭该提示框,返回的permissionResult值依次是'granted''denied''default',上面的例子中,用户点击允许,Promise对象才会resolve,否则将会抛出异常并reject。
你需要处理的一个边界情况是用户点击拒绝。用户一旦点击拒绝,你的web应用将无法向用户询问该权限,除非用户手动设置。但是该设置项在浏览器中隐藏较深。所以请仔细考虑获取用户权限的时机,因为用户一旦拒绝,他将很难再次给予web应用该权限。好消息如果用户知道他们为什么会被询问该权限,大多数用户是很乐意点击允许的。后面我们将演示那些主流网站是如何向用户征求该权限的。

使用PushManager订阅用户

一旦我们注册完service worker并获取了用户的授权,我们就可以通过调用registration.pushManager.subscribe()订阅用户。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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,如果没有传入该选项或者传入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 for more details.

从目前的情况来看,在Chrome中静默推送可能永远也不会被实现。不过,协议制定者们也在探讨其他方式,如在web app上允许一定数量的静默推送。

applicationServerKey选项

在之前的章节中,我们简单提到过应用服务器密钥(application server keys)。该密钥使得推送服务知道哪个应用正在订阅用户,并确保同一个应用能够发消息给用户。在你的应用中,应用服务器密钥是一个唯一的公钥和私钥的密钥对。私钥必须存储在你的应用服务器上,公钥可以公开给用户。
调用subscribe()方法时,传给applicationServerKey选项的是应用的公钥,订阅用户时,浏览器会将公钥传给推送服务。这个意味着推送服务会将公钥和用户的PushSubscription对象建立关联关系。
下面的流程图演示了以上几个步骤:

  1. 在浏览器中调用subscribe()方法,浏览器会把应用服务器公钥传给推送服务。
  2. 浏览器向推送服务发起网络请求,推送服务会生成endpoint,该endpoint与你提供的公钥建立关联,同时会将该endpoint返回给浏览器。
  3. 浏览器将该endpoint加入到PushSubscription对象中,然后subscribe()方法会返回该对象。

app-key.jpg

在服务器端,当你推送消息时,你需要向推送服务发起请求,这个请求的请求头必须包含Authorization字段,该字段要求使用私钥对你发送的数据进行签名。当推送服务收到推送的消息,他会使用与endpoint相关联的公钥验证已签名的Authorization字段,验证通过,推送服务会知道改应用服务器拥有匹配的私钥。这也是一个基本的安全措施来阻止推送服务查看应用发送给用户的消息。
serverapp-key.jpg

技术上来说,applicationServerKey是可选的,但在Chrome上他是必须的。其他浏览器如Firefox还不需要该参数,不过未来可能会支持。
VAPID spec定义了application server key的规范,任何时候你看到”application server keys” 或者 “VAPID keys”,他们指得是同一个东西。

如何创建应用服务器密钥

你可以访问 web-push-codelab.glitch.me创建应用服务器密钥,或者通过web-push的命令行工具来生成。

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

对你的应用来说,你只需要创建一次该密钥,并确保私钥的安全。

权限和subscribe()

当你的应用没有显示通知的权限时,调用subscribe()方法浏览器会显示权限询问提示框。这在UI交互中调用该函数是很有用的,但是如果你想更好的控制这个过程,你最好使用前面提到的Notification.requestPermission()API来获取权限。

什么是PushSubscription对象

调用subscribe()函数,传入了一些选项,函数resolve返回的对象就是PushSubscription对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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()函数打引出该对象的内容:

1
2
3
4
5
6
7
{
"endpoint": "https://some.pushservice.com/something-unique",
"keys": {
"p256dh": "BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=",
"auth":"FPssNDTKnInHVndSTdbKFw=="
}
}

endpoint字段是推送服务的URL,推送服务时,需要向该URL发送网络请求。keys对象包含的密钥是用来加密发送的消息,这个后面会讲到。

将subscription对象发送给服务器

一旦你拿到subscription对象,你需要将该对象发送给服务器,你直接调用JSON.stringify()将对象转换成字符串,或者手动处理各个对象:

1
2
3
4
5
6
7
8
9
10
11
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);

然后需要在前端页面将对象发送给服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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服务器收到该请求,需要将该对象存入数据库中,以便后续使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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对象保存如数据库后,我们就能随时向用户推送消息了。

常见问题

到此可能会有如下常见问题:
Q: 我可以更改推送服务么?
A: 不可以,推送服务是由浏览器厂商决定的,调用subscribe()方法时,我们能看到推送服务的地址,浏览器向推送服务发送网络请求,并获取到PushSubscription对象

Q: 每个浏览器都有不同的推送服务,他们会用不同的API么?
A: 所有的推送服务都应该有相同的API,API的规范就是Web Push Protocol,它描述了发送消息时发起网络请求的各种细节。

Q: 如果在桌面设备订阅了用户,在移动设备上,用户也会被自动订阅么?
A: 很遗憾,不会。用户必须在所有希望收到消息推送的设备上单独订阅,并且在所有设备上给予授权。