不只是离线缓存! - 论如何善用ServiceWorker
ServiceWorker作为前端革命领袖,毫不夸张地被誉为前端黑科技,此文将阐述如何巧妙的使用它来实现一些看起来匪夷所思的事情。
从2022/1/8开始,本文将持续更新。当前状态:更新中
起因 - 巨厦坍塌
2021/12/20日,赶在旧年的末尾,一则JSdelivrSSL证书错误
缓缓上了v2ex论坛热点。
此前JSD由于各种原因,曾经不正常了一段时间,所以大家并未对此感冒.正当人们以为这只是JSdelivr每年一度的年经
阵痛,发个issue,过一段时间就好了的时候.官方直接爆出大料:JSDelivr had lost their ICP license
由此可见,过去的几年里,当人们发现JSD对个人面向国内加速拥有者无与伦比的效果时,各种滥用方式层出不穷:图床曾一阵流行,国内搜索引擎JSdelivr十有八九都是作为图床的,连PicGo插件都出了Github+JSdelivr图床;猛一点的,直接做视频床,甚至为了突破单文件20M限制开发了一套ts切片m3u8一条龙服务;作妖的,托管了不少突破网络审查的脚本和规则集;寻死的,添加了大量的政治宗教敏感,有些甚至不配称为宗教,直接上来就是骗钱的.
jsd并不是没有发布许可条款,但这并不能阻止白嫖大军的进程。在羊毛大军中,只要是你是免费的、公益的,你就要做好被薅爆的结果。但是薅羊毛的前提是羊还活着,倘若羊被薅死了,哪来的羊毛给诸君所薅?
总之,不管怎样,JSDelivr在决定将节点设置为NearChina
,可以肯定的是,在最近很长一段时间内,我们都无法享受国内外双料同时加速的快感,换句话说,jsd在中国就被永久地打入了冷宫。
视线转向国内,jsd的替代品并不少。早在我写图床的千层套路我就试着假想jsd不可用时,我们该用什么。最终我给出的一份较为完美的答案-npm图床,优点无非就是镜像多速度快,许可条款较为宽松,缺点也很明显,需要安装node,用专门的客户端上传。
那事情就逐渐变得扑朔迷离起来了,我们应当如何选择合理的CDN加速器呢。
这时候,我想起了前端黑科技Serviceworker。是的,这种情况下使用SW最为巧妙不过,它可以在后台自动优选最佳的CDN,甚至可以用黑中黑Promise.any
打出一套漂亮的并行拳。经过两天的完善,我终于写出了一套具有离线可达、绕备、优选CDN、跟踪统计合一的SW脚本。此博客使用的SW
接下来我将从头开始讲述ServiceWorker的妙用。
Before Start
What Is The ServiceWorker
网上对于SW的解释比较模糊,在这里,我将其定义为用户浏览器里的服务器
,功能强大到令人发指。是的,接下来的两张图你应该能显著的看到这一差距:
采用循环,await
会堵塞循环,直到这次请求完成后才能执行下一个。如果有任何一个url长时间无法联通,将会导致极长的检测时间浪费。
const test = () => {
const url = [
"https://cdn.jsdelivr.net/npm/jquery@3.6.0/package.json",
"https://unpkg.com/jquery@3.6.0/package.json",
"https://unpkg.zhimg.com/jquery@3.6.0/package.json"
]
return Promise.all(url.map(url => {
return new Promise((resolve, reject) => {
fetch(url)
.then(res => {
if (res.status == 200) {
resolve(true)
} else {
reject(false)
}
})
.catch(err => {
reject(false)
})
})
}
)).then(res => {
return true
}).catch(err => {
return false
})
}
Promise.all
几乎在一瞬间请求所有的url,其请求时并行,每一个请求并不会堵塞其他请求,函数总耗时为最长请求耗时。
Promise.race
此函数也是并行执行,不过与all不同的是,只要有任何一个函数完成,就立刻返回,无论其是否reject
或者resolve
。
这个函数比较适合用于同时请求一些不关心结果,只要访问达到了即可,例如统计、签到等应用场景。
Promise.any
这个函数非常的有用,其作用和race
接近,不过与之不同的是,any
会同时检测结果是否resolve
。其并行处理后,只要有任何一个返回正确,就直接返回哪个最快的请求结果,返回错误的直接忽视,除非所有的请求都失败了,才会返回reject
这是一段同时请求jquery
的package.json
代码,它将从四个镜像同时请求:
const get_json = () => {
return new Promise((resolve, reject) => {
const urllist = [
"https://cdn.jsdelivr.net/npm/jquery@3.6.0/package.json",
"https://unpkg.com/jquery@3.6.0/package.json",
"https://unpkg.zhimg.com/jquery@3.6.0/package.json",
"https://npm.elemecdn.com/jquery@3.6.0/package.json"
]
Promise.any(urllist.map(url => {
fetch(url)
.then(res => {
if (res.status == 200) {
resolve(res)
} else {
reject()
}
}).catch(err => {
reject()
})
}))
})
}
console.log(await(await get_json()).text())
函数将会在21ms
上下返回json中的数据。
此函数的好处在于可以在用户客户端判断哪一个镜像发挥速度最快,并保证用户每一次获取都能达到最大速度。同时,任何一个镜像站崩溃了都不会造成太大的影响,脚本将自动从其他源拉取信息。
除非所有源都炸了,否则此请求不会失败。
但是,我们会额外地发现,当知乎镜像返回最新版本后,其余的请求依旧在继续,只是没有被利用到而已。
这会堵塞浏览器并发线程数,并且会造成额外的流量浪费。所以我们应该在其中任何一个请求完成后就打断其余请求。
fetch
有一个abort
对象,只要刚开始new AbortController()
指定控制器,在init
的里面指定控制器的signal
即可将其标记为待打断函数,最后controller.abort()
即可打断。
那么,很多同学就会开始这么写了:
const get_json = () => {
return new Promise((resolve, reject) => {
const controller = new AbortController();
const urllist = [
"https://cdn.jsdelivr.net/npm/jquery@3.6.0/package.json",
"https://unpkg.com/jquery@3.6.0/package.json",
"https://unpkg.zhimg.com/jquery@3.6.0/package.json",
"https://npm.elemecdn.com/jquery@3.6.0/package.json"
]
Promise.any(urllist.map(url => {
fetch(url,{
signal: controller.signal
})
.then(res => {
if (res.status == 200) {
controller.abort();
resolve(res)
} else {
reject()
}
}).catch(err => {
reject()
})
}))
})
}
console.log(await(await get_json()).text())
但很快,你就会发现它报错了:Uncaught DOMException: The user aborted a request.
,并且没有任何数据输出。
让我们看一下Network选项卡:
其中,知乎返回的最快,但他并没有完整的返回文件源文件1.8KB,但他只返回了1.4KB。这也直接导致了整个函数的fail
。
原因出在fetch
上,这个函数在获得响应之后就立刻resolve
了Response
,但这个时候body
并没有下载完成,即fetch
的返回基于状态的而非基于响应内容,当其中fetch
已经拿到了完整的状态代码,它就立刻把Response
丢给了下一个管道函数,而此时status
正确,abort
打断了包括这一个fetch
的所有请求,fetch
就直接工作不正常。
我个人采取的方式是读取arrayBuffer
,阻塞fetch
函数直到把整个文件下载下来。函数名为PauseProgress
const get_json = () => {
return new Promise((resolve, reject) => {
const controller = new AbortController();
const PauseProgress = async (res) => {
return new Response(await (res).arrayBuffer(), { status: res.status, headers: res.headers });
};
const urllist = [
"https://cdn.jsdelivr.net/npm/jquery@3.6.0/package.json",
"https://unpkg.com/jquery@3.6.0/package.json",
"https://unpkg.zhimg.com/jquery@3.6.0/package.json",
"https://npm.elemecdn.com/jquery@3.6.0/package.json"
]
Promise.any(urllist.map(url => {
fetch(url, {
signal: controller.signal
})
.then(PauseProgress)
.then(res => {
if (res.status == 200) {
controller.abort();
resolve(res)
} else {
reject()
}
}).catch(err => {
reject()
})
}))
})
}
console.log(await(await get_json()).text())
在这其中通过arrayBuffer()
方法异步读取res
的body
,将其读取为二进制文件,并新建一个新的Response
,还原状态和头,然后丢给管道函数同步处理。
在这里,我们就实现了暴力并发,以流量换速度的方式。同时也获得了一个高可用的SW负载均衡器。
这一段函数可以这样写在SW中:
//...
const lfetch = (urllist) => {
return new Promise((resolve, reject) => {
const controller = new AbortController();
const PauseProgress = async (res) => {
return new Response(await (res).arrayBuffer(), { status: res.status, headers: res.headers });
};
Promise.any(urllist.map(url => {
fetch(url, {
signal: controller.signal
})
.then(PauseProgress)
.then(res => {
if (res.status == 200) {
controller.abort();
resolve(res)
} else {
reject()
}
}).catch(err => {
reject()
})
}))
})
}
const handle = async (req) => {
const npm_mirror = [
'https://cdn.jsdelivr.net/npm/',
'https://unpkg.com/',
'https://npm.elemecdn.com/',
'https://unpkg.zhimg.com/'
]
for (var k in npm_mirror) {
if (req.url.match(npm_mirror[k]) && req.url.replace('https://', '').split('/')[0] == npm_mirror[k].replace('https://', '').split('/')[0]) {
return lfetch((() => {
let l = []
for (let i = 0; i < npm_mirror.length; i++) {
l.push(npm_mirror[i] + req.url.split('/')[3])
}
return l
})())
}
}
return fetch(req)
}
缓存控制 / Cache
持久化缓存 / Cache Persistently
对于来自CDN的流量,大部分是持久不变的,因此,如果我们将文件获得后直接填入缓存,之后访问也直接从本地缓存中读取,那将大大提升访问速度。
const handle = async (req) => {
const cache_url_list = [
/(http:\\/\\/|https:\\/\\/)cdn\\.jsdelivr\\.net/g,
/(http:\\/\\/|https:\\/\\/)cdn\\.bootcss\\.com/g,
/(http:\\/\\/|https:\\/\\/)zhimg\\.unpkg\\.com/g,
/(http:\\/\\/|https:\\/\\/)unpkg\\.com/g
]
for (var i in cache_url_list) {
if (req.url.match(cache_url_list[i])) {
return caches.match(req).then(function (resp) {
return resp || fetch(req).then(function (res) {
return caches.open(CACHE_NAME).then(function (cache) {
cache.put(req, res.clone());
return res;
});
});
})
}
}
return fetch(req)
}
cache_url_list
列出所有待匹配的域名(包括http/https头是为了避免误杀其他url),然后for
开始遍历待列表,如果url中匹配到了,开始执行返回缓存操作。
cache是一个近似于Key/Value(键名/键值),只要有对应的Request
(KEY
),就能匹配到响应的Response
(VALUE
)。
caches.match(req)
将会试图在CacheStorage中匹配请求的url获取值,然后丢给管道同步函数then
,传参resp
为Cache匹配到的值。
此时管道内将尝试返回resp,如果resp为null
或undefined
即获取不到对应的缓存,将执行fetch操作,fetch成功后将open
打开CacheStorage,并put
放入缓存。此时如果fetch
失败将直接报错,不写入缓存。
在下一次获取同一个URL的时候,缓存匹配到的将不再是空白值,此时fetch
不执行,直接返回缓存,大大提升了速度。
由于npm的cdn对于latest缓存并不是持久有效的,所以我们最好还是判断一下url版本中是否以@latest为结尾。
const is_latest = (url) => {
return url.replace('https://', '').split('/')[1].split('@')[1] === 'latest'
}
//...
for (var i in cache_url_list) {
if (is_latest(req.url)) { return fetch(req) }
if (req.url.match(cache_url_list[i])) {
return caches.match(req).then(function (resp) {
//...
})
}
}
离线化缓存 / Cache For Offline
对于博客来说,并不是所有内容都是一成不变的。传统PWA采用SW更新同时刷新缓存,这样不够灵活,同时刷新缓存的版本号管理也存在着很大的漏洞,长时间访问极易造成庞大的缓存冗余。因此,对于博客的缓存,我们要保证用户每次获取都是最新的版本,但也要保证用户在离线时能看到最后一个版本的内容。
因此,针对博客来说,策略应该是先获取最新内容,然后更新本地缓存,最后返回最新内容;离线的时候,尝试访问最新内容会回退到缓存,如果缓存也没有,就回退到错误页面。
即:
Online:
发起Request => 发起fetch => 更新Cache => 返回Response
Offline:
发起Request => 获取Cache => 返回Response
const handle = async (req) => {
return fetch(req.url).then(function (res) {
if (!res) { throw 'error' } //1
return caches.open(CACHE_NAME).then(function (cache) {
cache.delete(req);
cache.put(req, res.clone());
return res;
});
}).catch(function (err) {
return caches.match(req).then(function (resp) {
return resp || caches.match(new Request('/offline.html')) //2
})
})
}
if (!res) { throw 'error' }
如果没有返回值,直接抛出错误,会被下面的Catch捕获,返回缓存或错误页面
return resp || caches.match(new Request('/offline.html'))
返回缓存获得的内容。如果没有,就返回从缓存中拿到的错误网页。此处offline.html应该在最开始的时候就缓存好
持久化存储 / Storage Persistently
由于sw中无window
,我们不能使用localStorage
和sessionStorage
。SW脚本会在所有页面都关闭或重载的时候丢失原先的数据。因此,如果想要使用持久化存储,我们只能使用CacheAPI
和IndexdDB
。
IndexdDB
这货结构表类型类似于SQL
,能够存储JSON对象和数据内容,但版本更新及其操作非常麻烦,因此本文不对此做过多解释。
CacheAPI
这东西原本是用来缓存响应,但其本身的特性我们可以将其改造成一个简易的Key/Value数据表,可以存储文本/二进制,可扩展性远远比IndexdDB要好。
self.CACHE_NAME = 'SWHelperCache';
self.db = {
read: (key) => {
return new Promise((resolve, reject) => {
caches.match(new Request(`https://LOCALCACHE/${encodeURIComponent(key)}`)).then(function (res) {
res.text().then(text => resolve(text))
}).catch(() => {
resolve(null)
})
})
},
read_arrayBuffer: (key) => {
return new Promise((resolve, reject) => {
caches.match(new Request(`https://LOCALCACHE/${encodeURIComponent(key)}`)).then(function (res) {
res.arrayBuffer().then(aB => resolve(aB))
}).catch(() => {
resolve(null)
})
})
},
write: (key, value) => {
return new Promise((resolve, reject) => {
caches.open(CACHE_NAME).then(function (cache) {
cache.put(new Request(`https://LOCALCACHE/${encodeURIComponent(key)}`), new Response(value));
resolve()
}).catch(() => {
reject()
})
})
}
}
使用操作:
写入key,value:
await db.wtite(key,value)
以文本方式读取key:
await db.read(key)
以二进制方式读取key:
await db.read_arrayBuffer(key)
其余的blob读取、delete操作此处不过多阐述。
页面与SW通信 / Build Communication with Page and ServiceWorker
施工中