使用Service Worker优选请求资源

前言

当你的网站或博客有多个部署点时,部署在某个平台的访问速度比较快,于是你就把你的域名解析到了这个平台上,但有时候还是会变得很慢,这时其它站点速度可能会变得比你当前使用的还快一点,难道还有来回解析域名吗?太麻烦了

有没有可以直接返回最快网站资源的办法呢?

  1. 使用域名管理平台,有些平台可以解析不同网络或地区的站点
    例如腾讯云可以区分解析国内三大运营商、境内、境外、等一些解析选项(不太好用,还需要自己测试,难不成求使用其它运营商手机的朋友帮你测一下快不快嘛~)
  2. 使用 js 拦截网站的所有请求,并篡改将请求发送到自己的所有站点,这些站点中如果哪个站点最快返回,那么就用最快返回的这个信息,与此同时将其它的请求全部切断

正文

本文会详细说明如何使用 Service Worker 优选请求资源让你的网站比以前更快,更稳定

Service Worker在接下来的内容中统一称呼为sw

Service Worker

更详细请看https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API

Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器

Service worker 运行在 worker 上下文,因此它不能访问 DOM。相对于驱动应用的主 JavaScript 线程,它运行在其他线程中,所以不会造成阻塞。

出于安全考量,Service workers 只能由 HTTPS 承载,毕竟修改网络请求的能力暴露给中间人攻击会非常危险。

注册

注册 sw 很简单,只需一行代码即可,如果注册成功,则 sw 会被下载到客户端并且安装和激活,这一步仅仅是注册而已,完整的是: 下载—>安装—>激活

注意: sw 的注册日志记录在 Chrome 浏览器中可以通过访问chrome://serviceworker-internals查看

COPY
1
navigator.serviceWorker.register('/Service-Worker.js')

其中/Service-Worker.js必须是当前域下的 js 文件,他不能是其它域下的,即使 js 文件内的内容完全相等,那也不行

如果你只想在某个路径写使用 sw 的话,你可以使用scope选项,当然/Service-Worker.js的位置也可以自定义(只要是同源且是 https 协议就可以),如下只有在/article/文章页 sw 才启动,其它路径写 sw 不进行处理

COPY
1
navigator.serviceWorker.register('/sw-test/Service-Worker.js', { scope: '/article/' })

并且必须是 https 协议,如果是本地127.0.0.1localhost是被允许的

这是一个完整的注册代码

将安装代码放置在<head>之后

COPY
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
<script>
;(function () {
if (navigator.serviceWorker) {
navigator.serviceWorker
.register('/sw.js')
.then((result) => {
// 判断是否安装了sw
if (localStorage.getItem('installSW')) return
localStorage.setItem('installSW', true)
// 这里就不用清理setInterval了,因为页面刷新后就没有了
const timer = setInterval(() => {
// 判断sw安装后,是否处于激活状态,激活后刷新页面
if (result.active.state === 'activated') {
clearInterval(timer)
fetch(window.location.href)
.then((res) => res.text())
.then((text) => {
document.open()
document.write(text)
document.close()
})
}
}, 100)
})
.catch(console.log)
}
})()
</script>

生命周期

installing 状态

当注册成功后会触发install事件,然后触发activate事件,此时如果再次刷新页面,它俩都不会被触发了

直到/sw.js发生了改变,它就会触发一次 install (不仅仅是代码改变,哪怕是多一个空格或是少一个空格,又或是写一个注释都会被触发),但是只执行了install事件,并没有执行activate事件

activing 状态

为什么activate事件不触发了?因为已经有一个 sw 了,它一种处于等待状态,至于什么时候才会被触发,那就是等之前的 sw 停止了才会触发activate事件

那有没有办法不让它等待呢?答案是:

使用skipWaiting()跳过等待,它返回一个 promise 对象(异步的),防止还在执行skipWaiting()的时候直接就跳到activate事件,我们需要使用async/await,也可以使用event.waitUntil(skipWaiting())方法把skipWaiting()放到里面,和async/await效果一样

COPY
1
2
3
4
5
6
7
// sw.js

// 在sw中可以使用this或是self表示自身
self.addEventListener('install', async (event) => {
// event.waitUntil(self.skipWaiting())
await self.skipWaiting()
})

触发activate事件后 ,当前这一次网页是不会被 sw 管理的,需要下次页面刷新才会被 sw 管理,那怎么让它立即管理页面呢?

更详细请看:
https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting > https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim

COPY
1
2
3
4
self.addEventListener('activate', async (event) => {
// event.waitUntil(self.clients.claim())
await self.clients.claim() // 立即管理页面
})

安装

详细上面生命周期已经详细说明了

COPY
1
2
3
4
5
// sw.js
self.addEventListener('install', async (event) => {
// event.waitUntil(self.skipWaiting())
await self.skipWaiting()
})

捕获请求

COPY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// sw.js
self.addEventListener('fetch', async (event) => {
// 所有请求都得转到 handleRequest 函数内处理
handleRequest(event.request)
// 如果 handleRequest 请求成功则将数据响应到网页
.then((result) => event.respondWith(result))
// 如果 handleRequest 请求失败,什么都不做(这是网页自己走自己的实际请求)
.catch(() => 0)
})

// 处理请求
function handleRequest(req) {
return fetch(req.url)
}

event.respondWith(): 给浏览器一个响应,因为我们已经使用Fetch API替浏览器发送了请求,并且得到了结果并且返回,那么自然是要返回给浏览器啦

Fetch API可以和XMLHttpRequest一样,可以发送任何请求

值得一提的是使用 Fetch API 发送请求是会存在跨域问题的,一旦被跨域拦截,那么就上面都没有返回,会导致页面显示不了请求的内容(例如图片被跨域拦截了),而 img、script 标签它们是不会发生跨域请求问题的,所以上面 catch 捕获异常一个 0 和 null 差不多
既然没有用到 event.respondWith 那自然是没有给浏览器返回数据啦,那浏览器就自己请求(很好的避免了这个问题)

篡改请求

上面我们都可以使用Fetch API替浏览器发送请求了,那是不是可以篡改呢?

COPY
1
2
3
4
5
6
7
// 处理请求
function handleRequest(req) {
// 仅仅只是举个例子,更多奇妙的用法等待你去探索
const str = 'https://cdn.jsdelivr.net/npm/xhr-ajax/dist/'
const url = req.url.replace(str + 'ajax.js', str + 'ajax.min.js')
return fetch(url)
}

如上代码,我们就可以将 ajax 请求的第三方库 js 文件请求变为压缩后的请求,并返回给浏览器(篡改成功)

COPY
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
// 批量请求
function handleRequest(req) {
// 可以是多个
const urls = [
"https://cdn.jsdelivr.net/npm/xhr-ajax/dist/ajax.min.js",
"https://unpkg.com/xhr-ajax/dist/ajax.min.js",
];
// 中断一个或多个请求
const controller = new AbortController();
// 可以中断请求
// https://developer.mozilla.org/zh-CN/docs/Web/API/AbortController#%E5%B1%9E%E6%80%A7
const signal = controller.signal;

// 遍历将所有的请求地址转换为promise
const PromiseAll = urls.map((url) => {
return new Promise(async (resolve, reject) => {
fetch(url, { signal })
.then(
(res) =>
new Response(await res.arrayBuffer(), {
status: res.status,
headers: res.headers,
})
)
.then((res) => {
if (res.status !== 200) reject(null);
// 只要有一个请求成功返回那么就把所有的请求断开
controller.abort(); // 中断
resolve(res);
})
.catch(() => reject(null));
});
});
// 使用 Promise.any 发送批量请求,它接收一个可迭代对象,例如数组就是一个可迭代对象
return Promise.any(PromiseAll)
.then((res) => res)
.catch(() => null);
}

Promise.any 具体请看: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise/any
AbortController 具体请看: https://developer.mozilla.org/zh-CN/docs/Web/API/AbortController

只要传入的迭代对象中的任何一个 promise 返回成功(resolve)状态,那么它就返回成功状态,如果其中的所有的 promises 都失败,那么就会把所有的失败返回

所以只要Promise.any有一个成功状态的数据返回,那么我们就把这个数据响应给浏览器,而其它 的请求全部切断,这样就可以高效的在不同地区响应最快的资源给用户啦~

这也是正文开始前我们所需要解决的问题

完整 sw.js 文件

这是我总结写出的一个 sw.js 文件,你只需要将下面的origin数组改成你的博客地址就可以了,其它的可以不用动,如果你想添加新东西,那就随便你啦~哈哈哈

COPY
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
const origin = ['https://blog.imlete.cn', 'https://lete114.github.io']

const cdn = {
gh: {
jsdelivr: 'https://cdn.jsdelivr.net/gh',
fastly: 'https://fastly.jsdelivr.net/gh',
gcore: 'https://gcore.jsdelivr.net/gh',
testingcf: 'https://testingcf.jsdelivr.net/gh',
test1: 'https://test1.jsdelivr.net/gh',
tianli: 'https://cdn1.tianli0.top/gh'
},
combine: {
jsdelivr: 'https://cdn.jsdelivr.net/combine',
fastly: 'https://fastly.jsdelivr.net/combine',
gcore: 'https://gcore.jsdelivr.net/combine',
testingcf: 'https://testingcf.jsdelivr.net/combine',
test1: 'https://test1.jsdelivr.net/combine',
tianli: 'https://cdn1.tianli0.top/combine'
},
npm: {
jsdelivr: 'https://cdn.jsdelivr.net/npm',
fastly: 'https://fastly.jsdelivr.net/npm',
gcore: 'https://gcore.jsdelivr.net/npm',
testingcf: 'https://testingcf.jsdelivr.net/npm',
test1: 'https://test1.jsdelivr.net/npm',
unpkg: 'https://unpkg.com',
tianli: 'https://cdn1.tianli0.top/npm'
}
}

self.addEventListener('install', async () => {
await self.skipWaiting()
})

self.addEventListener('activate', async () => {
await self.clients.claim()
})

self.addEventListener('fetch', async (event) => {
try {
event.respondWith(handleRequest(event.request))
} catch (e) {}
})

// 返回响应
async function progress(res) {
return new Response(await res.arrayBuffer(), {
status: res.status,
headers: res.headers
})
}

function handleRequest(req) {
const urls = []
const urlStr = req.url
let urlObj = new URL(urlStr)
// 为了获取 cdn 类型
// 例如获取gh (https://cdn.jsdelivr.net/gh)
const path = urlObj.pathname.split('/')[1]

// 匹配 cdn
for (const type in cdn) {
if (type === path) {
for (const key in cdn[type]) {
const url = cdn[type][key] + urlObj.pathname.replace('/' + path, '')
urls.push(url)
}
}
}

// 如果上方 cdn 遍历 匹配到 cdn 则直接统一发送请求(不会往下执行了)
if (urls.length) return fetchAny(urls)

// 将用户访问的当前网站与所有源站合并
let origins = [...new Set([location.origin, ...origin])]

// 遍历判断当前请求是否是源站主机
const is = origins.find((i) => new URL(urlStr).hostname === new URL(i).hostname)

// 如果是源站,则竞速获取(不会往下执行了)
if (is) {
origins = origins.map((i) => i + urlObj.pathname + urlObj.search)
return fetchAny(origins)
}
// 抛出异常是为了让sw不拦截请求
throw new Error('不是源站')
}

// Promise.any 的 polyfill
function createPromiseAny() {
Promise.any = function (promises) {
return new Promise((resolve, reject) => {
promises = Array.isArray(promises) ? promises : []
let len = promises.length
let errs = []
if (len === 0) return reject(new AggregateError('All promises were rejected'))
promises.forEach((p) => {
if (!p instanceof Promise) return reject(p)
p.then(
(res) => resolve(res),
(err) => {
len--
errs.push(err)
if (len === 0) reject(new AggregateError(errs))
}
)
})
})
}
}

// 发送所有请求
function fetchAny(urls) {
// 中断一个或多个请求
const controller = new AbortController()
const signal = controller.signal

// 遍历将所有的请求地址转换为promise
const PromiseAll = urls.map((url) => {
return new Promise((resolve, reject) => {
fetch(url, { signal })
.then(progress)
.then((res) => {
const r = res.clone()
if (r.status !== 200) reject(null)
controller.abort() // 中断
resolve(r)
})
.catch(() => reject(null))
})
})

// 判断浏览器是否支持 Promise.any
if (!Promise.any) createPromiseAny()

// 谁先返回"成功状态"则返回谁的内容,如果都返回"失败状态"则返回null
return Promise.any(PromiseAll)
.then((res) => res)
.catch(() => null)
}

Authorship: Lete乐特
Article Link: https://blog.imlete.cn/article/Service-Worker-Preferred-Request-Resource.html
Copyright: All posts on this blog are licensed under the CC BY-NC-SA 4.0 license unless otherwise stated. Please cite Lete乐特 's Blog !