将你的博客升级为 PWA 渐进式Web离线应用

什么是 PWA

PWA 全称 Progressive Web Apps(渐进式 Web 应用程序),旨在使用现有的 Web 技术提供用户更优的使用体验。
基本要求

  • 可靠(Reliable) 一方面是指 PWA 的安全性,PWA 只能运行在 HTTPS 上;另一方面是指在网络不稳定或者没网情况下,PWA 依然可以访问。
  • 快速响应(Fast) 快速响应用户的交互行为,并且具有平滑流畅的动画、加载速度、渲染速度和渲染性能等。
  • 粘性(Engaging) 通过添加到桌面以及离线消息推送,能带来用户的第二次访问,并且依靠良好的用户体验吸引用户再次访问。

官网链接:Progressive Web Apps

PWA 的核心技术

PWA 不是一项单独的技术,技术包括 ManifestService WorkerPush API & Notification APIApp Shell & App Skeleton 等等技术,接下来我们重点介绍几项技术以及相关问题的解决方法。

manifest

manifest 是支持站点在主屏上创建图标的技术方案,并且定制 PWA 的启动画面的图标和颜色等,如下图:


chrome > 桌面图标 > 启动样式 > 打开效果

manifest 内容

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
35
36
37
38
39
40
41
42
43
44
45
46
47
{
"name": "梦魇小栈-专注于分享",
"short_name": "梦魇小栈",
"description": "心,若没有栖息的地方,到哪里都是流浪......",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#8a00f9",
"icons": [
{
"src": "images/icons/icon_32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "images/icons/icon_72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "images/icons/icon_128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "images/icons/icon_144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "images/icons/icon_192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "images/icons/icon_256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "images/icons/icon_512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

manifest 属性

  • name — 网页显示给用户的完整名称;
  • short_name — 这是为了在没有足够空间显示 Web 应用程序的全名时使用;
  • description — 关于网站的详细描述;
  • start_url — 网页的初始相对 URL 比如 /
  • display — 应用程序的首选显示模式;
    • fullscreen - 全屏显示;
    • standalone - 应用程序将看起来像一个独立的应用程序;
    • minimal-ui - 应用程序将看起来像一个独立的应用程序,但会有浏览器地址栏;
    • browser - 该应用程序在传统的浏览器标签或新窗口中打开.
  • orientation — 应用程序的首选显示方向;
    • any
    • natural
    • landscape
    • landscape-primary
    • landscape-secondary
    • portrait
    • portrait-primary
    • portrait-secondary
  • background_color — 启动屏的背景颜色;
  • theme_color — 网站的主题颜色;
  • icons — 定义了 srcsizestype 的图片对象数组,各种环境中用作应用程序图标的图像对象数组.

MDN 提供了完整的 manifest 属性列表: Web App Manifest properties

manifest 使用

manifest 功能虽然强大,但是技术上并不难,就是一个外链的 json 文件,通过 link 来引入:

1
2
<!-- 在 html 页面中添加以下 link 标签 -->
<link rel="manifest" href="/manifest.json" />

manifest 验证

在开发者工具中的 Application Tab 左边有 Manifest 选项,你可以验证你的 manifest JSON 文件,并提供了 “Add to homescreen” .

Service Worker

Service WorkerPWA 中最重要的概念之一,它是一个特殊的 Web Worker,独立于浏览器的主线程运行,特殊在它可以拦截用户的网络请求,并且操作缓存,还支持 Push 和后台同步等功能。

注册服务

install Service Worker 之前,要在主进程 JavaScript 代码里面注册它,注册是为了告诉浏览器我们的 Service Worker 文件是哪个,然后在后台,Service Worker 就开始安装激活。

1
2
3
4
5
6
7
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").then(() => {
console.log("注册成功!");
});
});
}

注册时,还可以指定可选参数 scope,scope 是 Service Worker 可以以访问到的作用域,或者说是目录。

1
2
3
navigator.serviceWorker.register("/sw.js", {
scope: "/app/",
});

注册成功后,您可以通过转至 chrome://inspect/#service-workers 并寻找您的网站来检查 Service Worker 是否已启用。

安装 Service Worker 服务

install 事件绑定在 Service Worker 文件中,当安装成功后,install 事件就会被触发。
一般我们会在 install 事件里面进行缓存的处理,用到之前提到的 Cahce API,它是一个 Service Worker 上的全局对象,可以缓存网络相应的资源,并根据他们的请求生成 key,这个 API 和浏览器标准的缓存工作原理相似,但是只是针对自己的 scope 域的,缓存会一直存在,知道手动清楚或者刷新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const cacheName = "bs-0-0-1"; // 缓存名称
const cacheFiles = ["/", "/favicon.ico", "/images/icons/icon_32.png", "..."]; // 需缓存的文件

// 监听 install 事件,安装完成后,进行文件缓存
self.addEventListener("install", (e) => {
e.waitUntil(
caches
.open(cacheName)
.then((cache) => {
console.log("Opened cache");
return cache.addAll(cacheFiles);
})
.then(() => self.skipWaiting())
);
});

// e.waitUntil 确保 Service Worker 不会在 e.waitUntil() 执行完成之前安装完成。
// caches.open(cacheName) 创建一个 cacheName 的新缓存,返回一个缓存的 promise 对象,当它 resolved 时候,我们在 then 方法里面用 caches.addAll 来添加想要缓存的列表,列表是一个数组,里面的 URL 是相对于 origin 的。
// self.skipWaiting() 跳过 waiting 状态,下面更新第3条~

更新 Service Worker 服务

当你的 Service Worker 需要更新时, 需要经过以下步骤

  1. 更新您的服务工作线程 JavaScript 文件。 用户导航至您的站点时,浏览器会尝试在后台重新下载定义 Service Worker 的脚本文件。 如果 Service Worker 文件与其当前所用文件存在字节差异,则将其视为新 Service Worker
  2. Service Worker 将会启动,且将会触发 install 事件。
  3. 此时,旧 Service Worker 仍控制着当前页面,因此新 Service Worker 将进入 waiting 状态。
  4. 当网站上打开的页面关闭时,旧 Service Worker 将会被终止,新 Service Worker 将会取得控制权。
  5. Service Worker 取得控制权后,将会触发其 activate 事件。

如果希望在有了新版本时,所有的页面都得到及时自动更新怎么办呢?可以在 install 事件中执行 self.skipWaiting() 方法跳过 waiting 状态,然后会直接进入 activate 阶段。接着在 activate 事件发生时,通过执行 self.clients.claim() 方法,更新所有客户端上的 Service Worker。

Service Worker 安装完成后并进入激活状态,会触发 activate 事件。通过监听 activate 事件你可以做一些预处理,如对旧版本的更新、对无用缓存的清理等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 监听 activate 事件,激活后通过cache的key来判断是否更新、删除 cache 中的静态资源
self.addEventListener("activate", (e) => {
console.log("sw: activate");

e.waitUntil(
caches
.keys()
.then((keys) => {
return Promise.all(
keys.map((key) => {
if (key !== cacheName && key !== apiCacheName) {
return caches.delete(key);
}
})
);
})
.then(() => self.clients.claim()) // 更新客户端
);
});

处理动态请求缓存

Service Worker 的作用域中,当有网络请求时发生时,fetch 事件将被触发。它调用 respondWith() 方法来劫持网络请求缓存并返回:

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
35
36
37
38
39
40
41
var apiCacheName = "api-0-1-1";
self.addEventListener("fetch", (e) => {
var currentUrl = e.request.url;

// 只处理同源
if (new URL(currentUrl).hostname != location.hostname) {
return;
}

// 需要缓存的 xhr 请求
var cacheRequestUrls = ["/message.json", "/manifest.json"];

// 判断当前请求是否需要缓存
var needCache = cacheRequestUrls.includes(new URL(currentUrl).pathname);

if (needCache) {
// 需要缓存
// 使用 fetch 请求数据,并将请求结果 clone 一份缓存到 cache
// 此部分缓存后在 browser 中使用全局变量 caches 获取
caches.open(apiCacheName).then((cache) => {
return fetch(e.request).then((response) => {
cache.put(e.request.url, response.clone());
return response;
});
});
} else {
// 不需要缓存,直接查询 cache
// 如果有 cache 则直接返回,否则通过 fetch 请求
e.respondWith(
caches
.match(e.request)
.then((cache) => {
return cache || fetch(e.request);
})
.catch((err) => {
console.log("respondWithErr:", err);
return fetch(e.request);
})
);
}
});

到这里,离线缓存动静态资源就完成了。

使用 Lighthouse 测试我们的应用

至此,我们完成了 PWA 的两大基本功能:Web App ManifestService Worker 的离线缓存。这两大功能可以很好地提升用户体验与应用性能。我们用 Chrome 中的 Lighthouse 来检测一下目前的应用:

可以看到,在 PWA 评分上,我们的这个 WebApp 已经非常不错了。

完整代码 -> 梦魇小栈 PWA 完整代码

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
var cacheName = "bs-0-0-2";
var apiCacheName = "api-0-0-2";

var cacheFiles = [
"/",
"/favicon.ico?v=6.2.0",
"/css/main.css?v=6.2.0",
"/js/src/set.js",
"/js/src/utils.js",
"/js/src/motion.js",
"/js/src/bootstrap.js",
"/images/cursor.ico",
"/images/icons/icon_32.png",
"/images/icons/icon_72.png",
"/images/icons/icon_128.png",
"/images/icons/icon_192.png",
"/images/icons/icon_256.png",
"/images/icons/icon_512.png",
];

// 监听 install 事件,安装完成后,进行文件缓存
self.addEventListener("install", (e) => {
console.log("sw: install");

e.waitUntil(
caches
.open(cacheName)
.then((cache) => {
console.log("Opened cache");
return cache.addAll(cacheFiles);
})
.then(() => self.skipWaiting())
);
});

// 监听 activate 事件,激活后通过 cache 的 key 来判断是否更新 cache 中的静态资源
self.addEventListener("activate", (e) => {
console.log("sw: activate");

e.waitUntil(
caches
.keys()
.then((keys) => {
return Promise.all(
keys.map((key) => {
if (key !== cacheName && key !== apiCacheName) {
return caches.delete(key);
}
})
);
})
// 更新客户端
.then(() => self.clients.claim())
);
});

self.addEventListener("fetch", (e) => {
var currentUrl = e.request.url;

// 只处理同源
if (new URL(currentUrl).hostname != location.hostname) {
return;
}

// 需要缓存的 xhr 请求
var cacheRequestUrls = ["/message.json", "/manifest.json"];

// 判断当前请求是否需要缓存
var needCache = cacheRequestUrls.includes(new URL(currentUrl).pathname);

if (needCache) {
// 需要缓存
// 使用 fetch 请求数据,并将请求结果 clone 一份缓存到 cache
// 此部分缓存后在 browser 中使用全局变量 caches 获取
caches.open(apiCacheName).then((cache) => {
return fetch(e.request).then((response) => {
cache.put(e.request.url, response.clone());
return response;
});
});
} else {
// 不需要缓存,直接查询 cache
// 如果有 cache 则直接返回,否则通过 fetch 请求
e.respondWith(
caches
.match(new URL(currentUrl).pathname)
.then((cache) => {
return cache || fetch(e.request);
})
.catch((err) => {
console.log("respondWithErr:", err);
return fetch(e.request);
})
);
}
});

由于现在博客仅需 ManifestService Worker 后面的技术、Push API & Notification APIApp Shell & App Skeleton 等打算以后有时间在考虑场景加上~