pwa
Progressive Web App, 简称 PWA,是提升 Web App 的体验的一种新方法,能给用户原生应用的体验
PWA 能做到原生应用的体验不是靠特指某一项技术,而是经过应用一些新技术进行改进,在安全、性能和体验三个方面都有很大提升,PWA 本质上是 Web App,借助一些新技术也具备了 Native App 的一些特性,兼具 Web App 和 Native App 的优点。
PWA 的主要特点包括下面三点:
- 可靠 - 即使在不稳定的网络环境下,也能瞬间加载并展现
- 体验 - 快速响应,并且有平滑的动画响应用户的操作
- 粘性 - 像设备上的原生应用,具有沉浸式的用户体验,用户可以添加到桌面
来源: https://lavas.baidu.com/pwa/README
离线和缓存
Service Worker
W3C 组织早在 2014 年 5 月就提出过 Service Worker 这样的一个 HTML5 API ,主要用来做持久的离线缓存
Service Worker 有以下功能和特性:
一个独立的 worker 线程,独立于当前网页进程,有自己独立的 worker context。
一旦被 install,就永远存在,除非被手动 unregister
用到的时候可以直接唤醒,不用的时候自动睡眠
可编程拦截代理请求和返回,缓存文件,缓存的文件可以被网页进程取到(包括网络离线状态)
离线内容开发者可控
能向客户端推送消息
不能直接操作 DOM
必须在 HTTPS 环境下才能工作
异步实现,内部大都是通过 Promise 实现
使用 前提条件
Service Worker 出于安全性和其实现原理,在使用的时候有一定的前提条件:
由于 Service Worker 要求 HTTPS 的环境,我们通常可以借助于 github page 进行学习调试。当然一般浏览器允许调试 Service Worker 的时候 host 为 localhost 或者 127.0.0.1 也是 ok 的。
Service Worker 的缓存机制是依赖 Cache API 实现的
依赖 HTML5 fetch API
依赖 Promise 实现
注册
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js', {scope: '/'})
.then(function (registration) {
// 注册成功
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(function (err) {
// 注册失败:(
console.log('ServiceWorker registration failed: ', err);
});
});
}
register 方法的 scope 参数是可选的,用于指定你想让 Service Worker 控制的内容的子目录。
Service Worker 线程将接收 scope 指定网域目录上所有事项的 fetch 事件,如果我们的 Service Worker 的 javaScript 文件在 /a/b/sw.js, 不传 scope 值的情况下, scope 的值就是 /a/b。
scope 的值的意义在于,如果 scope 的值为 /a/b, 那么 Service Worker 线程只能捕获到 path 为 /a/b 开头的( /a/b/page1, /a/b/page2,...)页面的 fetch 事件。通过 scope 的意义我们也能看出 Service Worker 不是服务单个页面的,所以在 Service Worker 的 js 逻辑中全局变量需要慎用
安装
- Service Worker 注册成功之后呢,我们的浏览器中已经有了一个属于你自己 web App 的 worker context,此时,浏览器就会马不停蹄的尝试为你的站点里面的页面安装并激活它,并且在这里可以把静态资源的缓存给办了
// 监听 service worker 的 install 事件
this.addEventListener('install', function (event) {
// 如果监听到了 service worker 已经安装成功的话,就会调用 event.waitUntil 回调函数
event.waitUntil(
// 安装成功后操作 CacheStorage 缓存,使用之前需要先通过 caches.open() 打开对应缓存空间。
caches.open('my-test-cache-v1').then(function (cache) {
// 通过 cache 缓存对象的 addAll 方法添加 precache 缓存
return cache.addAll([
'/',
'/index.html',
'/main.css',
'/main.js',
'/image.jpg'
]);
})
);
});
- ExtendableEvent.waitUntil() 方法——这会确保 Service Worker 不会在 waitUntil() 里面的代码执行完毕之前安装完成
- 在 waitUntil() 内,我们使用了 caches.open() 方法来创建了一个叫做 v1 的新的缓存,将会是我们的站点资源缓存的第一个版本。它返回了一个创建缓存的 promise,当它 resolved 的时候,我们接着会调用在创建的缓存实例(Cache API)上的一个方法 addAll(),这个方法的参数是一个由一组相对于 origin 的 URL 组成的数组,这些 URL 就是你想缓存的资源的列表
自定义请求响应
- 每次任何被 Service Worker 控制的资源被请求到时,都会触发 fetch 事件,这些资源包括了指定的 scope 内的 html 文档,和这些 html 文档内引用的其他任何资源(比如 index.html 发起了一个跨域的请求来嵌入一个图片,这个也会通过 Service Worker),这下 Service Worker 代理服务器的形象开始慢慢露出来了,而这个代理服务器的钩子就是凭借 scope 和 fetch 事件两大利器就能把站点的请求管理的井井有条
this.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
// 来来来,代理可以搞一些代理的事情
// 如果 Service Worker 有自己的返回,就直接返回,减少一次 http 请求
if (response) {
return response;
}
// 如果 service worker 没有返回,那就得直接请求真实远程服务
var request = event.request.clone(); // 把原始请求拷过来
return fetch(request).then(function (httpRes) {
// http请求的返回已被抓到,可以处置了。
// 请求失败了,直接返回失败的结果就好了。。
if (!httpRes || httpRes.status !== 200) {
return httpRes;
}
// 请求成功的话,将请求缓存起来。
var responseClone = httpRes.clone();
caches.open('my-test-cache-v1').then(function (cache) {
cache.put(event.request, responseClone);
});
return httpRes;
});
})
);
});
我们可以在 install 的时候进行静态资源缓存,也可以通过 fetch 事件处理回调来代理页面请求从而实现动态资源缓存
两种方式可以比较一下
on install 的优点是第二次访问即可离线,缺点是需要将需要缓存的 URL 在编译时插入到脚本中,增加代码量和降低可维护性;
on fetch 的优点是无需更改编译过程,也不会产生额外的流量,缺点是需要多一次访问才能离线可用。(第二次请求页面才缓存)
自动更新所有页面
- install 事件中执行 self.skipWaiting() 方法跳过 waiting 状态,然后会直接进入 activate 阶段。接着在 activate 事件发生时,通过执行 self.clients.claim() 方法,更新所有客户端上的 Service Worker
// 安装阶段跳过等待,直接进入 active
self.addEventListener('install', function (event) {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', function (event) {
event.waitUntil(
Promise.all([
// 更新客户端
self.clients.claim(),
// 清理旧版本
caches.keys().then(function (cacheList) {
return Promise.all(
cacheList.map(function (cacheName) {
if (cacheName !== 'my-test-cache-v1') {
return caches.delete(cacheName);
}
})
);
})
])
);
});
手动更新 Service Worker
- 其实在页面中,也可以手动借助 Registration.update() 更新。
var version = '1.0.1';
navigator.serviceWorker.register('/sw.js').then(function (reg) {
if (localStorage.getItem('sw_version') !== version) {
reg.update().then(function () {
localStorage.setItem('sw_version', version)
});
}
});
如何工作
首先我们需要在页面的 JavaScript 主线程中使用 serviceWorkerContainer.register() 来注册 Service Worker ,在注册的过程中,浏览器会在后台启动尝试 Service Worker 的安装步骤。
如果注册成功,Service Worker 在 ServiceWorkerGlobalScope 环境中运行; 这是一个特殊的 worker context,与主脚本的运行线程相独立,同时也没有访问 DOM 的能力。
后台开始安装步骤, 通常在安装的过程中需要缓存一些静态资源。如果所有的资源成功缓存则安装成功,如果有任何静态资源缓存失败则安装失败,在这里失败的不要紧,会自动继续安装直到安装成功,如果安装不成功无法进行下一步 — 激活 Service Worker。
开始激活 Service Worker,必须要在 Service Worker 安装成功之后,才能开始激活步骤,当 Service Worker 安装完成后,会接收到一个激活事件(activate event)。激活事件的处理函数中,主要操作是清理旧版本的 Service Worker 脚本中使用资源。
激活成功后 Service Worker 可以控制页面了,但是只针对在成功注册了 Service Worker 后打开的页面。也就是说,页面打开时有没有 Service Worker,决定了接下来页面的生命周期内受不受 Service Worker 控制。所以,只有当页面刷新后,之前不受 Service Worker 控制的页面才有可能被控制起来
生命周期 https://lavas.baidu.com/pwa/offline-and-cache-loading/service-worker/service-worker-lifecycle
添加到主屏幕 manifest.json
- 允许将站点添加至主屏幕,是 PWA 提供的一项重要功能。虽然目前部分浏览器已经支持向主屏幕添加网页快捷方式以方便用户快速打开站点,但是 PWA 添加到主屏幕的不仅仅是一个网页快捷方式,它将提供更多的功能,让 PWA 具有更加原生的体验。
manifest.json
{
"short_name": "短名称",
"name": "这是一个完整名称",
"icons": [
{
"src": "icon.png",
"type": "image/png",
"sizes": "48x48"
}
],
"start_url": "index.html"
}
index.html
<link rel="manifest" href="path-to-manifest/manifest.json">
<!-- safari 添加到主屏幕 图标 -->
<link rel="apple-touch-icon-precomposed" href="icon.jpg">
详细介绍 manifest.json 字段
- name: {string} 应用名称,用于安装横幅、启动画面显示
- short_name: {string} 应用短名称,用于主屏幕显示
- icons: {Array.
<ImageObject>
} 应用图标列表(浏览器会根据有效图标的 sizes 字段进行选择。首先寻找与显示密度相匹配并且尺寸调整到 48dp 屏幕密度的图标;如果未找到任何图标,则会查找与设备特性匹配度最高的图标;如果匹配到的图标路径错误,将会显示浏览器默认 icon。)- src {string} 图标 url
- type {string=} 图标的 mime 类型,非必填项,该字段可让浏览器快速忽略掉不支持的图标类型
- sizes {string} 图标尺寸,格式为 width x height,宽高数值以 css 的 px 为单位。如果需要填写多个尺寸,则使用空格进行间隔,如"48x48 96x96 128x128"
- start_url: {string=} 应用启动地址
- scope: {string} 作用域
- background_color: {Color} css 色值
- display : {string} 显示类型 属性去指定 PWA 从主屏幕点击启动后的显示类型
- fullscreen 应用的显示界面将占满整个屏幕
- standalone 浏览器相关 UI(如导航栏、工具栏等)将会被隐藏
- minimal-ui 显示形式与 standalone 类似,浏览器相关 UI 会最小化为一个按钮
- browser 浏览器模式,与普通网页在浏览器中打开的显示一致
- orientation: string 应用显示方向
- theme_color: {Color} css 色值 可以指定 PWA 的主题颜色
引导用户添加应用至主屏幕
打开浏览器菜单,会看到添加到主屏幕的功能,用户可以点击该选项手动将 PWA 站点添加至主屏幕
弹出应用安装横幅 条件
- 站点部署 manifest.json,该文件需配置如下属性
- short_name (用于主屏幕显示)
- name (用于安装横幅显示)
- icons (其中必须包含一个 mime 类型为 image/png 的图标声明)
- start_url (应用启动地址)
- display (必须为 standalone 或 fullscreen)
- 站点注册 Service Worker。
- 站点支持 HTTPS 访问。
- 站点在同一浏览器中被访问至少两次,两次访问间隔至少为 5 分钟。
注意这些事件接口仍处于 Working Draft 阶段,仅有部分浏览器支持。这也就意味着,即使支持弹出安装横幅的浏览器,也不一定支持应用安装横幅事件。想要进行完整的功能体验,建议使用 Chrome Beta for Android 浏览器进行测试。
消息推送介绍
- 目前整体支持度并不高,在手机端更是只有安卓 Chrome57 支持。
授权
window.addEventListener('load', () => {
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;
}
let promiseChain = new Promise((resolve, reject) => {
const permissionPromise = Notification.requestPermission(result => {
resolve(result);
});
if (permissionPromise) {
permissionPromise.then(resolve);
}
})
.then(result => {
if (result === 'granted') {
execute();
}
else {
console.log('no permission');
}
});
});
- 值得注意的是,当用户允许或者拒绝授权后,后续都不会重复询问。 想要更改这个设置,在 Chrome 地址栏左侧网站信息中如下
注册 service worker
function registerServiceWorker() {
return navigator.serviceWorker.register('service-worker.js')
.then(registration => {
console.log('Service worker successfully registered.');
return registration;
})
.catch(err => {
console.error('Unable to register service worker.', err);
});
}
使用 showNotification 方法弹出通知
function execute() {
registerServiceWorker().then(registration => {
registration.showNotification('Hello World!');
});
}
showNotification 参数
- title - 必填 字符串类型 表示通知的标题
- options - 选填 对象类型 集合众多配置项,可用项如下:
{
// 视觉相关
"body": "<String>",
"icon": "<URL String>",
"image": "<URL String>",
"badge": "<URL String>",
"vibrate": "<Array of Integers>",
"sound": "<URL String>",
"dir": "<String of 'auto' | 'ltr' | 'rtl'>",
// 行为相关
"tag": "<String>",
"data": "<Anything>",
"requireInteraction": "<boolean>",
"renotify": "<Boolean>",
"silent": "<Boolean>",
// 视觉行为均会影响
"actions": "<Array of Strings>",
// 定时发送时间戳
"timestamp": "<Long>"
}
- 通知关闭事件
self.addEventListener('notificationclose', event => {
let dismissedNotification = event.notification;
let promiseChain = notificationCloseAnalytics();
event.waitUntil(promiseChain);
});
- 通知事件的数据传递
index.html
registration.showNotification('Notification With Data', {
body: 'This notification has data attached to it that is printed to the console when it\'s clicked.',
data: {
time: (new Date()).toString(),
message: 'Hello World!'
}
});
sw.js
self.addEventListener('notificationclick', event => {
const notificationData = event.notification.data;
console.log('The data notification had the following parameters:');
Object.keys(notificationData).forEach(key => {
console.log(` ${key}: ${notificationData[key]}`);
});
})
- 打开页面
let examplePage = '/demos/notification-examples/example-page.html';
let promiseChain = clients.openWindow(examplePage);
event.waitUntil(promiseChain);
- 向页面发送信息
sw.js
clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage('向页面发送的信息')
})
})
index.html
navigator.serviceWorker.addEventListener('message', event => {
console.log('Received a message from service worker: ', event.data);
});
post Meassage
http://craig-russell.co.uk/2016/01/29/service-worker-messaging.html#.XGZK3VUzaUl
为什么 pwa 没有发展起来
https://baijiahao.baidu.com/s?id=1612919514973793701&wfr=spider&for=pc
后面是完整代码
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello PWA</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<link rel="stylesheet" href="main.css">
<link rel="manifest" href="manifest.json">
<!-- safari 添加到主屏幕 图标 -->
<link rel="apple-touch-icon-precomposed" href="icon.jpg">
</head>
<body>
<h3>Hello PWA</h3>
<p>dsjflskdjfl</p>
</body>
<script>
// 检测浏览器是否支持SW
/* if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('./sw.js')
.then(function(registration) {
// 注册成功
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(function(err) {
// 注册失败:(
console.log('ServiceWorker registration failed: ', err);
});
});
} */
</script>
<script>
window.addEventListener('load', () => {
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;
}
let promiseChain = new Promise((resolve, reject) => {
// 在订阅之前先获取用户授权,使用Notification.requestPermission
const permissionPromise = Notification.requestPermission(result => {
resolve(result);
});
if (permissionPromise) {
permissionPromise.then(resolve);
}
})
.then(result => {
// 授权通过
if (result === 'granted') {
execute();
} else {
console.log('no permission');
}
});
// 注册 service worker ,获取注册对象
function registerServiceWorker() {
return navigator.serviceWorker.register('./sw.js')
.then(registration => {
console.log('Service worker successfully registered.');
return registration;
})
.catch(err => {
console.error('Unable to register service worker.', err);
});
}
// 使用 showNotification 方法弹出通知
function execute() {
registerServiceWorker().then(registration => {
registration.showNotification('短标题4', {
body: "我是bodya,老长老长了",
icon: "./icon.jpg",
// badge: './icon.jpg', // 手机通知消息缩略图
// dir:'auto',// 文字方向 auto|ltr|rtr
// vibrate:[],// 震动 以数组的形式进行配置,其中的数字以2个为一组,分别表示震动的毫秒数,和不震动的毫秒数,如此往复
// sound:'',// 声音
image: "./image.jpg",
// timestamp: Date.parse('2019/02/15 15:22:00'), // 时间戳
tag: 'message-group-1', // 两个相同ID的通知会被归类到一起
renotify: true, // tag 一同使用的 替换通知时提示声音或者震动
requireInteraction: true, // 显式的让通知一直显示直到用户交互,
data: {
time: (new Date()).toString(),
message: 'Hello World!'
},
actions: [{
action: 'coffee-action',
title: 'Coffee',
icon: './icon.jpg'
}, {
action: 'doughnut-action',
title: 'Doughnut',
icon: './icon.jpg'
}]
});
});
}
navigator.serviceWorker.addEventListener('message', function(event) {
console.log("Client 1 Received Message: " + event.data);
console.log("Client ports: " + event.ports);
// window.location.href = "https://www.baidu.com"
// 如果 event.ports存在,就可以通过端口传递数据到 sw.js中
// event.ports[0].postMessage("Client 1 Says 'Hello back!'");
});
});
</script>
</html>
main.css
h3{
color: #f00;
}
sw.js
var cacheStorageKey = 'test-pwa-4'
var cacheList = [
'/pwa',
'/pwa/index.html',
'/pwa/main.css',
'/pwa/manifest.json',
'/pwa/icon.jpg'
]
self.addEventListener('install', e => {
// 如果监听到了 service worker 已经安装成功的话,就会调用 event.waitUntil 回调函数
e.waitUntil(
// 安装成功后操作 CacheStorage 缓存,使用之前需要先通过 caches.open() 打开对应缓存空间。
caches.open(cacheStorageKey)
// 通过 cache 缓存对象的 addAll 方法添加 precache 缓存
// .then(cache => cache.addAll(cacheList))
.then(cache => {
return cache.addAll(cacheList);
})
.then(() => self.skipWaiting())
)
})
self.addEventListener('fetch', function(e) {
console.log(e.request)
e.respondWith(
caches.match(e.request).then(function(response) {
if (response) {
return response;
}
// 如果 service worker 没有返回,那就得直接请求真实远程服务
var request = e.request.clone(); // 把原始请求拷过来
return fetch(request).then(function(httpRes) {
// http请求的返回已被抓到,可以处置了。
// 请求失败了,直接返回失败的结果就好了。。
if (!httpRes || httpRes.status !== 200) {
return httpRes;
}
// 请求成功的话,将请求缓存起来。
var responseClone = httpRes.clone();
caches.open(cacheStorageKey).then(function(cache) {
cache.put(e.request, responseClone);
});
return httpRes;
});
})
)
})
self.addEventListener('activate', function(e) {
e.waitUntil(
//获取所有cache名称
caches.keys().then(cacheNames => {
return Promise.all(
// 获取所有不同于当前版本名称cache下的内容
cacheNames.filter(cacheNames => {
return cacheNames !== cacheStorageKey
}).map(cacheNames => {
return caches.delete(cacheNames)
})
)
}).then(() => {
return self.clients.claim()
})
)
})
function doSome() {
console.log(33333334)
}
// 点击通知
self.addEventListener('notificationclick', event => {
// 获取传递data数据
const notificationData = event.notification.data;
console.log('The data notification had the following parameters:');
Object.keys(notificationData).forEach(key => {
console.log(` ${key}: ${notificationData[key]}`);
});
let clickedNotification = event.notification;
// 关闭通知
clickedNotification.close();
// 执行某些异步操作,等待它完成
let promiseChain = doSome();
event.waitUntil(promiseChain);
// 点击按钮
if (!event.action) {
// 没有点击在按钮上
console.log('没有点击在按钮上');
return;
}
switch (event.action) {
case 'coffee-action':
console.log('User \'s coffee.');
send_message_to_all_clients('666666666666666666')
break;
case 'doughnut-action':
console.log('User \'s doughnuts.');
// 再次弹窗
self.registration.showNotification('Had to show a notification.');
// 打开新页面
clients.openWindow("https://taobao.com");
break;
default:
console.log(`Unknown action clicked: '${event.action}'`);
break;
}
});
function send_message_to_all_clients(msg) {
clients.matchAll().then(clients => {
clients.forEach(client => {
console.log(clients, client)
send_message_to_client(client, msg).then(m => console.log("SW Received Message: " + m));
})
})
}
function send_message_to_client(client, msg) {
return new Promise(function(resolve, reject) {
var msg_chan = new MessageChannel();
msg_chan.port1.onmessage = function(event) {
if (event.data.error) {
reject(event.data.error);
} else {
resolve(event.data);
}
};
// 发送给页面操作dom元素 (把posrt2发送给index.html。方便回传数据)
client.postMessage("SW Says: '" + msg + "'", [msg_chan.port2]);
// client.postMessage('给客户端发送数据'); //如果仅仅发送数据,不要回传数据这个就可以了
});
}
manifest.json
{
"name": "一个PWA示例",
"short_name": "PWA示例",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#fff",
"theme_color": "#3eaf7c",
"icons": [{
"src": "./icon.jpg",
"sizes": "120x120",
"type": "image/jpg"
}]
}
icon.jpg(120*120) image.jpg 自己随便找两张图片就可以了