本文中的SW经过一段时间测试应该是没有BUG了,不过我本人已经不再使用本文中编写的SW,故不再维护
对SW有兴趣的可以去看新版实现

更新内容

删除长时间未访问的缓存

运行时替换CDN链接

过期缓存采用超时策略

增强拓展性

支持204拦截网络请求

参考内容

本文参考了以下教程/文档,这些都是很不错的资源,读者可以自行参阅

  1. 店长写的Butterfly 主题的 PWA 实现方案
  2. service Worker API文档
  3. Cyfan写的欲善其事,必利其器 - 论如何善用ServiceWorker
  4. PWA 文档

教程

本文仅贴出本人使用的方案,其余方案请见店长写的教程

配置Json

  说到生成图标第一步肯定是想办法弄到图标,有条件的小伙伴可以找别人帮自己设计一个,没有的话就跟我一样用工具生成吧:

  这个网站生成的图标虽然下载要收费,但是并不妨碍我们截图

  接下来就是根据图标生成我们要的图标包了,我们可以使用这个网站:

  进入网站后点击那大大的Select your Favicon image按钮,然后选择你处理好的图标文件。

  如果你的图标不是正方形,网站会提醒你,如果满意网站的修复效果点击Continue with this picture即可,不满意的话自己修改完再上传就可以了。

  可能是我网络的问题,进入下一步的时候可能会出现白屏,刷新页面重新来一遍就行了。

  然后向下滚动,找到Favicon for Android Chrome栏目,点击里面的Assets选项卡,勾选Create all documented icons

Favicon for Android Chrome

  然后点击最下面的Generate your Favicons and HTML code,等待Download your package后面的按钮转好点击就嫩下载下来ZIP压缩包了。

  将压缩包内的要用到的图标统统放到博客网站中,具体放到哪看个人喜好,然后把site.webmanifest改名为manifest.json并放到source文件夹下。接下来修改manifest.json的内容,这里给出我的Json,其中namestart_url是必须项目,同时图标目录一定要改成自己的(我没有512x512大小的图标所以我把那一项删了,有的话留着就行):

  解释:

  1. name - 应用名称,用于安装横幅、启动画面显示
  2. short_name - 应用短名称,用于主屏幕显示
  3. description - 网站描述(目前我没发现在哪里派上用场了)
  4. theme_color - 主题颜色
  5. background_color - 背景色
  6. display - 显示方式:(fullscreen能占多少屏幕就占多少、standalone独立应用、minimal-ui带地址栏、browser和浏览器一样)
  7. scope - 作用域,保持默认即可,具体内容见参考资料
  8. start_url - 应用启动地址,保持默认即可,具体内容见参考资料
  9. icons - 图标
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
{
"lang": "en",
"name": "\u5c71\u5cb3\u5e93\u535a",
"short_name": "\u5c71\u5cb3\u5e93\u535a",
"description": "kmar.top",
"theme_color": "#242424",
"background_color": "#242424",
"display": "standalone",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "logo/android-chrome-36x36.png",
"sizes": "36x36",
"type": "image/png"
},
{
"src": "logo/android-chrome-48x48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "logo/android-chrome-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "logo/android-chrome-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "logo/android-chrome-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "logo/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "logo/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "logo/android-chrome-384x384.png",
"sizes": "384x384",
"type": "image/png"
}
]
}

修改配置文件

  修改主题配置文件,注意里面的文件路径要改成自己的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-  # pwa:
- # enable: false
- # manifest: /pwa/manifest.json
- # apple_touch_icon: /pwa/apple-touch-icon.png
- # favicon_32_32: /pwa/32.png
- # favicon_16_16: /pwa/16.png
- # mask_icon: /pwa/safari-pinned-tab.svg
+ pwa:
+ enable: true
+ manifest: /manifest.json
+ apple_touch_icon: /logo/apple-touch-icon.png
+ favicon_32_32: /logo/favicon-32x32.png
+ favicon_16_16: /logo/favicon-16x16.png
+ mask_icon: /logo/safari-pinned-tab.svg

编写SW文件

  接下来就是重中之重了:编写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
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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
/** 缓存库(数据)名称 */
const CACHE_NAME = 'kmarCache'
/** 缓存库(时间戳)名称 */
const VERSION_CACHE_NAME = 'kmarCacheTime'
/** 缓存离线超时时间 */
const MAX_ACCESS_CACHE_TIME = 60 * 60 * 24 * 10

function time() {
return new Date().getTime()
}

const dbHelper = {
read: (key) => {
return new Promise((resolve) => {
caches.match(key).then(function (res) {
if (!res) resolve(null)
res.text().then(text => resolve(text))
}).catch(() => {
resolve(null)
})
})
},
write: (key, value) => {
return new Promise((resolve, reject) => {
caches.open(VERSION_CACHE_NAME).then(function (cache) {
// noinspection JSIgnoredPromiseFromCall
cache.put(key, new Response(value));
resolve()
}).catch(() => {
reject()
})
})
},
delete: (key) => {
caches.match(key).then(response => {
if (response) caches.open(VERSION_CACHE_NAME).then(cache => cache.delete(key))
})
}
}

/** 存储缓存入库时间 */
const dbTime = {
read: (key) => dbHelper.read(new Request(`https://LOCALCACHE/${encodeURIComponent(key)}`)),
write: (key, value) => dbHelper.write(new Request(`https://LOCALCACHE/${encodeURIComponent(key)}`), value),
delete: (key) => dbHelper.delete(new Request(`https://LOCALCACHE/${encodeURIComponent(key)}`))
}

/** 存储缓存最后一次访问的时间 */
const dbAccess = {
update: (key) => dbHelper.write(new Request(`https://ACCESS-CACHE/${encodeURIComponent(key)}`), time()),
check: async (key) => {
const realKey = new Request(`https://ACCESS-CACHE/${encodeURIComponent(key)}`)
const value = await dbHelper.read(realKey)
if (value) {
dbHelper.delete(realKey)
return time() - value < MAX_ACCESS_CACHE_TIME
} else return false
}
}

self.addEventListener('install', () => self.skipWaiting())

/**
* 缓存列表
* @param url 匹配规则
* @param time 缓存有效时间
* @param clean 清理缓存时是否无视最终访问时间直接删除
*/

const cacheList = {
sample: {
url: /[填写正则表达式]/g,
time: Number.MAX_VALUE,
clean: true
}
}

/**
* 链接替换列表
* @param source 源链接
* @param dist 目标链接
*/

const replaceList = {
sample: {
source: ['//www.kmar.top'],
dist: '//kmar.top'
}
}

/** 判断指定url击中了哪一种缓存,都没有击中则返回null */
function findCache(url) {
for (let key in cacheList) {
const value = cacheList[key]
if (url.match(value.url)) return value
}
return null
}

/**
* 检查连接是否需要重定向至另外的链接,如果需要则返回新的Request,否则返回null<br/>
* 该函数会顺序匹配{@link replaceList}中的所有项目,即使已经有可用的替换项<br/>
* 故该函数允许重复替换,例如:<br/>
* 如果第一个匹配项把链接由"http://abc.com/"改为了"https://abc.com/"<br/>
* 此时第二个匹配项可以以此为基础继续进行修改,替换为"https://abc.net/"<br/>
*/

function replaceRequest(request) {
let url = request.url;
let flag = false
for (let key in replaceList) {
const value = replaceList[key]
for (let source of value.source) {
if (url.match(source)) {
url = url.replace(source, value.dist)
flag = true
}
}
}
return flag ? new Request(url) : null
}

/** 判断是否拦截指定的request */
function blockRequest(request) {
return false
}

async function fetchEvent(request, response, cacheDist) {
const NOW_TIME = time()
// noinspection ES6MissingAwait
dbAccess.update(request.url)
const maxTime = cacheDist.time
let remove = false
if (response) {
const time = await dbTime.read(request.url)
if (time) {
const difTime = NOW_TIME - time
if (difTime < maxTime) return response
}
remove = true
}
const fetchFunction = () => fetch(request).then(response => {
dbTime.write(request.url, NOW_TIME)
//如果加载正常再缓存
//至于为什么多了个检测与0相等是因为我实操的时候遇到了status为0的情况
//为什么会有0我没搞清楚,知道的小伙伴可以分享一下
if (response.ok || response.status === 0) {
const clone = response.clone()
caches.open(CACHE_NAME).then(cache => cache.put(request, clone))
}
return response
})
if (!remove) return fetchFunction()
const timeOut = () => new Promise((resolve => setTimeout(() => resolve(response), 400)))
return Promise.race([
timeOut(),
fetchFunction()]
).catch(err => console.error('不可达的链接:' + request.url + '\n错误信息:' + err))
}

self.addEventListener('fetch', async event => {
const replace = replaceRequest(event.request)
const request = replace === null ? event.request : replace
const cacheDist = findCache(request.url)
if (blockRequest(request)) {
event.respondWith(new Response(null, {status: 204}))
} else if (cacheDist !== null) {
event.respondWith(caches.match(request)
.then(async (response) => fetchEvent(request, response, cacheList))
)
} else if (replace !== null) {
event.respondWith(fetch(request))
}
})

  这里我把开了两个缓存空间,一个是kmarCache,一个是kmarCacheTime。前者是用来存储缓存内容的,后者是用来存储时间戳的。

注册SW

  请注意,因为安全问题,sw不允许以任何形式跨域加载,比如你的域名是kmar.top,那么你只能从kmar.top中加载sw.js,其余任何形式都是不允许的,包括使用IP地址代替域名。

  核心的注册代码很简单:

1
    navigator.serviceWorker.register('/sw.js')

  其中,register的参数就是sw.js的地址,对于我而言,合法的地址有:

  1. /sw.js
  2. https://kmar.top/sw.js

  其余任何形式的加载都是非法的,包括但不限于:

  1. http://kmar.top/sw.js #非法,因为sw仅允许通过https注册(本地127.0.0.1允许http)
  2. https://wulawula.com/sw.js #非法,跨域
  3. https://11.53.15.145/sw.js #即使11.53.15.145是我的博客的IP地址依然非法,被视为跨域

  同时需要注意的是,注册swsw并不会立即生效,只有在刷新页面后才会有效果。解决方案十分简单粗暴,即在安装成功后直接用代码刷新页面,这里我们就用到了jsthen

  现在我们找个地方把注册代码放进去就可以了,我是放在了[butterfly]/layout/includes/layout.pug的开头:

1
2
3
4
5
6
7
8
9
10
11
12
+  script.
+ (() => {
+ const sw = navigator.serviceWorker
+ const error = () => document.addEventListener('DOMContentLoaded',
+ () => btf.snackbarShow('当前浏览器不支持SW,建议更换浏览器以获取最佳体验~'))
+ if (!sw?.register('/sw.js')?.then(() => {
+ // 这里我是用的这种粗暴的方式检测SW是否安装成功
+ // 不过大佬说这么检测不太靠谱
+ // 保守的小伙伴可以用另一个方案
+ if (!sw.controller) location.reload()
+ })?.catch(error)) error()
+ })()
1
2
3
4
5
6
7
8
9
+  script.
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.register('/sw.js').then(() => {
+ if (localStorage.getItem('install') !== 'true') {
+ localStorage.setItem('install', 'true')
+ location.reload()
+ }
+ })
+ }

检测PWA

  浏览器打开网页,按F12,找到Lighthouse项目,勾选渐进式 Web 应用,点击生成报告就能判断PWA是否生效。

调试SW

  浏览器打开网页,按F12,找到应用程序项目,在里面就可以调试ServiceWorker

补充

  现在,ServiceWorker的缓存完全由代码控制,用户想要刷新缓存只能使用开发者工具删除缓存,这无疑是非常不友好的。

  这在另一篇教程中通过添加刷新缓存的按钮得到了解决:《给博客添加刷新缓存按钮》


创作不易,扫描下方打赏二维码支持一下吧ヾ(≧▽≦*)o