简单介绍
PWA(Progressive Web App)渐进式Web APP,它并不是单只某一项技术,而是一系列技术综合应用的结果,其中主要包含的相关技术就是Service Worker、Cache Api、Fetch Api、Push API、Notification API 和 postMessage API。使用PWA可以给我们带来什么好处呢?主要体现在如下几方面
1 离线缓存
2 web页面添加桌面快速入口
3 消息推送
相关知识
Service Worker
简单来说,Service Worker 是一个可编程的 Web Worker,它就像一个位于浏览器与网络之间的客户端代理,可以拦截、处理、响应流经的 HTTP 请求。它没有调用 DOM 和其他页面 api 的能力,但他可以拦截网络请求,包括页面切换,静态资源下载,ajax请求所引起的网络请求。Service Worker 是一个独立于JavaScript主线程的浏览器线程。Service Worker有如下特性:
- 必须在 HTTPS 环境下才能工作(在开发模式下http://localhost也可以工作)
- 不能直接操作 DOM,(但是可以通过postMessage发送某些信号,主进程根据信号类型,进行不同的操作)
- 一个独立的 worker 线程,独立于当前网页进程,有自己独立的 worker context。
- 运行于浏览器后台,可以控制打开的作用域范围下所有的页面请求
- Service Worker 必须要在主线中进行注册
- 一旦被 install,就永远存在,除非被手动 unregister
- 用到的时候可以直接唤醒,不用的时候自动睡眠
注册Service Work
我们需要在主线程中注册Service Worker,并且一般是在页面触发load事件之后进行注册。当Service Worker注册成功后便会进入其生命周期。scope代表Service Worker控制该路径下的所有请求,如果请求路径不是在该路径之下,则请求不会被拦截。
// 注册service worker
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);
});
});
Service Worker生命周期
Service Worker生命周期大致如下
install -> installed -> actvating -> Active -> Activated -> Redundant
在Service Worker注册成功之后就会触发install事件,在触发install事件后,我们就可以开始缓存一些静态资。waitUntil方法确保所有代码执行完毕后,Service Worker 才会完成Service Worker的安装。需要注意的是只有CACHE_LIST中的资源全部安装成功后,才会完成安装,否则失败,进入redundant
状态,所以这里的静态资源最好不要太多。如果 sw.js 文件的内容有改动,当访问网站页面时浏览器获取了新的文件,它会认为有更新,于是会安装新的文件并触发 install 事件。但是此时已经处于激活状态的旧的 Service Worker 还在运行,新的 Service Worker 完成安装后会进入 waiting 状态。直到所有已打开的页面都关闭,旧的 Service Worker 自动停止,新的 Service Worker 才会在接下来打开的页面里生效。为了能够让新的Service Worker及时生效,我们使用skipWaiting
直接使Service Worker跳过等待时期,从而直接进入下一个阶段。
const CACHE_NAME = 'cache_v' + 2;
const CACGE_LIST = [
'/',
'/index.html',
'/main.css',
'/app.js',
'/icon.png'
];
function preCache() {
// 安装成功后操作 CacheStorage 缓存,使用之前需要先通过 caches.open() 打开对应缓存空间。
return caches.open(CACHE_NAME).then(cache => {
// 通过 cache 缓存对象的 addAll 方法添加 precache 缓存
return cache.addAll(CACGE_LIST);
})
}
// 安装
self.addEventListener('install', function (event) {
// 等待promise执行完
event.waitUntil(
// 如果上一个serviceWorker不销毁 需要手动skipWaiting()
preCache().then(skipWaiting)
);
});
在安装成功后,便会触发activate
事件,在进入这个生命周期后,我们一般会删除掉之前已经过期的版本(因为默认情况下浏览器是不会自动删除过期的版本的),并更新客户端Service Worker(使用当前处于激活状态的Service Worker)。
// 删除过期缓存
function clearCache() {
return caches.keys().then(keys => {
return Promise.all(keys.map(key => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
}))
})
}
// 激活 activate 事件中通常做一些过期资源释放的工作
self.addEventListener('activate', function (e) {
e.waitUntil(
Promise.all([
clearCache(),
self.clients.claim()
])
);
});
在这里还有一个问题就是sw.js文件有可能会被浏览器缓存,所以我们一般需要设置sw.js不缓存或者较短的缓存时间
更多详细参考 如何优雅的为 PWA 注册 Service Worker
Service Worker 拦截请求
之前说过,Service Worker 是可以拦截请求的,那么一定就会存在一个拦截请求的事件fetch。我们需要在sw.js去监听这个事件。
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(function (response) {
// 如果 Service Worker 有自己的返回,就直接返回,减少一次 http 请求
if (response) {
console.log('cache 缓存', event.request.url, response);
return response;
} else {
if (navigator.online) {
return fetch(event.request).then(function(response) {
console.log('network', event.request.url, response);
// 由于响应是一个JavaScript或者HTML,会认为这个响应为一个流,而流是只能被消费一次的,所以只能被读一次
// 第二次就会报错 参考文章https://jakearchibald.com/2014/reading-responses/
cache.put(event.request, response.clone());
return response;
}).catch(function(error) {
console.error('请求失败', error);
throw error;
});
} else {
// 断网处理
offlineRequest(fetchRequest);
}
}
});
})
);
});
这里我们在fetch事件中监听请求事件,我们通过cache.match来进行请求的比较,如果存再这个请求的响应我们就直接返回缓存结果,否则就去请求。在这里我们通过cache.add来添加新的缓存,他实际上内部是包含了fetch请求过程的(注意:Cache.put, Cache.add和Cache.addAll只能在GET请求下使用)。在match的时候,需要请求的url和header都一致才是相同的资源,可以设定第二个参数ignoreVary:true。caches.match(event.request, {ignoreVary: true})
表示只要请求url相同就认为是同一个资源。另外需要提到一点,Fetch 请求默认是不附带 Cookies 等信息的,在请求静态资源上这没有问题,而且节省了网络请求大小。但对于动态页面,则可能会因为请求缺失 Cookies 而存在问题。此时可以给 Fetch 请求设置第二个参数。示例:fetch(fetchRequest, { credentials: 'include' } );
Cache API
Cache API 不仅在Service Worker中可以使用,在主页面中也可以使用。我们通过 caches.open(cacheName)来打开一个缓存空间,在,默认情况下,如果我们不手动去清除这个缓存空间,这个缓存会一直存在,不会过期。在使用Cache API之前,我们都需要通过caches.open先去打开这个缓存空间,然后在使用相应的Cache方法。这里有几个注意点:
- Cache.put, Cache.add和Cache.addAll只能在GET请求下使用
- 自Chrome 46版本起,Cache API只保存安全来源的请求,即那些通过HTTPS服务的请求。
- Cache API不支持HTTP缓存头
在使用cache.add和cache.addAll的时候,是先根据url获取到相应的response,然后再添加到缓存中。过程类似于调用 fetch(), 然后使用 Cache.put() 将response添加到cache中
Fetch API
Fetch API不仅可以在主线程中进行使用,也可以在Service Worker中进行使用。fetch 和 XMLHttpRequest有两种方式不同:
当接收到一个代表错误的 HTTP 状态码时,从 fetch()返回的 Promise 不会被标记为 reject, 即使该 HTTP 响应的状态码是 404 或 500。相反,它会将 Promise 状态标记为 resolve (但是会将 resolve 的返回值的 ok 属性设置为 false ),仅当网络故障时或请求被阻止时,才会标记为 reject。
默认情况下,fetch 不会从服务端发送或接收任何 cookies, 如果站点依赖于用户 session,则会导致未经认证的请求(要发送 cookies,必须设置 credentials 选项)
// Example POST method implementation:
postData('http://example.com/answer', {answer: 42})
.then(data => console.log(data)) // JSON from `response.json()` call
.catch(error => console.error(error))
function postData(url, data) {
// Default options are marked with *
return fetch(url, {
body: JSON.stringify(data), // must match 'Content-Type' header
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
credentials: 'same-origin', // include(始终携带), same-origin(同源携带cookie), omit(始终不携带)
headers: {
'user-agent': 'Mozilla/4.0 MDN Example',
'content-type': 'application/json'
},
method: 'POST', // *GET, POST, PUT, DELETE, etc.
mode: 'cors', // no-cors, cors, *same-origin
redirect: 'follow', // manual, *follow, error
referrer: 'no-referrer', // *client, no-referrer
})
.then(response => response.json()) // parses response to JSON
}
更多信息请查阅:使用 Fetch
Notification
Notification API 用来进行浏览器通知,当用户允许时,浏览器就可以弹出通知。这个API在主页面和Service Worker中都可以使用,MDN文档
- 在主页面中使用
// 先检查浏览器是否支持
if (!("Notification" in window)) {
alert("This browser does not support desktop notification");
}
// 检查用户是否同意接受通知
else if (Notification.permission === "granted") {
// If it's okay let's create a notification
new Notification(title, {
body: desc,
icon: '/icon.png',
requireInteraction: true
});
}
// 否则我们需要向用户获取权限
else if (Notification.permission !== 'denied') {
Notification.requestPermission(function (permission) {
// 如果用户同意,就可以向他们发送通知
if (permission === "granted") {
new Notification(title, {
body: desc,
icon: '/icon.png',
requireInteraction: true
});
} else {
console.warn('用户拒绝通知');
}
});
}
- 在Service Worker中使用
// 发送 Notification 通知
function sendNotify(title, options={}, event) {
if (Notification.permission !== 'granted') {
console.log('Not granted Notification permission.');
// 通过post一个message信号量,来在主页面中询问用户获取页面通知权限
postMessage({
type: 'applyNotify'
})
} else {
// 在Service Worker 中 触发一条通知
self.registration.showNotification(title || 'Hi:', Object.assign({
body: '这是一个通知示例',
icon: '/icon.png',
requireInteraction: true
}, options));
}
}
我们可以看见当我们在Service Worker中进行消息提示时,用户可能关闭了消息提示的功能,所以我们首先要再次询问用户是否开启消息提示的功能,但是在Service Worker中是不能够直接询问用户的,我们必须要在主页面中去询问,这个时候我们可以通过postMessage去发送一个信号量,根据这个信号量的类型,来做响应的处理(例如:询问消息提示的权限,DOM操作等等)
function postMessage(data) {
self.clients.matchAll().then(clientList => {
clientList.forEach(client => {
// 当前打开的标签页发送消息
if (client.visibilityState === 'visible') {
client.postMessage(data);
}
})
})
}
在这里我们只向打开的标签页发送该信号量,避免重复询问
message 事件
由于Service Worker是一个单独的浏览器线程,与JavaScript主线程互不干扰,但是我们还是可以通过postMessage实现通信,而且可以通过post特定的消息,从而让主线程去进行相应的DOM操作,实现间接操作DOM的方式。
-
页面发送消息给Service Worker
在页面上通过 navigator.serviceWorker.controller 获得 ServiceWorker 的句柄。但只有 ServiceWorker 注册成功后该句柄才会存在。
function sendMsg(msg) {
const controller = navigator.serviceWorker.controller;
if (!controller) {
return;
}
controller.postMessage(msg, []);
}
// 在 serviceWorker 注册成功后,页面上即可通过 navigator.serviceWorker.controller 发送消息给它
navigator.serviceWorker
.register('/test/sw.js', {scope: '/test/'})
.then(registration => console.log('ServiceWorker 注册成功!作用域为: ', registration.scope))
.then(() => sendMsg('hello sw!'))
.catch(err => console.log('ServiceWorker 注册失败: ', err));
在 ServiceWorker 内部,可以通过监听 message 事件即可获得消息:
self.addEventListener('message', function(ev) {
console.log(ev.data);
});
- Service Worker发送消息给页面
// self.clients.matchAll方法获取当前serviceWorker实例所接管的所有标签页,注意是当前实例 已经接管的
self.clients.matchAll().then(clientList => {
clientList.forEach(client => {
client.postMessage('Hi, I am send from Service worker!');
})
});
在主页面中监听
navigator.serviceWorker.addEventListener('message', event => {
console.log(event.data);
});
manifest
3 manifest.json 作用
PWA 添加至桌面的功能实现依赖于 manifest.json,也就是说如果要实现添加至主屏幕这个功能,就必须要有这个文件
{
"short_name": "短名称",
"name": "这是一个完整名称",
"icons": [
{
"src": "icon.png",
"type": "image/png",
"sizes": "144x144"
}
],
"start_url": "index.html"
}
<link rel="manifest" href="path-to-manifest/manifest.json">
name —— 网页显示给用户的完整名称
short_name —— 当空间不足以显示全名时的网站缩写名称
description —— 关于网站的详细描述
start_url —— 网页的初始 相对 URL(比如 /)
scope —— 导航范围。比如,/app/的scope就限制 app 在这个文件夹里。
background-color —— 启动屏和浏览器的背景颜色
theme_color —— 网站的主题颜色,一般都与背景颜色相同,它可以影响网站的显示
orientation —— 首选的显示方向:any, natural, landscape, landscape-primary, landscape-secondary, portrait, portrait-primary, 和 portrait-secondary。
display —— 首选的显示方式:fullscreen, standalone(看起来像是native app),minimal-ui(有简化的浏览器控制选项) 和 browser(常规的浏览器 tab)
icons —— 定义了 src URL, sizes和type的图片对象数组。
相关问题
- 对于不同的资源,我们可能有不同的缓存策略,怎么方便的去实现这些复杂的场景
使用workbox,如果使用webpack进行项目打包,我们可以使用workbox-webpack-plugin插件
- 为什么不适用其他的本地缓存方案
Web Storage(例如 LocalStorage 和 SessionStorage)是同步的,不支持网页工作线程,并对大小和类型(仅限字符串)进行限制。 Cookie 具有自身的用途,但它们是同步的,缺少网页工作线程支持,同时对大小进行限制。WebSQL 不具有广泛的浏览器支持,因此不建议使用它。File System API 在 Chrome 以外的任意浏览器上都不受支持。目前正在 File and Directory Entries API 和 File API 规范中改进 File API,但该 API 还不够成熟也未完全标准化,因此无法被广泛采用。
同步的问题 就是负担大,如果有大量请求缓存在本地缓存中,如果是同步,可能负担重
- 在将相应存在cache中并返回给浏览器报错
resulted in a network error response: a Response whose "body" is locked cannot be used to respond to a request
这是因为在使用put的时候,是流的一个pipe操作,流是只能被消费一次的。我们可以clone这个response或者reques参考文章
- 在经过webpack打包后,所有的静态资源都会带有hash值,怎么办
使用某些webpack插件,例如offline-plugin或者webpack-workbox-plugin
代码示例
参考资料
最后(欢迎大家关注我)
博客GitHub地址(欢迎star)