本教程基于hexo-swpp@2.4.2编写

请注意:如果版本号a.b.c中的ab发生变化,请务必重新阅读一遍文档!!!

请务必使用最新的稳定版本!旧版本很可能包含某些功能漏洞!!!

若版本号后方带有beta.*字样,请勿使用!

介绍

  该插件用于在 hexo 中自动构建可用的 ServiceWorker,插件内自带了一个缺省的 sw.js,也支持使用自定义的 sw。

  本插件的特性:

  • 本地缓存
  • 增量更新
  • Request 篡改
  • CDN 竞速
  • 备用 URL
  • 208 阻塞响应
  • 兼容 MoOx-Pjax
  • 高度自由

  我的另一篇博文已经介绍了这个 SW 的工作原理,具体介绍不再赘述,详情见:

如果你的网站使用了 CDN 且启用了 CDN 端缓存,请务必将 CDN 缓存时间调整至最大值,然后每次更新网页内容后手动刷新 CDN 缓存。

因为本插件的更新方案要求update.json更新时,其它所有需要更新的资源均已更新,否则客户端拉取时会误以为拉取到了最新的内容,从而导致部分资源“错过”更新。

简而言之,就是update.json必须与需要缓存的资源共享同样的 CDN 缓存周期,但是目前市面上我知道的 CDN 无法做到这一点,所以只能从下列选项中二选一

  • 把所有资源的 CDN 缓存时间拉满,每次更新网站时刷新 CDN 缓存
  • CDN 不缓存所有需要在客户端缓存的资源

Netlify 构建后自动刷新 CDN 缓存的教程见:《全自动博客部署方案》

请务必注意 CDN 缓存的问题!!!

  更新日志见:GitHub Commits

教程

  首先需要安装插件(参数使用--save--save-dev均可):

1
npm install hexo-swpp --save

  然后添加配置项,下面列出的是插件支持的所有配置项,非必填项可省略不写:

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
swpp:
enable: true
sw:
# 是否使用自定义的 sw,为 true 时不自动生成 sw.js,但是仍然会插入注册 sw 的代码
# 注意:不支持自定义 sw.js 的路径及文件名,sw.js 必须放置在 source_dir 中
custom: false
# 注册 sw 发生错误时触发的 js 代码,如果包含多个指令需使用花括号({})包裹
# 注意:SW 注册代码将直接内嵌在 HTML 首部,该代码执行时其它 JS 不一定完成了加载
onerror: "{console.error('注册 sw 时发生错误,很可能是由于您的浏览器不支持 sw')}"
# 注册 sw 成功后触发的 js 代码,如果包含多个指令需使用花括号({})包裹
# 注意:SW 注册代码将直接内嵌在 HTML 首部,该代码执行时其它 JS 不一定完成了加载
onsuccess: "location.reload()"
# 缓存库名称,缺省为 kmarBlogCache
# 发布网站后请勿修改该配置项,无特殊需求建议保持缺省
cacheName: 'kmarBlogCache'
# 逃生门,详情见:https://kmar.top/posts/73014407/#cdf686f0
escape: 0
# 是否启用 CDN 竞速,详情见:https://kmar.top/posts/73014407/#4acf3000
# 该项与 spareUrl 冲突,若两项同时开启,则该项生效
cdnRacing: false
# 是否启用备用 URL,详情见:https://kmar.top/posts/73014407/#62889c40
# 该项与 cdnRacing 冲突,若两项同时开启,则 cdnRacing 生效
spareUrl: false
# 是否启用调试,启用后会在 sw 中插入一些辅助调试的代码,不建议开启
debug: false
dom:
# 是否使用自定义的 DOM 端 JS,为 true 时不会自动生成 sw-dom.js,且不会插入引入 JS 的代码
# 当值为 true 时本分类下其余设置项均无效
custom: false
# 当更新成功(更新完毕自动刷新页面)后触发,包含多个指令不需要用花括号包裹
onsuccess: "console.log('更新成功')"
json:
# 最大 HTML 数量,超过这个数量后会直接清除所有 HTML 缓存
maxHtml: 15
# update.json 的最大字符数量
# 超过后会移除旧的版本号,直到满足要求,如果只有全部清空才能满足就会直接刷新所有缓存
charLimit: 1024
# 文件缓存匹配采取精确模式
# 关闭时更新缓存时仅匹配文件名称,如 https://kmar.top/simple/a/index.html 仅匹配 /a/index.html
# 开启后更新缓存时将会匹配完整名称,如 https://kmar.top/simple/a/index.html 将匹配 /simple/a/index.html
# 两种方式各有优劣,开启后会增加 update.json 的空间占用,但会提升精确度
# 如果网站内没有多级目录结构,就可以放心大胆的关闭了
# key 值为文件拓展名,default 用于指代所有未列出的拓展名以及没有拓展名的文件
precisionMode:
default: false
# 是否合并指定项目
# 例如当 tags 为 true 时(假设标签目录为 https://kmar.top/tags/...)
# 如果标签页存在更新,则直接匹配 https://kmar.top/tags/ 目录下的所有文件
# 推荐将此项开启
merge:
tags: true
archives: true
categories: true
index: true
# 自定义
custom:
# 这里填写目录名称列表(不带两边的斜杠)
# 如果按下面这样写,当 `//[domain]/bilibili/` 下的任意文件更新,都会合并为同一个更新项目
- 'bilibili'
# 忽略哪些文件,正则表达式,不写两边的斜杠,不区分大小写
# 注:匹配的时候不附带域名,只有 pathname
exclude:
# 这里写正则表达式,格式如下:
- sw\.js$
# 外部文件更新监听
# 开启后会捕获外部文件的更新,具体原理见:https://kmar.top/posts/73014407/#771b3e00
external:
enable: false
# 自定义拉取网络文件时的超时时间,缺省 1500
timeout: 1500
# 见 https://kmar.top/posts/73014407/#771b3e00
js:
- head: '这里写 head 内容'
tail: '这里写 tail 内容(必须有 tail)'
# 某些外链只要 URL 不变其内容就一定不会变
# 可以通过正则表达式排除这些外链的文件内容监控,加快构建速度
# 下面是几个常用的 CDN 的匹配
# 正则表达式不用写两边的斜杠,区分大小写
skip:
- ^(https?:\/\/|\/\/)(cdn|fastly)\.jsdelivr\.net\/npm\/.*@\d+\.\d+\.\d+\/
- ^(https?:\/\/|\/\/)cdn\d\.tianli0\.top\/.*@\d+\.\d+\.\d+\/
- ^(https?:\/\/|\/\/)cdn\.staticfile\.org\/.*\/\d+\.\d+\.\d+\/
- ^(https?:\/\/|\/\/)lf\d+-cdn-tos\.bytecdntp\.com\/.*\/\d+\.\d+\.\d+\/
- ^(https?:\/\/|\/\/)npm\.elemecdn\.com\/.*@\d+\.\d+\.\d+\/
# 在构建过程中替换部分链接,该替换结果不会影响文件内容
# 该设置项是为了应对构建服务器在国外,但是网站内部分缓存资源无法在国外访问导致拉取时 timeout 的问题
# 填写格式和替换规则见 https://kmar.top/posts/73014407/#9dde3600
replace:
- source:
- 'rules0'
- 'rules1'
dist: 'url'
# 对 Hexo 中的变量进行排序
# 默认插件对 posts、tags、categories、pages 四个变量进行排序
# 排序规则为优先按照字符串长度排序,若长度一致按照字典序排序
sort:
# 格式为 `name: value`
# value 的可能值为:字符串、非负整数、false
# 假定 Array<obj> 为要被排序的数据
# 当 value 为字符串和非负整数时,插件会以 `obj[value]` 的格式读取关键字
# 当 value 为 false 时,插件会直接以 `obj` 为关键字
# 注意:关键字必须为含有 length 属性且支持 < 操作符的类型
# 插件内置的 posts 规则如果用上面的格式写应该为:
# posts: 'title'
# 插件支持使用配置项覆盖插件内置规则
# 下面是一个示例
keywords: false

  下面列出的配置项为必填项,未列出的为可选项:

1
2
3
4
5
6
7
swpp:
enable: true
sw:
onerror: "{console.error('注册 sw 时发生错误,很可能是由于您的浏览器不支持 sw')}"
onsuccess: "location.reload()"
dom:
onsuccess: "console.log('更新成功')" # 若 custom 为 false 则该项必填

  下面列出的配置项为包含缺省值的配置项及其缺省值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
swpp:
enable: false
sw:
custom: false
cacheName: 'kmarBlogCache'
escape: 0
cdnRacing: false
spareUrl: false
debug: false
dom:
custom: false
json:
maxHtml: 15
charLimit: 1024
precisionMode:
default: false
merge:
tags: true
archives: true
categories: true
index: true
external:
enable: false
timeout: 1500

  接着还需要在博客根目录创建一个 JS 文件——sw-rules.js,该文件内需按照下面的格式填写缓存规则以及修改函数:

sw-rules.js必须要创建,且其中必须包含cacheList,就算对象内容都是空的也要写上,不然无法构建!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 这个文件中所有“module.exports.”都会被替换为“const ”
// 请勿在非声明的位置使用这个字符串,否则会被替换掉

/**
* 缓存列表
* @param clean 清理全站时是否删除其缓存
* @param match {function(URL)} 匹配规则
*/
module.exports.cacheList = {
// 这个 [simple] 就是规则的名称,该对象下可以包含多个规则,名称不影响缓存匹配
// 缓存匹配时按声明顺序进行匹配
simple: {
// [clean] 项用于声明符合该规则的缓存在进行全局清理时是否清除
// 如果你无法确定是否需要声明为 false 的话写 true 即可
clean: true,
// 该项用于匹配缓存,传入的参数是 URL 类型的,返回一个 boolean
match: url => url.host === 'kmar.top' && url.pathname.match(/\.(woff2|woff|ttf|cur)$/)
}
}

  插件会在生成网站的过程中自动生成 sw.js 和 sw-dom.js 并插入注册代码,所以本地预览时支持对 sw 和 sw-dom.js 进行调试。而 json 文件则需要通过hexo swpp指令手动生成,在执行hexo g && hexo swpp后启用本地预览时 sw.js 可以访问到update.json,如果没有提前生成发布目录并运行指令,本地预览时浏览器后台报404错误属于正常现象,无需担心。(一般不建议在本地中启用本地文件的缓存,不方便本地调试。)

  请在发布网站及压缩文件前执行hexo swpp,否则可能会因为压缩导致文件md5频繁变动。如果你的博客需要使用gulp一类的脚本替换一部分链接,请在替换链接后执行hexo swpp,在替换之前执行只能监听到替换前的链接。

  请注意:在缓存数据时不会处理hash和参数,所以如果请求内容会根据 URL 参数不同而不同的话请勿缓存,否则会导致拉取到错误的内容!!!

逃生门

  该配置我们可以称之为“逃生门”。这个配置后应填写一个整数(缺省为 0),在 sw 检测到用户没有加载过这个版本时,就会强制刷新用户所有缓存。这个选项主要是提供给想要自定义 DOM 端 JS 的用户使用的,因为缓存的更新是需要 DOM 端发送更新请求到 SW 端的,所以有些时候因为 DOM 端代码有误会导致永远无法触发缓存更新,这时候就可以通过逃生门强制更新缓存。

  填写逃生门时,随意填写一个未填写过的正整数即可,不填或填0表示不启用逃生门。

  2.4.1(不包括) 版本前的逃生门的功能存在缺陷,在 DOM 端无法发送update信息到 SW 端时无法触发逃生门,2.4.1版本开始修正了该问题,现在无论 DOM 端是否发送信息 SW 端都能正常触发逃生门。

  注意:如果你是从 1.4.0 及更早的版本更新上来的用户,请务必填写 escape,因为老版本插件中生成的 DOM 端 JS 无法正常执行!!!

Request 篡改

  如果你需要在发起网络请求前修改请求头的信息,直接在sw-rules.js中添加modifyRequest函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 修改 Request
* @param request {Request} 原始 Request
* @return {Request} 修改后的 Request,不修改的话返回 null 或不返回数据
*/
module.exports.modifyRequest = request => {
// 下面是一个示例
// 如果不需要该功能,可直接删除该函数
const url = request.url
const source = '/gh/EmptyDreams/resources/icon'
if (url.includes(source)) {
return new Request(url.replace(source, '/gh/EmptyDreams/twikoo-emoji'), request)
}
}

CDN 竞速

  CDN 竞速开启后会同时向多个 URL 发起网络请求,使用速度最快的一个 URL 返回的数据。开启该功能后,将增加客户端的网络和 CPU 压力,请酌情开启。

  想要启用 CDN 竞速,需要在配置中将cdnRacing设置为true,并在sw-rules.js中添加如下语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 获取一个 URL 对应的多个 CDN 的 URL
*
* 竞速时除 URL 外的所有参数均保持一致
*
* @param srcUrl 原始 URL
* @return {string[]} URL数组,需要包含原始 URL,不包含则表示去除原始 URL 的访问,返回 null 或不返回数据表示该 URL 不启用竞速
*/
module.exports.getCdnList = srcUrl => {
if (srcUrl.startsWith('https://npm.elemecdn.com')) {
const url = new URL(srcUrl)
return [
srcUrl,
`https://cdn.jsdelivr.net/npm` + url.pathname,
`https://cdn1.tianli0.top/npm` + url.pathname,
`https://fastly.jsdelivr.net/npm` + url.pathname
]
}
}

  这个函数体只是给出了一个样例,读者可自行编写。

备用 URL

  备用 URL 和 CDN 竞速有异曲同工之妙,不过备用 URL 是在上一个链接下载时长超过指定时间后再发起对下一个 URL 的访问。在发起新的访问时不会中断对上一个 URL 的访问,只有在某一个访问成功拉取内容后才会中断对其它 URL 的访问。

  如果需要开启此功能,需要在配置项中将spareUrl设置为true。开启时,建议将速度最快、受众群体最大的 URL 放在开头,否则会严重影响加载速度。

  下面给出一个代码样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 获取一个 URL 对应的备用 URL 列表,访问顺序按列表顺序,所有 URL 访问时参数一致
* @param srcUrl {string} 原始 URL
* @return {{list: string[], timeout: number}} 返回 null 或不返回表示对该 URL 不启用该功能。timeout 为超时时间(ms),list 为 URL 列表,列表不包含原始 URL 表示去除原始访问
*/
module.exports.getSpareUrls = srcUrl => {
if (srcUrl.startsWith('https://npm.elemecdn.com')) {
return {
timeout: 3000,
list: [
srcUrl,
`https://cdn.jsdelivr.net/npm${new URL(srcUrl).pathname}`
]
}
}
}

阻塞响应

  有时候,我们可能希望屏蔽一些 URL 的访问,这时候可以通过直接返回 208 来阻塞这些 URL。

  注意:当 URL 被屏蔽后,拉取文件的过程中并不会抛出异常,如果拉取文件的代码没有做错误处理,在处理文件内容时可能会报错。

  在sw-rules.js中添加如下代码即可启用阻塞响应功能:

1
2
3
4
5
6
7
8
/**
* 判断是否阻塞响应
* @param url {URL} URL
* @return {boolean} true 表示阻塞,false 表示不阻塞
*/
modelus.exports.blockRequest = url => {
// ...
}

URL 美化问题

  注意,本插件不支持部分 URL 开启 URL 美化,部分 URL 关闭 URL 美化。如果部分 URL 开启了美化,且你的缓存规则同时匹配了开启了美化的 URL 和未开启美化的 URL,则会导致部分 URL 的缓存无法正常更新!

  插件通过hexo内的pretty_urls设置项判断网站是否启用 URL 美化,请务必保证部署到网站的效果和配置项中的一致。

  如果你无法做到统一 URL 风格,请不要缓存与配置项风格不一样的 URL!

外部文件监听

  插件支持监听一下文件内引入的外部文件的更新:

  • html 文档中使用scriptlink标签引入的 URL
  • css 文件及 html 中内嵌的 css 中使用url(...)语法引入的 URL(排除注释)
  • js 文件中符合预定要求的 URL

  当插件尝试监听 URL 时,如果 URL 没有附带通讯协议,会默认使用http协议而非https协议。

  html 和 css 内 URL 的监听就不再多说,我们重点说 js 文件的监听。从上面配置文件的示例可以看到监听 js 内 URL 的格式如下:

1
2
3
4
5
js:
- head: #...
tail: #...
- head: #...
tail: #...

  headtail项的值都为字符串,字符串的内容会直接内嵌到正则表达式中,所以如果你使用了一些特殊字符,记得使用反斜线,否则无法正常匹配。

  每一对headtail都会被拼接为如下格式的正则表达式:

1
new RegExp(`${head}(['"\`])(.*?)\\1${tail}`, 'mg')

  该正则表达式的作用是匹配被headtail包裹起来的字符串(不忽略注释),同时会向head后和tail前添加一个引号(通用匹配三种引号)

  注意,插件不支持拼接字符串,同时如果你使用模板字符串,也不支持使用${},如果被检查到字符串中包含三种引号中任意一种或多种或者$符号,都会被视为非法 URL 跳过监听。

  遇到非法 URL 时会自动跳过,不用担心正则表达式匹配到不是 URL 的内容。如果你是 butterfly 主题的用户,可以使用如下配置来捕获使用getScript()函数引入的文件:

1
2
3
js:
- head: 'getScript\('
tail: `\)'

replace

  如果部署服务器在国外,但是网站中部分链接只有国内才能访问,可以选择取消对这些 URL 的监控或者使用 replace 配置项将 URL 改为国外可访问的 URL。

  替换时会把source中写出的字符串替换为dist中的内容,且一次替换完成后不会终止,会一直完成整个列表的匹配。

  替换 URL 的代码如下:

1
2
3
4
5
6
7
8
9
10
function replaceDevRequest(url) {
for (let value of external.replace) {
for (let source of value.source) {
if (url.match(source)) {
url = url.replace(source, value.dist)
}
}
}
return url
}

手动更新缓存

  插件捕获外部文件更新的能力有限,如果部分文件无法使用插件自动捕获更新,则需要在更新时手动添加。

  当需要手动向版本文件添加信息时在 hexo 根目录中创建update.json即可,格式如下:

1
2
3
4
5
6
{
"global": false,
"all": false,
"change": [
]
}

  其中:

  • global用于标记常驻缓存是否发生更新
  • all用于标记是否清除所有缓存(如果global没变就不会清除常驻缓存)
  • change中填写你要刷新的缓存,具体格式下面再描述,如果all标记为了truechange写不写都没有区别

  change的格式为{"flag": [name], value: [args]},其中flag为规则名称,value为匹配值,value可以为数组也可以为单个值。

  目前支持以下几种规则:

flagvalue描述示例
html清除所有 html 缓存{"flag": "html"}
file文件名称(带拓展名)清除指定文件的缓存(如果不同目录下有重名文件并且想要准确清理的话需要带上部分路径名){"flag": "file", "value": ["main.js", "/css/index.css"]}
str任意字符串清除所有完整路径中包含指定字符串的缓存{"flag": "str", "value": "fonts"}
reg正则表达式(不带两边的斜杠,不区分大小写)清除所有完整路径与指定正则表达式存在匹配的缓存{"flag": "reg", "value": "\.js$"}

运行机制及避坑指南

  插件内置的 SW 实现增量更新的方式是在网站内生成了一个描述各个版本差异的文件(下称作“版本文件”),更新时通过读取这个文件来计算需要更新哪些内容,插件的主要工作就是在生成网站时自动创建这个版本文件。

  如果你使用1.3.0之前的版本(请务必更新到最新版本),需要注意下面这种情况:

  假如我们缓存了https://[npm]/hexo-swpp@1.0.2/index.js,然后在本站中将链接的版本号替换为了1.1.0,这时候插件是不能检测到需要删除index.js这个文件的缓存的。客户端访问网页时虽然可以正确地访问到1.1.0index.js,但是1.0.2版本的 JS 仍然存在于缓存之中,但是永远不会被使用,这时候需要手动输入更新,告诉插件这个缓存需要删除。

  当然新版本该问题也没有彻底解决,只是很大程度上的缓解了该问题,如果你是用插件无法监听到的方式引入的文件,仍然需要手动告知插件更新缓存。

  那么插件是如何实现监听文件更新的呢?答案就是通过计算并存储每个文件的md5

  插件会扫描配置文件中设置的输出目录(一般为public)下的所有文件以及能够扫描到的你引入的文件,并从中找出需要缓存的文件,计算并存储它们的md5值,然后存储在public/cacheList.json当中。

  那么插件是如何获取上一次生成的结果呢?为了兼容netlifygithub action等非本地的自动构建方案,插件没有在本地存储cacheList.json,而是直接输出到public目录中。在下一次构建网站时会使用node-fetch从网络拉取cacheList.json。所以一定要保证在构建网站时网站可以被访问(请求的时候会仿造refererUser-Agent),否则拉取文件时会失败。

  第一次构建网站的时候拉取文件的时候会出现404警告,这是正常现象直接无视即可,但是第二次构建如果仍然出现404则需要检查是否是哪里出了问题。

  这里还有一个优化生成性能的小技巧,cacheList.json中会存储所有满足缓存条件的文件信息,但是可能并不是所有文件都会被用户访问和缓存。比如sw.js可能就满足你的缓存规则,但是sw.js本身并不会走 sw,在exclude中排除这些文件/文件夹可以优化生成性能(对用户无影响)。

  同时注意maxHtml不宜调的过大,charLimit不宜过小,否则有可能会导致当你修改的 html 数量很多时一条更新信息就超过了charLimit的限制,直接清掉了全站缓存,得不偿失。

  反过来,charLimit也不宜过大,否则当版本号积累后会使update.json文件体积过大,从而拖慢客户端更新速度,还会加大服务器负担。

  另外还需要注意,虽然插件内对 hexo 内置的全局变量进行了排序,但是部分主题会在页面内添加一些随机的数据(不修改网页内容连续构建得出不同的结果),这些数据会导致插件认为该页面需要刷新缓存。

  此时需要用户手动解决这个问题,比如butterfly主题会在页面内插入时间戳来实现过期文章提示的功能,我们可以选择修改主题源代码,将文章过期提示的功能移除(深度修改也可以),或者将插入时间戳的页面放入exclude列表中,然后需要更新时手动在update.json文件中写出更新条目。

QA

  • Q:逃生门必须填吗?
    A:该选项不是必填项。
      如果你是从1.4.1前的版本升级上来的,则必须填写逃生门;
      如果你想要自定义 DOM-JS,则在 DOM-JS 出现不可逆的故障时需要填写逃生门;
      逃生门留空或不设置默认为 0。
  • Q:如何控制缓存大小?
    A:当前 SW 不支持通过代码控制缓存大小,需要自行通过缓存规则控制缓存规模。
  • Q:插件执行过程中 NodeJs 提示语法错误是怎么回事?
    A:版本过低,升级 NodeJs 版本即可,推荐升级到 STL 版本。
  • Q:如何在线上查看 sw 的工作情况?
    A:开发者工具的“应用程序”和“网络”都可以查看相关信息。
  • Q:能否向自动生成的 sw 中插入一些代码?
    A:目前不支持自定义代码插入位点,但是sw-rules.js中的代码会一同被插入到 sw 中,插入的位置为代码中调用require的位置。

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