PWA (Progressive Web Apps)
webapp用户体验差(不能离线访问),用户粘性低(无法保存入口),pwa就是为了解决这一系列问题(Progressive Web Apps),让webapp具有快速,可靠,安全等特点.
PWA一系列用到的技术
- Web App Manifest (让网站再桌面上生成桌面图标)
- Service Worker(离线缓存使用)
- Push Api & Notification Api(服务端推送)
- App Shell & App Skeleton(未加载完成的壳、正在加载的骨架)
- …
Web App Manifest
将网站添加到桌面、更类似native的体验
Web App Manifest设置
<link rel="manifest" href="/manifest.json">
{
"name": "PWA效果展示", // 应用名称
"short_name": "PWA", // 桌面应用的名称 ✓
"display": "standalone", // fullScreen (standalone) minimal-ui browser ✓
"start_url": "/", // 打开时的网址 ✓
"icons": [{ // 设置桌面图片 icon图标
"src": "/icon.png",
"sizes": "144x144",
"type": "image/png"
}],
"background_color": "#aaa", // 启动画面颜色
"theme_color": "#aaa" // 状态栏的颜色
}
ios 不支持
manifest
文件,可以通过 meta/link 私有属性进行设置
<!-- 图标icon -->
<link rel="apple-touch-icon" href="/icon.png"/>
<!-- 添加到主屏后的标题 和 short_name一致 -->
<meta name="apple-mobile-web-app-title" content="标题">
<!-- 隐藏safari地址栏 standalone模式下默认隐藏 -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- 设置状态栏颜色 -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
Service Worker
Service Worker特点:
-
不能访问/操作dom
-
会自动休眠,不会随浏览器关闭所失效(必须手动卸载)
-
离线缓存内容开发者可控
-
必须在https或者localhost下使用
-
所有的api都基于promise
生命周期
- 安装( installing ):这个状态发生在 Service Worker 注册之后,表示开始安装,触发 install 事件回调指定一些静态资源进行离线缓存。
- 安装后( installed ):Service Worker 已经完成了安装,并且等待其他的 Service Worker 线程被关闭。
- 激活( activating ):在这个状态下没有被其他的 Service Worker 控制的客户端,允许当前的 worker 完成安装,并且清除了其他的 worker 以及关联缓存的旧缓存资源,等待新的 Service Worker 线程被激活。
- 激活后( activated ):在这个状态会处理 activate 事件回调 (提供了更新缓存策略的机会)。并可以处理功能性的事件 fetch (请求)、sync (后台同步)、push (推送)。
- 废弃状态 ( redundant ):这个状态表示一个 Service Worker 的生命周期结束。
关键方法
- self.skipWaiting():表示强制当前处在 waiting 状态的 Service Worker 进入 activate 状态
- event.waitUntil():传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。
- self.clients.claim():在 activate 事件回调中执行该方法表示取得页面的控制权, 这样之后打开页面都会使用版本更新的缓存。旧的 Service Worker 脚本不再控制着页面,之后会被停止。
index.html
-
index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <link rel="stylesheet" href="/index.css"> <link rel="manifest" href="/manifest.json"> </head> <body> <div> <h1>PWA 效果展示</h1> <ul></ul> </div> <script src="/main.js"></script> </body> </html>
页面中分别引入 css、js文件 及引入manifest.json
main.js
-
main.js
const ul = document.querySelector('ul'); fetch('/api/list').then(res => res.json()).then(data => { data.forEach(item => { let li = document.createElement('li'); let img = document.createElement('img'); img.src = item; li.appendChild(img); ul.appendChild(li); }); })
main.js中会调用服务端接口获取数据进行页面渲染。
核心实现:实现离线访问功能、并离线缓存接口数据
在index.html中注册serviceWorker
-
index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <link rel="manifest" href="/manifest.json"> <link rel="apple-touch-icon" href="/icon.png" /> <!-- 添加到主屏后的标题 和 short_name一致 --> <meta name="apple-mobile-web-app-title" content="标题"> <!-- 隐藏safari地址栏 standalone模式下默认隐藏 --> <meta name="apple-mobile-web-app-capable" content="yes" /> <!-- 设置状态栏颜色 --> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <link rel="stylesheet" href="./index.css"> </head> <body> <h1>PWA 网站</h1> <ul></ul> <script src="/main.js"></script> <script> // 给网站注册serviceWorker, 注册时机 等待资源加载完毕后 ,在开线程 function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } const convertedVapidKey = urlBase64ToUint8Array('BKn9WZWSFKaRlWfxwg32xV5M_IYr_nUFRQnS8tb_fR_1X1Ga_xP2TGfObHtKZzDeVBSJfoNasD_-N5qnYyg5enc'); window.addEventListener('load', async () => { if ('serviceWorker' in navigator) { // web worker let registration = await navigator.serviceWorker.register('/sw.js'); // 等待serviceWorker 激活成功成 订阅 await navigator.serviceWorker.ready; let pushSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: convertedVapidKey }); // 拿到pushSubscription 可以推送消息, 我将他转交给我们的服务器,我们的服务器可以用他来通知pushService -> 浏览器 fetch('/add-sub',{ headers:{ 'Content-Type':'application/json' }, method:'post', body:JSON.stringify(pushSubscription) }) } }) </script> </body> </html>
sw.js
-
sw.js
- 注册监听函数
- 缓存静态资源
- 安装时将缓存列表进行缓存操作
- 激活后删除无用的缓存
- 离线使用缓存
// 对资源进行离线缓存 seriveWorker 可以自定义缓存的内容 const CACHE_NAME = 'cache_v' + 2; const CAHCE_LIST = [ // 列表越长 越容易出问题 '/', '/index.html', '/main.js', '/index.css', '/api/list', '/manifest.json', '/icon.png' ]; // 当断网时 我需要拦截请求, 使用缓存的结果 // 核心就是拦截请求 async function fetchAndSave(request){ let res = await fetch(request); // 数据流 let cloneRes = res.clone(); // 为了保证不破坏原有的响应结果 let cache = await caches.open(CACHE_NAME); await cache.put(request,cloneRes); // 用响应结果更新缓存 return res; } self.addEventListener('fetch', (e) => { // 如果是静态资源 不做拦截 // serviceWorker中不支持ajax,但是支持fetch let url = new URL(e.request.url); if(url.origin !== self.origin){ return } // 缓存策略, 如果接口是不停的变化的 我们希望将数据更新到缓存中 if(e.request.url.includes('/api')){ return e.respondWith( fetchAndSave(e.request).catch(res => { return caches.match(e.request); }) ) } // 如果断网了, 抛出异常 e.respondWith( fetch(e.request).catch(res => { return caches.match(e.request); }) ) }); // 当serviceWorker 安装时 需要跳过等待 // 预先将缓存列表的数据缓存起来 async function preCache() { let cache = await caches.open(CACHE_NAME); // 创建一个缓存空间 await cache.addAll(CAHCE_LIST); await self.skipWaiting() } self.addEventListener('install', (e) => { // e.waitUtil表示等待promise执行完成 // 预先将缓存列表的数据缓存起来 e.waitUntil(preCache()) }) async function clearCache() { let keys = await caches.keys(); return Promise.all(keys.map(key => { if (key !== CACHE_NAME) { return caches.delete(key) } })) } // serviceWorker 不是立即生效,需要在下一次访问的时候才生效 self.addEventListener('activate', (e) => { e.waitUntil(Promise.all([clearCache(), clients.claim()])); // 激活后立刻让serviceWorker拥有控制权 }) // workbox ->workbox-webpack-plugin self.addEventListener('push',function (e) { self.registration.showNotification(e.data.text()) })
缓存策略
- cachefirst 缓存优先
- cacheonly 仅缓存
- networkfirst 网络优先
- networkonly 仅网络
- StaleWhileRevalidate 从缓存取,用网络数据更新缓存
Push Api
+-------+ +--------------+ +-------------+
| UA | | Push Service | | Application |
+-------+ +--------------+ | Server |
| | +-------------+
| Subscribe | |
|--------------------->| |
| Monitor | |
|<====================>| |
| | |
| Distribute Push Resource |
|-------------------------------------------->|
| | |
: : :
| | Push Message |
| Push Message |<---------------------|
|<---------------------| |
| | |
核心实现流程
Subscribe
: 向Push Service发起订阅,获取PushSubscription
Monitor
: 实现浏览器和PushService通信 (用户离线,PushSevice会维护消息列表)Distribute Push Resource
: 将PushSubscription
交给服务器,用于通信Push Message
: 服务端将消息推送给Push Service
。Push Service
在推送给对应的客户端
实现服务端推送
server.js
const express = require('express');
const fs = require('fs');
const path = require('path');
const bodyParser = require('body-parser');
const webpush = require('web-push');
const app = express();
let list = require('./data.json');
const vapidKeys = {
publicKey: 'BKn9WZWSFKaRlWfxwg32xV5M_IYr_nUFRQnS8tb_fR_1X1Ga_xP2TGfObHtKZzDeVBSJfoNasD_-N5qnYyg5enc',
privateKey: 'bmsKpg6rE-K-LgU_DAIPynBdD8AK8hal8IMfYo3IyVc'
}
const subs = [];
app.use(bodyParser.json())
app.use(express.static(path.join(__dirname)));
app.use(express.static(path.join(__dirname, 'public')))
app.get('/api/list', (req, res) => {
let start = Math.floor(Math.random() * (list.length - 20));
res.json(list.slice(start, start + 20));
});
app.post('/add-sub', (req, res) => {
subs.push(req.body);
res.json({ data: 'ok' })
});
webpush.setVapidDetails(
'mailto:1035465284@qq.com',
vapidKeys.publicKey,
vapidKeys.privateKey
)
app.get('/server-push', (req, res) => {
subs.forEach(sub => webpush.sendNotification(sub, JSON.stringify('hello zf')));
res.end();
});
app.listen(3000,()=>{
console.log(`pwa server port:3000`)
});
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icon.png" />
<!-- 添加到主屏后的标题 和 short_name一致 -->
<meta name="apple-mobile-web-app-title" content="标题">
<!-- 隐藏safari地址栏 standalone模式下默认隐藏 -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- 设置状态栏颜色 -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="stylesheet" href="./index.css">
</head>
<body>
<h1>PWA 网站</h1>
<ul></ul>
<script src="/main.js"></script>
<script>
// 给网站注册serviceWorker, 注册时机 等待资源加载完毕后 ,在开线程
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
const convertedVapidKey = urlBase64ToUint8Array('BKn9WZWSFKaRlWfxwg32xV5M_IYr_nUFRQnS8tb_fR_1X1Ga_xP2TGfObHtKZzDeVBSJfoNasD_-N5qnYyg5enc');
window.addEventListener('load', async () => {
if ('serviceWorker' in navigator) {
// web worker
let registration = await navigator.serviceWorker.register('/sw.js');
// 等待serviceWorker 激活成功成 订阅
await navigator.serviceWorker.ready;
let pushSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
});
// 拿到pushSubscription 可以推送消息, 我将他转交给我们的服务器,我们的服务器可以用他来通知pushService -> 浏览器
fetch('/add-sub',{
headers:{
'Content-Type':'application/json'
},
method:'post',
body:JSON.stringify(pushSubscription)
})
}
})
</script>
</body>
</html>
Notification
在ServieWorker
中监听服务端推送的消息
self.addEventListener('push', function (e) {
var data = e.data;
if (e.data) {
self.registration.showNotification(data.text());
} else {
console.log('push没有任何数据');
}
});
可以实现
APP
中信息推送的功能,当然可以直接使用Notification API
,但是大部分情况,我们还是会配合Push Api
进行使用
Workbox资料
- https://blog.csdn.net/haosicx/article/details/132025426
- https://cloud.tencent.com/developer/article/2169293
- https://www.cnblogs.com/lcosima/p/12342611.html
- https://webpack.docschina.org/guides/progressive-web-application/
- https://www.jianshu.com/p/54cc04190252
- workbox-webpack-plugin