PWA学习总结

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控制该路径下的所有请求,如果请求路径不是在该路径之下,则请求不会被拦截。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 注册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生命周期图

在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跳过等待时期,从而直接进入下一个阶段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 删除过期缓存
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去监听这个事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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中

详细MDN文档

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 选项)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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文档

  • 在主页面中使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 先检查浏览器是否支持
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中使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 发送 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操作等等)

1
2
3
4
5
6
7
8
9
10
11
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 注册成功后该句柄才会存在。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 事件即可获得消息:

1
2
3
4
self.addEventListener('message', function(ev) {
console.log(ev.data);
});
  • Service Worker发送消息给页面
1
2
3
4
5
6
7
// self.clients.matchAll方法获取当前serviceWorker实例所接管的所有标签页,注意是当前实例 已经接管的
self.clients.matchAll().then(clientList => {
clientList.forEach(client => {
client.postMessage('Hi, I am send from Service worker!');
})
});

在主页面中监听

1
2
3
navigator.serviceWorker.addEventListener('message', event => {
console.log(event.data);
});

Client.postMessage

manifest

3 manifest.json 作用
PWA 添加至桌面的功能实现依赖于 manifest.json,也就是说如果要实现添加至主屏幕这个功能,就必须要有这个文件

1
2
3
4
5
6
7
8
9
10
11
12
{
  "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">

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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的图片对象数组。

详细配置

MDN详细配置

manifest验证

相关问题

  • 对于不同的资源,我们可能有不同的缓存策略,怎么方便的去实现这些复杂的场景

使用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

代码示例

pwa-study

pwa-webpack-study

参考资料

最后(欢迎大家关注我)

DJL箫氏个人博客

博客GitHub地址(欢迎star)

简书

掘金

个人公众号
个人公众号

文章目录
  1. 1. 相关知识
    1. 1.1. Service Worker
    2. 1.2. 注册Service Work
    3. 1.3. Service Worker生命周期
    4. 1.4. Service Worker 拦截请求
    5. 1.5. Cache API
    6. 1.6. Fetch API
    7. 1.7. Notification
    8. 1.8. message 事件
    9. 1.9. manifest
    10. 1.10. 相关问题
  2. 2. 代码示例
  3. 3. 参考资料
  4. 4. 最后(欢迎大家关注我)
|