本文不会讲述PWA的内容,PWA内容请参考:《基于Butterfly的PWA适配》。
博主写了一个 hexo 插件,有兴趣的小伙伴可以前往查看:《小白也能用的 SW 构建插件》。
前置知识
解读我的SW的实现之前肯定要先知道一些基本的知识。
SW代理
首先我们要知道SW代理网络请求是怎么回事,SW代理网络请求的实质就是在浏览器真的发起网络请求之前对这次请求做一些处理,我们能做的处理包含但不限于:
- 重定向请求(比如原本请求的是
https://.../a.jpg
,我们可以把它改成https://.../b.png
) - 修改请求头
- ……
实际上,SW代理的本质就是让我们自己返回一个资源回去。所以不论我们通过什么手段,只需要能够返回资源就可以了(实际上啥都不返回也没问题,不过控制台会有警告)。
了解了这个原理,我们就能很轻易地想到一个SW经常用的功能:客户端缓存控制。在发起网络请求的时候,如果本地已经存在其对应的结果,那么我们直接把本地的缓存内容返回给浏览器就可以了,这是减轻服务器压力、提高客户端体验的一种最直接的方案。
注册SW
请注意,因为安全问题,sw
不允许以任何形式跨域加载,比如你的域名是kmar.top
,那么你只能从kmar.top
中加载sw.js
,其余任何形式都是不允许的,包括使用IP地址代替域名。
核心的注册代码很简单:
1 | navigator.serviceWorker.register('/sw.js')
|
其中,register
的参数就是sw.js
的地址,对于我而言,合法的地址有:
/sw.js
https://kmar.top/sw.js
其余任何形式的加载都是非法的,包括但不限于:
http://kmar.top/sw.js
#非法,因为sw仅允许通过https注册(本地127.0.0.1
允许http)https://wulawula.com/sw.js
#非法,跨域https://11.53.15.145/sw.js
#即使11.53.15.145
是我的博客的IP地址依然非法,被视为跨域
同时需要注意的是,注册sw
后sw
并不会立即生效,只有在刷新页面后才会有效果。解决方案十分简单粗暴,即在安装成功后直接用代码刷新页面,这里我们就用到了js
的then
。
现在我们找个地方把注册代码放进去就可以了,我是放在了[butterfly]/layout/includes/layout.pug
的开头:
1 2 3 4 5 6 7 8 9 10 11 | + 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() + } + }) + }
|
异步程序
整个SW并不和浏览器页面(DOM)工作在同一个线程中,所以SW中不能使用和DOM同步的接口(包括但不限于:localStorage
、sessionStorage
)。同时为了避免阻塞线程,我们也应该尽量采用异步的方式编写SW。
我们这里使用的异步实现为Promise
,我们简单说明一下这个API的用法,想要了解更多的小伙伴可以自行百度。
1 2 3 4 5 6 7 8 9 10 11 12 | function simple() { return new Promise((resolve, reject) => { }) }
simple().then((args) => { }).catch((args) => { })
|
上面的代码就是Promise
的一个简单样例,我们声明了一个函数,其返回一个Promise
,Promise
的构造函数接收两个参数:resolve
和reject
(可以省略)。
其中resolve
是用来标记代码执行成功的,用法为resolve(args)
,传进去的参数我们后面再说。相反,reject
就是用来标记代码执行错误,用法为reject(args)
。
当我们创建Promise
后,他就会执行构造函数中传入的函数,当resolve
执行时就会触发Promise
尾巴上带的then
,当reject
执行时就会触发catch
。(一个Promise
可以带多个then
和catch
。)
我们发现,不论是resolve
和then
,reject
和catch
,他们都有一个参数表,实际上,传进resolve
的参数就是传给then
的参数,reject
同理。(参数也可以为空,即resolve()
。)
fetchAPI
fetchAPI
和XMLHttpRequest
类似,都是用于发起网络请求,获取网络资源的接口,不过fetchAPI
提供了更强大、更灵活的功能,也更容易上手。
fetchAPI
是一个异步接口,其也是使用了Promise
,具体内容我不再赘述,只说明基本用法:
1 2 3 4 5 6 | fetch(new Request(url)).then(response => { }).catch(err => { })
|
参考内容
功能实现
资源重定向
有些时候,我们不方便或者没办法在本地直接修改链接,我们就可以使用SW动态的把链接替换掉:
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 | const replaceList = { gh: { source: ['//cdn.jsdelivr.net/gh'], dist: '//cdn1.tianli0.top/gh' }, npm: { source: ['//cdn.jsdelivr.net/npm', '//unpkg.zhimg.com'], dist: '//npm.elemecdn.com' }, emoji: { source: ['/gh/EmptyDreams/resources/icon'], dist: '/gh/EmptyDreams/twikoo-emoji' } }
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 }
self.addEventListener('fetch', event => { const replace = replaceRequest(event.request) if (!replace) return event.respondWith(fetch(replace)) })
|
请求拦截
在有些时候,我们不希望一些网络请求被发出,我们就可以使用SW拦截这些请求。
1 2 3 4 5 6 7 8 9 10 11 12 13 | function isInterrupt(url) { return false }
self.addEventListener('fetch', event => { if (isInterrupt(event.request.url)) event.respondWith(new Response(null, 204)) })
|
缓存控制
通过sw实现本地缓存的思路非常简单,如果本地已经缓存了内容,那么客户端在发起网络请求的时候我们就不再通过网络下载,直接返回本地存储的内容即可;如果本地并没有缓存内容,那么就根据需求选择是否将指定内容缓存到本地。
下面是一个简单的例子:
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 | const cacheList = { simple: { clean: true, match: url => url.endsWith('/') } }
self.addEventListener('fetch', async event => { const request = event.request if (!findCache(request.url)) return event.respondWith(caches.match(request).then(response => { if (response) return response return fetchNoCache(request).then(response => { if ((response.status >= 200 && response.status < 400) || response.status === 0) { const clone = response.clone() caches.open(CACHE_NAME).then(cache => cache.put(request, clone)) } return response }).catch(err => console.error(`访问 ${request.url} 时出现错误:\n${err}`)) })) })
const fetchNoCache = request => fetch(request, {cache: "no-store"})
function findCache(url) { for (let key in cacheList) { const value = cacheList[key] if (value.match(url)) return value } return null }
|
老版实现
接下来就是我们今天的重点——本人的SW实现。看过我前面的文章的读者可能已经对我的缓存规则有了了解:
- 每个缓存有固定的存活时间
- 缓存过期后再次发送请求时会尝试通过网络请求获取新内容,如果超过指定时间没有下载完毕就先返回缓存内容,后台继续下载,下载完毕后替换缓存
- 每个缓存存储一个额外的时间戳,用于标记最后一次访问时间
- 手动刷新缓存时会删掉超过一定时间没访问过的缓存
不过这个实现已经被我替换掉了,旧版的实现我们不再多说,直接给出代码:
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) { 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())
const cacheList = { sample: { url: /[填写正则表达式]/g, time: Number.MAX_VALUE, clean: true } }
const replaceList = { sample: { source: ['//www.kmar.top'], dist: '//kmar.top' } }
function findCache(url) { for (let key in cacheList) { const value = cacheList[key] if (url.match(value.url)) return value } return null }
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 }
function blockRequest(request) { return false }
async function fetchEvent(request, response, cacheDist) { const NOW_TIME = time() 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) 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)) } })
|
新版实现
那么我们新版的方案换成了什么呢?
- 所有缓存都没有过期时间(永久存活)
- 每次更新文章时通过一系列JSON告诉客户端更新了哪些文件
- 客户端根据JSON删除更新掉的缓存
这个方案我们可以称之为增量更新,该方案成功地实现了本地的永久缓存,仅在有需要的时候更新掉缓存。
不过这个方案也有其缺陷,因为需要通过JSON判断缓存是否需要删除,所以我们需要提前下载指定的JSON。但是我们又不能在更新JSON的时候卡住页面加载,所以在缓存更新后,只有在刷新后才能生效,同时下载的JSON也需要占用一定的流量成本。
因为是增量更新,在设计时也遇到了很多问题,其中最重要的一个就是如何解决跨版本问题。JSON是根据上一版的内容编写需要更新哪些内容的,如果用户错过了某些版本,就可能出现部分需要更新的资源永远无法更新。这是一个非常严重的问题,我首先想到的解决方案是:把所有版本的JSON都存下来,这样的话即使用户跨了版本,也能先通过前面的JSON更新,最终完成增量更新。
这个方案有一个很严重的问题:无休止的更新会使JSON的体积无限膨胀,我们这个缓存方案的前提就是JSON足够轻量,这样很明显就冲突了。
于是我想到了一种比较暴力的解决方案,我仅存储一定数量的版本信息。比如我只存储近10个版本的JSON信息,如果用户跨越的版本数量超过了10,就无法通过JSON更新内容了,这时候就强制用户刷新全站缓存。
不过看起来傻,其实成本并没有那么高,因为本身更新新的博文的时候就需要刷新全站缓存来保证相关页面的更新(手动排查哪些页面受到影响实在是太累了),相比于更新博文,全站刷新无非是多了CSS和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 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 |
(() => { const CACHE_NAME = 'kmarBlogCache' const VERSION_PATH = 'https://id.v3/'
self.addEventListener('install', () => self.skipWaiting())
const cacheList = { static: { clean: false, match: url => run(url.pathname, it => it.match(/\.(woff2|woff|ttf|cur)$/) || it.match(/\/(pjax\.min|fancybox\.umd\.min|twikoo\.all\.min)\.js$/) || it.match(/\/(all\.min|fancybox\.min)\.css/) ) } }
const replaceList = { simple: { source: ['//cdn.jsdelivr.net/gh'], dist: '//cdn1.tianli0.top/gh' } }
const deleteCache = list => new Promise(resolve => { caches.open(CACHE_NAME).then(cache => cache.keys() .then(keys => Promise.all(keys.map( it => new Promise(async resolve1 => { const url = it.url if (url !== VERSION_PATH && list.match(url)) { await cache.delete(it) resolve1(url) } else resolve1(undefined) }) )).then(removeList => resolve(removeList))) ) })
self.addEventListener('fetch', event => { const replace = replaceRequest(event.request) const request = replace || event.request const url = new URL(request.url) if (findCache(url)) { event.respondWith(new Promise(async resolve => { const key = new Request(`${url.protocol}//${url.host}${url.pathname}`) let response = await caches.match(key) if (!response) { response = await fetchNoCache(request) const status = response.status if ((status > 199 && status < 400) || status === 0) { const clone = response.clone() caches.open(CACHE_NAME).then(cache => cache.put(key, clone)) } } resolve(response) })) } else if (replace !== null) { event.respondWith(fetch(request)) } })
self.addEventListener('message', event => { const data = event.data switch (data) { case 'update': updateJson().then(info => { event.source.postMessage({ type: 'update', update: info.update, version: info.version, }) }) break default: const list = new VersionList() list.push(new CacheChangeExpression({'flag': 'all'})) deleteCache(list).then(() => { if (data === 'refresh') event.source.postMessage({type: 'refresh'}) }) break } })
const run = (it, task) => task(it)
const fetchNoCache = request => fetch(request, {cache: "no-store"})
function findCache(url) { for (let key in cacheList) { const value = cacheList[key] if (value.match(url)) return value } return null }
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 }
function updateJson() { const parseChange = (list, elements, version) => { for (let element of elements) { const ver = element['version'] if (ver === version) return false const jsonList = element['change'] if (jsonList) { for (let it of jsonList) list.push(new CacheChangeExpression(it)) } } return true } const parseJson = json => new Promise(resolve => { const dbVersion = { write: (id) => new Promise((resolve, reject) => { caches.open(CACHE_NAME).then(function (cache) { cache.put( new Request(VERSION_PATH), new Response(id) ).then(() => resolve()) }).catch(() => reject()) }), read: () => new Promise((resolve) => { caches.match(new Request(VERSION_PATH)) .then(function (response) { if (!response) resolve(null) response.text().then(text => resolve(text)) }).catch(() => resolve(null)) }) } let list = new VersionList() dbVersion.read().then(oldData => { const oldVersion = JSON.parse(oldData) const elementList = json['info'] const global = json['global'] const newVersion = {global: global, local: elementList[0].version} if (!oldVersion) { dbVersion.write(`{"global":${global},"local":"${newVersion.local}"}`) return resolve(newVersion) } const refresh = parseChange(list, elementList, oldVersion.local) dbVersion.write(JSON.stringify(newVersion)) if (refresh) { if (global === oldVersion.global) { list._list.length = 0 list.push(new CacheChangeExpression({'flag': 'all'})) } else list.refresh = true } resolve({list: list, version: newVersion}) }) }) const url = `/update.json` return new Promise(resolve => fetchNoCache(url) .then(response => response.text().then(text => { const json = JSON.parse(text) parseJson(json).then(result => { if (!result.list) return resolve({version: result}) deleteCache(result.list).then(list => resolve({ update: list.filter(it => it), version: result.version }) ) }) })) ) }
class VersionList {
_list = [] refresh = false
push(element) { this._list.push(element) }
clean(element = null) { this._list.length = 0 if (!element) this.push(element) }
match(url) { if (this.refresh) return true else { for (let it of this._list) { if (it.match(url)) return true } } return false }
}
class CacheChangeExpression {
constructor(json) { const checkCache = url => { const cache = findCache(new URL(url)) return !cache || cache.clean } const forEachValues = action => { const value = json.value if (Array.isArray(value)) { for (let it of value) { if (action(it)) return true } return false } else return action(value) } switch (json['flag']) { case 'all': this.match = checkCache break case 'post': this.match = url => url.endsWith('postsInfo.json') || forEachValues(post => url.endsWith(`posts/${post}/`)) break case 'html': this.match = cacheList.html.match break case 'file': this.match = url => forEachValues(value => url.endsWith(value)) break case 'new': this.match = url => url.endsWith('postsInfo.json') || url.match(/\/archives\//) break case 'page': this.match = url => forEachValues(value => url.match(new RegExp(`\/${value}(\/|)$`))) break case 'str': this.match = url => forEachValues(value => url.includes(value)) break default: throw `未知表达式:${JSON.stringify(json)}` } }
} })()
|
版本更新过程
当SW更新缓存时,会严格按照下列步骤进行:
- 获取JSON文件
- 读取JSON文件中的外部版本信息(
global
) - 将JSON中的版本号与本地版本号对比,一致则跳过剩余步骤
- 解析
info
中的内容 - 根据解析结果删除缓存
对于其中可能遇到的各种情况我们做了如下处理:
版本号对比
如果本地版本号不存在,则视其为新用户,不进行任何操作,跳过剩余步骤
解析JSON错误
如果解析JSON的过程中出现错误,则会停止解析并不进行任何操作
本地版本过期
如果JSON的版本列表中没有包含本地版本对应的版本号,那么就视为本地版本过期(即和最新版本跨越版本数量过多)。
本地版本过期时,会将本地存储的外部版本号与JSON中的外部版本号进行对比:
- 如果一致,则清除所有标记
clean = true
的缓存 - 如果不一致,则清除所有(除版本信息以外)缓存
JSON格式
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | { "global": 0, "info": [ { "version": "任意不重复的字符串0", "change": [ {"flag": "[flag0]", "value": "[value0]"}, {"flag": "[flag1]", "value": "[value1]"} ] }, { "version": "任意不重复的字符串1", "change": [ {"flag": "[flag0]", "value": "[value0]"}, {"flag": "[flag1]", "value": "[value1]"} ] } ] }
|
change
列表内容:
flag | value | 功能 |
---|
file | 有 | 刷新名为value 的文件缓存(需要带拓展名) |
post | 有 | 刷新abbrlink为value 的博文及search.xml |
all | 无 | 刷新全部clean = true 的缓存 |
注意:post
是给我的目录结构订制的,如果需要使用或想要订制自己的匹配规则,请修改SW中的CacheChangeExpression
注意事项
- 如果没有删除
clean = false
的缓存,就不要修改global
- 同时存在于JSON的版本数量可以有无限个,但是请注意,过大的JSON会损耗性能,所以不要让同时存在的版本数量过多
- SW在匹配JSON信息时采用顺序匹配,所以写在
info
里面的版本信息越靠上表明越新,第一个即最新的版本,最后一个为保存的最旧的版本 change
列表中匹配规则的数量也没有上限,同样因为性能问题尽量合并一下同类项- 如果相邻两个版本更新的内容是一样的,请勿删除其中某一个版本,可以把老版本的
change
列表置空 - 尽量不要重复利用
version
的字符串,避免出现意料之外的问题 - 如果需要清除所有缓存,可以不使用
all
,把旧版本号全部删掉就可以了
CDN缓存问题
如果你的网站接入了CDN并启用了缓存,请务必注意缓存问题,因为该方案要求当JSON在CDN缓存中更新时change
中包含的文件同样更新,否则就会导致客户端拉取到旧的内容。
目前我还没有实现CDN缓存的自动刷新(不会写hexo/gulp
插件),所以我选择了另外一种暴力但是简单的方法:CDN缓存时间拉到最长,每次更新时手动刷新CDN缓存。
各家CDN的情况可能不太一样,各位读者根据自己的情况选择处理方法即可。
DOM端
可能已经有小伙伴迫不及待地把我的SW复制过去实操了,结果发现缓存更新并没有生效,这是因为没有在DOM中编写对应代码。
我们需要DOM在加载页面地时候发送信息到SW,告知SW开始根据JSON更新缓存,所以我们需要在DOM中添加如下JS:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | if ('serviceWorker' in window.navigator && navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage('update') navigator.serviceWorker.addEventListener('message', event => { const data = event.data switch (data.type) { case 'update': break case 'refresh': break default: console.error(`未知事件:${data.type}`) } }) }
|
在更新完毕后,如果你没有开启Pjax,直接刷新页面即可使最新代码生效。但是如果开启了Pjax,无论你如何刷新页面,新的js/css都不会生效。如何在解决开启Pjax后js/css不更新的问题是一个非常重要的问题,因为如果无法解决的话很容易出现更新后的HTML结构无法和老JS代码兼容的情况,具体解决方案见:在开启Pjax的情况下实现缓存的更新。
这次SW真的废了我很大功夫才写出来
创作不易,扫描下方打赏二维码支持一下吧ヾ(≧▽≦*)o