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。

  1. 首先我们需要在 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>
  1. 基本引入完成后,我们设置 manifest.json 中的 gcm_sender_id103953800507, 表示使用 firebase 的推送。

  2. 创建 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()
  1. 引入 sw 文件

需要设置云消息的公钥:可在设置 云消息集成 -> 网络配置 创建公钥。

const messaging = firebase.messaging()
messaging.usePublicVapidKey('BJBX2316OIair2mmmlcmUy6Turkwg2dqK1hHq4uj17oEQ6wk76bpUfDlMCNxXUuLebge2AneQBabVRUoiEGVmbE')
  1. 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;
}
}

注意:独立循环要单独的进程,不要和其他服务一起,便于其他服务的重启和开启多核。

被墙下的替代方案

讲到这里基本上已经完成了,如果用户没有翻墙,那么同样是收不到消息,如果我们自己来实现一个推送服务用来替代呢?思路是:在 service worker 启动时创建一个长链接,链接到服务端,一有消息,浏览器收到消息调用通知功能通知用户。很简单是吧,就是使用 SSE 的功能而已。关于 sse 有很多现成的库,这里就不展示自己实现的代码了,要注意几点:

  1. SSE 的句柄需要保存在内存中,这种情况下只能开启一个线程,免得找不到句柄,无法写入
  2. 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 进行推送是最完美的方法,可惜国内无法访问。作为开发人员,长期可以翻墙是正常的,所以最终还是保留了 firebase 推送。事实证明, firebase 的才是最可靠的,即使你重启浏览器或者重启电脑。纯本地的服务和自定义的推送,由于浏览器关闭后就不再有入口,永远无法再循环,需要用户访问网页来再次触发,或者使用推送通知再来实现。由于有一个能用,懒得再去修正了,如果后面有兴趣再修复吧。

最新文章

  1. [LeetCode] Count of Smaller Numbers After Self 计算后面较小数字的个数
  2. 21-React的学习
  3. 《python核心编程》笔记——系统限制
  4. 【linux】虚拟机安装centos后ping ip地址出现错误:Network is unreachable
  5. linxu ffmpeg 编译安装
  6. DDD:如何更好的使用值对象
  7. url中的scheme
  8. get方式请求会出现中文乱码。post方式不会。
  9. OD: Big_Endian vs Little_Endian
  10. Android学习之简单的数据存储
  11. Keil - 编译错误总结 01
  12. iOS开发- 拨打电话总结
  13. MongoDB覆盖索引查询
  14. [转] 深刻理解Python中的元类(metaclass)
  15. c语言基础学习02_windows系统下的cmd命令
  16. tomcat之过滤器
  17. Qt 编程指南 4 单行编辑控件
  18. TravelPort官方API解读
  19. [工作相关] GS产品使用LInux下Oracle数据库以及ASM存储时的数据文件路径写法.
  20. 设置 webstorm 对 .vue 高亮

热门文章

  1. windows下远程访问Linux系统中mysql
  2. 60 个让程序员崩溃的瞬间,太TM真实了
  3. FastDF step by step
  4. BZOJ4559 成绩比较
  5. Ubuntu下cc和gcc的关系
  6. jemeter察看结果树中文乱码解决办法
  7. sqli_labs学习笔记(一)Less-21~Less-37
  8. Windows版Redis主从配置
  9. PBR原理
  10. windows丢失文件的恢复技巧