二、PWA实战
首先我们新建目录结构如下:

我们在index.html文件中配置如下:
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>PWA-demo</title>
<script src="https://cdn.bootcss.com/jquery/2.2.1/jquery.js"></script>
<link rel="stylesheet" type="text/css" href="/style/main.css" />
<link rel="manifest" href="/manifest.json">
</head>
<body>
<div class="nav">
<div class="button ind-btn">index</div>
<div class="button det-btn">detail</div>
</div>
<div class="index">
<p>index页</p>
</div>
<div class="detail">
<p>detail页</p>
</div>
<script src="/js/main.js"></script>
</body>
</html>
然后在main.js文件中注册service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js', {scope: '/'})
.then(function (registration) {
registration.update()
// 注册成功
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(function (err) {
// 注册失败:(
console.log('ServiceWorker registration failed: ', err);
});
});
}
下面来看看sw.js做了什么
let dataCacheName = 'new-data-v1'
let cacheName = 'new-data' // 缓存区域名
let filesToCache = [
'/',
'/index.html',
'/js/main.js',
'/style/main.css',
'/assets/images/icons/icon_144x144.png',
'/assets/images/icons/icon_152x152.png',
'/assets/images/icons/icon_192x192.png',
'/assets/images/icons/icon_512x512.png'
]
self.addEventListener('install', function (e) { // // 安装阶段
// 如果监听到了 service worker 已经安装成功的话,就会调用 e.waitUntil 回调函数
e.waitUntil(
caches.open(cacheName).then(function (cache) { // caches.open() 开启一个缓存区
// 通过 cache 缓存对象的 addAll 方法添加 precache 缓存
return cache.addAll(filesToCache)
})
)
// 当修改sw.js时,因为只能有一个service worker,所以新的service worker处于waiting状态,只有卸载旧的service worker后,新的才会进入activate状态
// 强制当前处在 waiting 状态的 Service Worker 进入 activate 状态,使得新的sw立即生效
self.skipWaiting()
})
self.addEventListener('activate', function (e) {
e.waitUntil(
// 清理旧版本
caches.keys().then(function (keyList) {
return Promise.all(keyList.map(function (key) {
if (key !== cacheName && key !== dataCacheName) {
return caches.delete(key)
}
}))
})
)
// 更新客户端, 取得页面的控制权, 这样之后打开页面都会使用版本更新的缓存。旧的 Service Worker 脚本不再控制着页面,之后会被停止
return self.clients.claim()
})
通过manifest.json将web添加到桌面,配置如下:
{
"name": "PWA实战",
"short_name": "PWA",
"icons": [
{
"src": "assets/images/icons/icon_144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "assets/images/icons/icon_152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "assets/images/icons/icon_192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/images/icons/icon_512x512.png",
"sizes": "256x256",
"type": "image/png"
}
],
"start_url": "/index.html",
"display": "standalone",
"background_color": "#fff",
"theme_color": "#1976d2"
}
接下来我们用ko2启一个服务,在server目录中新建app.js,
const Koa = require('koa');
const statics = require('koa-static')
const path = require('path')
const fs = require('fs')
const cors = require('koa2-cors');
const router = require('koa-router')()
const app = new Koa();
app.use(cors({
origin: function (ctx) {
// if (ctx.url === '/test') {
return "*"; // 允许来自所有域名请求
// }
// return "http://localhost:8080"; // 这样就能只允许 http://localhost:8080 这个域名的请求了
},
exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'],
maxAge: 5,
credentials: true, // 当设置成允许请求携带cookie时,需要保证"Access-Control-Allow-Origin"是服务器有的域名,而不能是"*";
allowMethods: ['GET', 'POST', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization', 'Accept'],
}))
app.use(statics(path.join(__dirname, '../public')))
router.get('/index',async (ctx)=>{
const result = JSON.parse(fs.readFileSync(path.join(__dirname,'../public/assets/mockData/index.json')))
ctx.body = result;
})
router.get('/detail',async (ctx)=>{
const result = JSON.parse(fs.readFileSync(path.join(__dirname,'../public/assets/mockData/detail.json')))
ctx.body = result;
})
router.get('/', async (ctx, next)=>{
ctx.type= 'text/html;charset=utf-8'
ctx.response.body = fs.readFileSync(path.join(__dirname,'../public/index.html'))
})
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(8088, function() {
console.log("🚀🚀🚀8088端口已启动,可以发射!")
});
这时我们就可以启动了,最后在main.js中请求koa2中的接口
$('.detail').hide()
let navIndex = 1
$(".ind-btn").click(function(){
if(navIndex != 1) {
fetch('/index').then(res => {
return res.json()
}).then(res => {
const text = res.data.data
$(".index p").text(text);
$(".detail").hide();
$('.index').show()
navIndex = 1
})
}
});
$(".det-btn").click(function(){
if(navIndex != 2) {
fetch('/detail').then(res => {
return res.json()
}).then(res => {
const text = res.data.data
$(".detail p").text(text);
$('.index').hide()
$(".detail").show();
navIndex = 2
})
}
});
最后我们再试一下对路由拦截,在sw.js中对路由进行拦截
self.addEventListener('fetch', function (event) {
console.log('SW Fetch', event.request.url)
let result = JSON.stringify({
"status": 0,
"errMsg": "",
"data": {
"data": "Index Page222"
}
})
if (event.request.url === 'http://localhost:8088/index') {
event.respondWith(new Response(result))
}
})
三、service worker生命周期

service worker执行流程如下:

四、cache API
// 打开cache对象
caches.open(cacheName).then(cache => {/* 获得 Cache 对象 */})
添加缓存
Cache 对象提供了 put()、add()、addAll() 三个方法来添加或者覆盖资源请求响应的缓存。需要注意的是,这些添加缓存的方法只会对 GET 请求起作用。
put
资源请求响应在通过 Cache API 进行存储的时候,会以请求的 Request 对象作为键,响应的 Response 对象作为值,因此 put() 方法需要依次传入资源的请求和响应对象,然后生成键值对并缓存起来。下面举例说明它的使用方法:
cache.put(
new Request('/data.json'),
new Response(JSON.stringify({name: 'lilei'}))
)
// 或结合 Fetch API 来获取并存储服务端所返回的资源
fetch('/data.json').then(response => {
if (response.ok) {
cache.put(new Request('/data.json'), response.clone())
}
})
add() 和 addAll() 方法的功能类似于 Fetch API 结合 put() 方法实现对服务端资源的抓取和缓存。add() 和 addAll() 的区别在于,add() 只能请求和缓存一个资源,而 addAll() 能够抓取并缓存多个资源。有了这两个方法,缓存服务端资源将变得更为简单:
cache.add('/data.json').then(() => {/* 缓存成功 */})
cache.addAll([
'/data.json',
'/info.txt'
])
.then(() => {/* 缓存成功 */})
cache.match() 和 cache.matchAll() 可以实现对缓存的查找。其中 match() 会返回第一个匹配条件的缓存结果,而 matchAll() 则会返回所有满足匹配条件的缓存结果。下面举例说明如何查找“/data.json”的缓存资源,相关代码如下所示
// 使用 match() 进行查找
cache.match('/data.json').then(response => {
if (response == null) {
// 没有匹配到任何资源
}
else {
// 成功匹配资源
}
})
// 使用 matchAll() 进行查找
cache.matchAll('/data.json').then(responses => {
if (!responses.length) {
// 没有匹配到任何资源
}
else {
// 成功匹配到资源
}
})
上述查找方法可以传入第二参数来控制匹配过程,比如设置 ignoreSearch 参数,会在匹配过程中忽略 URL 中的 Search 部分,下面通过代码举例说明这一匹配过程
// 假设缓存的请求 URL 为 /data.json?v=1
cache.match('/data.json?v=2', {ignoreSearch: true}).then(response => {
// 匹配成功
})
match()、matchAll() 方法会返回匹配到的响应,但如果需要获取匹配到的请求,可以通过 cache.keys() 方法实现:
cache.keys('/data.json', {ignoreSearch: true}).then(requests => {
// requests 可能包含 /data.json、/data.json?v=1、/data.json?v=2 等等请求对象
// 如果匹配不到任何请求,则返回空数组
})
// 如果没有传入任何参数,cache.keys() 会默认返回当前 Cache 对象中缓存的全部请求
cache.keys().then(requests => {
// 返回全部请求对象
})
删除缓存
通过 cache.delete() 方法可以实现对缓存的清理。其语法如下所示:
cache.delete(request, options).then(success => {
// 通过 success 判断是否删除成功
})
比如要删除前面添加成功的“/data.json”请求,相关代码如下所示:
cache.delete('/data.json').then(success => {
// 将打印 true,代表删除成功
console.log(success)
})
假如删除一个未被缓存的请求,则执行删除后返回的 success 为 false:
cache.delete('/no-cache.data').then(success => {
// 将打印 false,代表删除失败
console.log(success)
})
在调用 cache.delete() 时可以传入第二参数去控制删除操作中如何匹配缓存,其格式与 match()、matchAll() 等匹配方法的第二参数一致。因此下面举例的删除过程能够忽略 Search 参数:
// 假设缓存的请求 URL 为 /data.json?v=1.0.1
// 那么设置 ignoreSearch 之后同样也回删除该缓存
cache.delete('/data.json', {ignoreSearch: true}).then(success => {
// /data.json?v=1.0.1 已被成功删除
})
五、桌面通知
新建notification.js,首先来设置用户权限。
function main () {
var $state = document.getElementById('notification-state')
if (typeof Notification === 'undefined') {
$state.innerText = '浏览器不支持 Notification API'
$state.classList.add('disabled')
return
}
if (Notification.permission === 'denied') {
$state.innerText = 'Notification 权限已被禁用'
return
}
if (Notification.permission === 'granted') {
$state.innerText = 'Notification 可用'
register()
} else {
Notification.requestPermission().then(function (permission) { // 向用户申请通知权限
switch (permission) {
case 'granted':
$state.innerText = 'Notification 可用'
register()
break
case 'denied':
$state.innerText = 'Notification 权限已被禁用'
break
default:
$state.innerText = 'Notification 权限尚未授权'
}
})
}
}
来绑定几个点击事件推送消息
function register () {
// 标题&内容
document.getElementById('btn-title-body').addEventListener('click', notifyTitleAndBody)
document.getElementById('btn-long-title-body').addEventListener('click', notifyLongTitleAndBody)
document.getElementById('btn-notificationclick').addEventListener('click', notifyOpenWindow)
}
function displayNotification (title, options) {
navigator.serviceWorker.getRegistration().then(function (registration) {
registration.showNotification(title, options)
})
}
function notifyTitleAndBody () {
displayNotification('PWA-Book-Demo Notification 测试标题内容', {
body: 'Simple piece of body text.\nSecond line of body text :)'
})
}
function notifyLongTitleAndBody () {
displayNotification('PWA-Book-Demo Notification 测试长标题长内容', {
body: 'Simple piece of body text.\nSecond line of long long long long long long long long body text :) '
})
}
// 测试点击事件,点击打开新页面
function notifyOpenWindow () {
displayNotification('你好', {
body: '我叫李雷,交个朋友吧',
icon: 'https://gss0.baidu.com/9rkZbzqaKgQUohGko9WTAnF6hhy/assets/pwa/demo/pwa-icon.png',
data: {
time: new Date(Date.now()).toString(),
url: 'https://www.baidu.com'
}
})
}
// 执行main
main()
在sw.js中添加桌面推送通知
// 桌面通知
self.addEventListener('notificationclick', function (event) {
const notification = event.notification
console.log('测试 data 通知时间:' + notification.data)
// 点击点赞
if (event.action === 'like') {
console.log('点击了点赞按钮')
}
// 关闭通知
event.notification.close()
// 打开网页
if (notification.data && notification.data.url) {
event.waitUntil(clients.openWindow(event.notification.data.url))
}
})
六、网络推送
Web Push 协议出于用户隐私考虑,在应用和推送服务器之间没有进行强身份验证,这为用户应用和推送服务都带来了一定的风险。解决方案是对 Web Push 使用自主应用服务器标识(VAPID)协议,VAPID 规范允许应用服务器向推送服务器标识身份,推送服务器知道哪个应用服务器订阅了用户,并确保它也是向用户推送信息的服务器。使用 VAPID 服务过程很简单,通过几个步骤可以理解 VAPID 如何实现安全性。
如果我们使用 Node.js 作为服务端语言,那么可以通过安装 web-push 来协助生成公钥。
npm install web-push -g
web-push generate-vapid-keys
然后我们在server/config.js 文件中配置 VAPIDKeys 公钥和私钥,以及配置 Firebase 云服务(FCM)生成的 GCMAPIkey。
module.exports = {
VAPIDKeys: {
publicKey: '<Your Public Key>',
privateKey: '<Your private Key>'
},
GCMAPIkey: 'FCM Public Key'
}
在主线程文件中注册 Service Worker、申请桌面通知权限、订阅推送等等工作。
const publicKey = 'FCM Public Key'
let registration
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js', {scope: '/'})
.then(function (reg) {
reg.update()
registration = reg
// 注册成功
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.then(() => {
// 申请桌面通知权限
requestNotificationPermission()
})
.then(() => {
// 订阅推送
subscribeAndDistribute(registration)
})
.catch(function (err) {
// 注册失败:(
console.log('ServiceWorker registration failed: ', err);
});
});
}
// 申请桌面通知权限
function requestNotificationPermission () {
// 系统不支持桌面通知
if (!window.Notification) {
return Promise.reject('系统不支持桌面通知')
}
return Notification.requestPermission()
.then(function (permission) {
if (permission === 'granted') {
return Promise.resolve()
}
return Promise.reject('用户已禁止桌面通知权限')
})
}
// 订阅推送并将订阅结果发送给后端
function subscribeAndDistribute (registration) {
if (!window.PushManager) {
console.log("系统不支持消息推送")
return Promise.reject('系统不支持消息推送')
}
// 检查是否已经订阅过
return registration.pushManager.getSubscription().then(function (subscription) {
// 如果已经订阅过,就不重新订阅了
if (subscription) {
console.log("用户已订阅")
return
}
// 如果尚未订阅则发起推送订阅
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64ToUint8Array(publicKey)
})
// 订阅推送成功之后,将订阅信息传给后端服务器
.then(function (subscription) {
distributePushResource(subscription)
})
})
}
// 假设后端接收并存储订阅对象的接口为 '/api/push/subscribe'
function distributePushResource (subscription) {
return fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
endpoint: subscription.endpoint,
keys: {
p256dh: uint8ArrayToBase64(subscription.getKey('p256dh')),
auth: uint8ArrayToBase64(subscription.getKey('auth'))
}
})
})
}
// 可分别通过 pushSubscription.getKey('p256dh') 和 pushSubscription.getKey('auth') 来获取密钥和校验码信息。由于通过 getKey() 方法获取到的密钥信息类型为 ArrayBuffer,因此还需要通过转码将其转成 base64 字符串以便于传输。
function uint8ArrayToBase64 (arr) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(arr)))
}
// subscribe 方法通过 applicationServerKey 传入所需要的公钥。一般来说得到的公钥一般都是 base64 编码后的字符串,需要将其转换成 Uint8Array 格式才能作为 subscribe 的参数传入
function base64ToUint8Array (base64String) {
let padding = '='.repeat((4 - base64String.length % 4) % 4)
let base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
let rawData = atob(base64)
let outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
sw.js中监听push,监听点击事件
// 监听 push 事件
self.addEventListener('push', function (e) {
if (!e.data) {
return
}
// 解析获取推送消息
let payload = e.data.text()
// 根据推送消息生成桌面通知并展现出来
// rrayBuffer():将消息解析成 ArrayBuffer 对象;
// blob():将消息解析成 Blob 对象;
// json():将消息解析成 JSON 对象;
// text():将消息解析成字符串
let promise = self.registration.showNotification(payload.title, {
body: payload.body,
icon: payload.icon,
data: {
url: payload.url
}
})
e.waitUntil(promise)
})
// 监听通知点击事件
self.addEventListener('notificationclick', function (e) {
// 关闭窗口
e.notification.close()
// 打开网页
e.waitUntil(self.clients.openWindow(e.data.url))
})
最后我们在服务器中监听推送消息
const bodyParser = require('koa-bodyparser')
const webpush = require('web-push')
const config = require('./config')
const VAPIDkeys = config.VAPIDKeys
const GCMAPIkey = config.GCMAPIkey
// 配置 web push
webpush.setVapidDetails(
'mailto:xiaohannext@qq.com',
VAPIDkeys.publicKey,
VAPIDkeys.privateKey
)
webpush.setGCMAPIKey(GCMAPIkey)
// 存储 pushSubscription 对象
let pushSubscriptionSet = new Set()
// 定时任务,每隔 10 分钟向推送服务器发送消息
setInterval(function () {
if (pushSubscriptionSet.size > 0) {
pushSubscriptionSet.forEach(function (pushSubscription) {
webpush.sendNotification(pushSubscription, JSON.stringify({
title: '你好',
body: '我叫李雷,很高兴认识你',
icon: 'https://gss0.baidu.com/9rkZbzqaKgQUohGko9WTAnF6hhy/assets/pwa/demo/pwa-icon.png',
url: 'https://www.baidu.com'
}))
})
}
}, 10 * 60)
// 服务端提供接口接收并存储 pushSubscription
router.post('/api/push/subscribe', function (ctx, next) {
if (ctx.request.body) {
try {
pushSubscriptionSet.add(ctx.request.body)
ctx.status = 200
} catch (e) {
ctx.status= 403
}
} else {
ctx.status = 403
}
})
文档参考:
lavas
service worker
网友评论