PWA 推送实践
PWA 推送实践
最近公司内录任务的系统总是忘记录任务,而那个系统又没有通知,所以想要实现一个浏览器的通知功能,免得自己忘记录入任务。
前端实现通知的几种方式
想要实现通知,我们就需要有个客户端,对于前端同学来说,我们的客户端就是浏览器,我们每天基本上都是长开浏览器,所以用浏览器做个通知效果更好。既然是浏览器,在PWA 出现之前我们就只有 chrome 插件可以用,现在既然有了 PWA,我们有一个更为方便的方案:PWA。
为什么选用 PWA?由于内部系统的任何信息,包括域名都不能上传到外部,如果使用插件的方式,那么不可避免代码要发布到应用商店,那么恭喜,你要被约谈了。
PWA 基础介绍
PWA(Progress Web Application), 渐近式网页应用,相比于普通的网页,它具备了客户端的特性,下面是官方特性介绍
Reliable - Load instantly and never show the downasaur, even in uncertain network conditions.
Fast - Respond quickly to user interactions with silky smooth animations and no janky scrolling.
Engaging - Feel like a natural app on the device, with an immersive user experience.
使用的理由:
可发送至桌面上,以 APP 标识展示
可靠性高,可离线使用
增加用户粘性,打开的频率会更高
提高用户转化率
简单来讲, PWA 对于 Web 主要意义在于:推送和后台服务。这两个特性使得 PWA 在某种意义上可以替代部分 Chrome 插件功能(当然安全权限的原因,PWA 部分功能无法实现)。
PWA 涉及两个主要的方面:推送(含通知等)和 service worker。
关于实现一个简单的例子: https://segmentfault.com/a/1190000012462202
注册 service worker
首先我们要注册 service-worker,当然本文章不讨论兼容性问题,可自行添加相关的判断。注册完成后,需要在 service worker ready 后才可以执行其他操作
window.addEventListener('load', function() {
// register 方法里第一个参数为 Service Worker 要加载的文件;第二个参数 scope 可选,用来指定 Service Worker 控制的内容的子目录
navigator.serviceWorker.register('./ServiceWorker.js').then(function(registration) {
// Service Worker 注册成功
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}).catch(function(err) {
// Service Worker 注册失败
console.log('ServiceWorker registration failed: ', err);
});
});
推送注册
在 service worker ready 后,我们就可以进行订阅通知了:
function subscribePush() {
navigator.serviceWorker.ready.then(function(registration) {
if (!registration.pushManager) {
alert('Your browser doesn\'t support push notification.');
return false;
}
registration.pushManager.subscribe({
userVisibleOnly: true //Always show notification when received
})
.then(function (subscription) {
console.log(subscription);
});
})
}
function unsubscribePush() {
navigator.serviceWorker.ready
.then(function(registration) {
//Get `push subscription`
registration.pushManager.getSubscription()
.then(function (subscription) {
//If no `push subscription`, then return
if(!subscription) {
alert('Unable to unregister push notification.');
return;
}
//Unsubscribe `push notification`
subscription.unsubscribe()
.then(function () {
console.log(subscription);
})
})
.catch(function (error) {
console.error('Failed to unsubscribe push notification.');
});
})
}
订阅完成通知后,我们需要从 subscription
中获取用户的推送 ID,从而使用该 ID 对用户进行推送控制。
添加 manifest.json
要想发送到桌面上,并接受推送,我们需要配置 manifest.json。其中最关键的要配置 gcm_sender_id
,这个是需要在 firebase 中获取的。
{
"name": "PWA - Commits",
"short_name": "PWA",
"description": "Progressive Web Apps for Resources I like",
"start_url": "./index.html?utm=homescreen",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f5f5f5",
"theme_color": "#f5f5f5",
"icons": [
{
"src": "./images/192x192.png",
"type": "image/png",
"sizes": "192x192"
}
],
"author": {
"name": "Prosper Otemuyiwa",
"website": "https://twitter.com/unicodeveloper",
"github": "https://github.com/unicodeveloper",
"source-repo": "https://github.com/unicodeveloper/pwa-commits"
},
"gcm_sender_id": "571712848651"
}
获取允许通知权限
想到给用户发通知,需要先请求用户权限:
window.Notification.requestPermission((permission) => {
if (permission === 'granted') {
const notice = payload.notification || {}
const n = new Notification(notice.title, { ...notice })
n.onclick = function (e) {
if (payload.notification.click_action) {
window.open(payload.notification.click_action, '_blank')
}
n.onclick = undefined
n.close()
}
}
})
workbox
当然,这些都是些重复的工作,实际上 firebase 已经给我们封装好了一个现成的库,我们可以直接调用。同样,google 提供了一个 workbox 库,专门用于 service worker 功能。它的主要功能是:pre-cache 和 route request。简单来讲,就是设置某些文件预缓存,从 service worker 的 cache 中获取。Router Request 主是拦截请求,根据不同的请求定制规则,不需要自己再监听 fetch 事件,手写一大堆代码。相关文档可以直接看 https://developers.google.com/web/tools/workbox/guides/get-started .
PWA 实现推送的设计
PWA 的基本功能我们都知道了,那么我们就来实现一个 firebase 的推送。我们需要在 firebase 中创建项目,这一部分可以搜搜教程,这里就不详解了。我们的后端采用 Nodejs 来实现,使用 node-gcm 库进行通知的发送。
firebase 和项目配置
使用 firebase 进行推送和之前提到浏览器推送不同,我们需要使用 firebase 的库,并设置相关的 id。
- 首先我们需要在 html 中添加 firebase 库地址, 我们只需要推送功能,所以只引入两个脚本:
<script src="https://www.gstatic.com/firebasejs/5.8.4/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/5.8.4/firebase-messaging.js"></script>
关于使用全部文档参见这里 https://firebase.google.com/docs/web/setup?authuser=0
引用后我们需要进行配置,这段代码可以在 firebase 网站上找到。 建完应用后,在设置
-> 添加应用
, 然后复制代码至 body 中。大概是下面这个样子的
<!-- The core Firebase JS SDK is always required and must be listed first -->
<script src="https://www.gstatic.com/firebasejs/5.11.1/firebase-app.js"></script>
<!-- TODO: Add SDKs for Firebase products that you want to use
https://firebase.google.com/docs/web/setup#config-web-app -->
<script>
// Your web app's Firebase configuration
var firebaseConfig = {
apiKey: "2333333xxxx-9Co0",
authDomain: "project-name.firebaseapp.com",
databaseURL: "https://project-name.firebaseio.com",
projectId: "project-name",
storageBucket: "project-name.appspot.com",
messagingSenderId: "21590860940",
appId: "1:21590860940:web:22222"
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);
</script>
-
基本引入完成后,我们设置
manifest.json
中的gcm_sender_id
为103953800507
, 表示使用 firebase 的推送。 -
创建
firebase-messaging-sw.js
/* eslint-disable */
importScripts('https://www.gstatic.com/firebasejs/4.8.1/firebase-app.js')
importScripts('https://www.gstatic.com/firebasejs/4.8.1/firebase-messaging.js')
// Initialize the Firebase app in the service worker by passing in the
// messagingSenderId.
firebase.initializeApp({
'messagingSenderId': '21590860940'
})
// Retrieve an instance of Firebase Messaging so that it can handle background
// messages.
const messaging = firebase.messaging()
- 引入 sw 文件
需要设置云消息的公钥:可在设置 云消息集成
-> 网络配置
创建公钥。
const messaging = firebase.messaging()
messaging.usePublicVapidKey('BJBX2316OIair2mmmlcmUy6Turkwg2dqK1hHq4uj17oEQ6wk76bpUfDlMCNxXUuLebge2AneQBabVRUoiEGVmbE')
- token 上传
我们虽然已经接入了 firebase,但需要获取到 firebase 对应用户的 token 才能给用户推送消息,所以我们要获取 token 并上传
// 获取通知权限,并上传 token
export function subscribe () {
return messaging.requestPermission().then(function () {
getToken()
refreshToken()
}).catch(function (err) {
console.log('Unable to get permission to notify.', err)
})
}
function getToken () {
messaging.getToken().then((fcm) => {
if (!fcm) {
console.log('error')
return axios.delete('/fcm/token', { fcm })
}
console.log(fcm)
return axios.put('/fcm/token', { fcm })
})
.catch((err) => {
console.error(err)
return axios.delete('/fcm/token')
})
}
function refreshToken () {
messaging.onTokenRefresh(() => {
getToken()
})
}
这样,我们页面端的推送的基本设置就完成了。下面是设置服务端发送消息(如何存储用户 token 需要自行设计), 这里面使用 node-gcm,当然你也可以考虑使用其他的库。
const fcm = require('node-gcm')
const FCM_KEY = '云消息 -> 服务器密钥';
const defaultOption = {
// collapseKey: 'demo',
// priority: 'high',
// contentAvailable: true,
// delayWhileIdle: true,
// timeToLive: 3,
// dryRun: true,
};
const router = new Router({});
async function push (option) {
const { message, tokens } = option
const msg = new fcm.Message(Object.assign({}, defaultOption, message));
const sender = new fcm.Sender(FCM_KEY);
try {
// const result = await send(msg, { registrationTokens: tokens });
const result = await new Promise((resolve, reject) => {
sender.sendNoRetry(msg, { registrationTokens: tokens }, (err, res) => {
if (err) {
return reject(err)
}
return resolve(res)
})
})
return result
} catch (e) {
console.error(e)
}
};
以上这些步骤基本上可以实现推送了,可以手动尝试下推送,看是否能接收到。
几个要解决的问题
推送服务独立
由于墙的存在,内网机器无法访问 firebase 服务
解决方案:我们需要把 firebase 推送功能独立出来,形成一个 http 服务,放在可以访问 firebase 的机器上,也是就是进行拆分只需要重开个项目即可
证书
service worker 需要 https 才可以安装
这个问题是不可避免的,如果是有公网 IP 的机器,可以考虑使用 let's encrypt,推荐使用自动化脚本 acme.sh https://github.com/Neilpang/acme.sh。
内网机器就没这么方便了,没有 DNS 操作权限,只能使用自建的 CA 来发证书。使用 mkcert 即可:
mkcert 生成证书的步骤:
a) 下载linux 下的二进制文件,存放在某个目录下,然后使用 ln -s ./xxx /usr/bin/mkcert
,软链过去
b) 生成 ca 证书, 后面有 ca 证书的位置,把公钥拷贝出来,用于安装
mkcert -install
c) 生成证书,根据你的服务域名,生成证书
mkcert example.com
把公钥都复制出来,私钥和 key 用于 nginx 等配置。生成完成证书后 https 就没有问题了。不过这个方案下需要用户安装 ca 的证书才可以,增加了使用的门槛。
发送通知的主逻辑
前端提到的是一些准备工作,包括怎么引入 firebase 和推送实现,怎样避免墙的问题。而什么时候发送推送,为什么要发送推送才是工作的关键。
怎么检测用户需要通知?
服务端与其他服务是隔离的,没办法获取,只能通过接口去获取用户的状态。如果他们没有提供服务,我们就只能拿用户的 token 定时查询,如果查询结果发现当前用户没有任务,那么就调用推送接口。逻辑很简单,功能上需要获取用户登录信息,并定时查询
登录设计
想要获取用户 token,就需要根据同域原理,取在种在用户 cookie 中的 token。所幸我们虽然是内网,有子域名,取到 token 不成问题,同时用前面生成的证书,整个登录就没问题了。
由于 token 可能会失效,我们需要存储最新有效的 token,用于调用接口查询用户状态。这样流程是:
接收到用户访问页面时查询指定服务状态的请求 -> 取到当前的 token -> 使用当前 token 去查询接口 -> 成功后返回数据,并更新数据库中的 token -> 失败则使用数据库中的 token 再去查询。
登录设计一般都不相同,并涉及数据库,这里就不展示代码了。
定时任务
定时使用可以使用一些现成的库,比如说 cron,当然,如果简单的话可以自行实现一个无限循环的函数。
循环中每次遍历所有的用户,取出 token 和推送的 token,使用 token 查询服务,然后使用推送的 token 进行相关的推送。下面是无限循环类的写法:
export interface QueueOption {
time?: number;
onExecEnd?: any;
}
export default class Queue {
actions: any[];
handle: any;
onExecEnd: any;
time: number;
constructor (func: any, { time = 3000, onExecEnd }: QueueOption) {
this.actions = [{ func, count: 0, errorCount: 0, maxTime: 0 }];
this.time = time;
this.onExecEnd = onExecEnd;
this.start();
}
start () {
clearTimeout(this.handle);
this.handle = setTimeout(() => {
this.execActions().then((time) => {
this.onExecEnd && this.onExecEnd(time, this.actions);
this.start();
});
}, this.time);
}
add (func: any) {
this.actions.push({ func, count: 0, errorCount: 0, maxTime: 0 });
}
async execActions () {
const startTime = new Date().getTime();
for (const action of this.actions) {
const startStamp = process.hrtime();
try {
await action.func();
} catch (e) {
action.errorCount++;
console.error(e);
}
action.count++;
// 统计执行时间
const execStamp = process.hrtime();
const execCost = (execStamp[0] - startStamp[0]) * 1000 + (execStamp[1] - startStamp[1]) / 1e6;
action.maxTime = Math.max(action.maxTime, execCost);
}
return (new Date()).getTime() - startTime;
}
}
注意:独立循环要单独的进程,不要和其他服务一起,便于其他服务的重启和开启多核。
被墙下的替代方案
讲到这里基本上已经完成了,如果用户没有FQ,那么同样是收不到消息,如果我们自己来实现一个推送服务用来替代呢?思路是:在 service worker 启动时创建一个长链接,链接到服务端,一有消息,浏览器收到消息调用通知功能通知用户。很简单是吧,就是使用 SSE 的功能而已。关于 sse 有很多现成的库,这里就不展示自己实现的代码了,要注意几点:
- SSE 的句柄需要保存在内存中,这种情况下只能开启一个线程,免得找不到句柄,无法写入
- SSE 在断开后会自动重连,需要移除那么失效的句柄
这个方案和后面要介绍的 PWA 纯本地服务一样,有一个大问题:浏览器重启后不会继续执行,需要用户访问一次,然后重启该功能。
PWA 纯本地服务设计
上面我们已经实现了 PWA 推送和自定义的推送,为什么还要使用纯本地推送?主要根源在于:我们保存了 token。作为 SSO,token 可用于多个服务,那么推送服务的持有者可以使用其他人的 token 做一些事情,或者是 token 泄漏,这就是一个大的安全问题。所以我们需要来一个不需要存储 token 的推送。
纯本地服务后, server 端只做代理转发,解决浏览器无法重写 cookie 的问题,其他的功能均由 service worker 内部的功能来实现。
注册和通讯设计
纯的 service worker 就不需要 firebase 的功能了,我们使用 workbox 库来注册,简化操作,同时它默认实现了一些缓存功能,加速网站访问速度。下面是 sw-loop.js 的代码中注册和生成通知的代码:
// 循环实现
importScripts('https://cdn.jsdelivr.net/npm/idb-keyval@3/dist/idb-keyval-iife.min.js')
importScripts('/workbox-sw-4.2.js')
workbox.precaching.precacheAndRoute(self.__precacheManifest || [])
self.addEventListener('notificationclick', e => {
// eslint-disable-next-line no-undef
const f = clients.matchAll({
includeUncontrolled: true,
type: 'window'
})
.then(function (clientList) {
if (e.notification.data) {
// eslint-disable-next-line no-undef
clients.openWindow(e.notification.data.click_action).then(function (windowClient) {})
}
})
e.notification.close()
e.waitUntil(f)
})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
在 main.js 中进行 workbox 的注册:
import { Workbox } from 'workbox-window'
if ('serviceWorker' in navigator) {
const wb = new Workbox('/sw-loop.js')
// Add an event listener to detect when the registered
// service worker has installed but is waiting to activate.
wb.addEventListener('waiting', (event) => {
wb.addEventListener('controlling', (event) => {
window.location.reload()
})
// Send a message telling the service worker to skip waiting.
// This will trigger the `controlling` event handler above.
// Note: for this to work, you have to add a message
// listener in your service worker. See below.
wb.messageSW({ type: 'SKIP_WAITING' })
})
wb.register()
}
循环设计
本质上只是把服务端的搬运过来而已。由于在浏览器端实现,
service worker 与页面通讯
从上面的注册中我们看到,可以使用 workbox 的功能发送通知。不过我们功能简单,直接采用共享数据库的方式。在 service worker 中可以使用的存储是 indexDb,容量也比较大,完全够我们使用。现在有很多方案解决 indexDb 的 callback 模式,比如 idb。我们用存储只用于 key-value 形式,使用 idb-keyval即可。
使用方式和 localStorage 没有什么差别,除了是异步:
await idbKeyval.set('validToken', cookie)
await idbKeyval.get('validToken')
无限循环
直接使用我们上面贴出的无限循环类即可。其他代码如下:
function timeStampToISOString (time = new Date(), noHour = false) {
const date = new Date(time)
if (isNaN(date.getTime())) {
return ''
}
date.setMinutes(date.getMinutes() - date.getTimezoneOffset())
const str = date.toISOString()
return noHour
? `${str.slice(0, 10)}`
: `${str.slice(0, 10)} ${str.slice(11, 19)}`
}
const time = 1000 * 30
// eslint-disable-next-line no-new
new Queue(loopFunc, { time })
function noticeData (notification) {
self.registration.showNotification(notification.title, notification)
}
async function authRequest (url, options = {}, cookie) {
/* eslint-disable no-undef */
const validToken = await idbKeyval.get('validToken')
const firstResult = await fetch(url, {
...options,
headers: {
...(options.headers || {}),
'X-Token': cookie
}
})
if (firstResult.status !== 401) {
await idbKeyval.set('validToken', cookie)
if (firstResult.status >= 400) {
return Promise.reject(firstResult)
}
return firstResult
}
const finalResult = await fetch(url, {
...options,
headers: {
...(options.headers || {}),
'X-Token': validToken
}
})
if (finalResult.status >= 400) {
if (finalResult.status === 401) {
const notification = {
title: '助手提醒',
tag: `${Date.now()}`,
icon: '图片.png',
body: '您没有登录,请点击登录',
data: {
click_action: '页面地址'
}
}
noticeData(notification)
return Promise.reject(finalResult)
}
return finalResult
}
}
async function loopFunc () {
// 你的逻辑
}
其他
作为一个练手的小项目,很多功能还是有问题的:
使用 es2018,没有转换
在 webpack 中没有办法直接转换 sw-loop.js 为 es5,需要独立进行配置。不过考虑到浏览器的版本,目前使用的 async await 等支持的情况还是比较好的。
两种方案效果
如果没有墙的存在,使用 firebase 进行推送是最完美的方法,可惜国内无法访问。作为开发人员,长期可以FQ是正常的,所以最终还是保留了 firebase 推送。事实证明, firebase 的才是最可靠的,即使你重启浏览器或者重启电脑。纯本地的服务和自定义的推送,由于浏览器关闭后就不再有入口,永远无法再循环,需要用户访问网页来再次触发,或者使用推送通知再来实现。由于有一个能用,懒得再去修正了,如果后面有兴趣再修复吧。