Service Worker
是一个服务器与浏览器之间的中间人角色,它可以拦截网络请求并根据网络具体情况采取适当的动作、更新来自服务器的的资源。本文通过 Service Worker
代理请求,实现网页第二次访问的离线访问和CDN并发请求功能,提高网站的访问速度,同时能够避免 jsDeliver
这类 CDN 问题造成的网站不可用的情况。
对于一些
Service Worker
的基础认识在前文有介绍,本文主要讲述使用。
一、Service worker 注册与解除
注册:
navigator.serviceWorker.register('/sw.min.js')
.catch(function (error) {
console.log('cache failed with ' + error); // registration failed
});
解除注册:
navigator.serviceWorker.getRegistrations().then(function (registrations) {
for (let registration of registrations) {
registration.active && registration.active.scriptURL && registration.active.scriptURL.indexOf("/sw.min.js") !== -1 && registration.unregister()
}
})
解除注册后缓存的数据依旧不会被删除,如果需要删除缓存数据的话,还需要添加以下代码。
window.caches && caches.keys && caches.keys().then(function (keys) {
keys.forEach(function (key) {
console.log("delete cache", key);
caches.delete(key);
});
});
二、网页离线缓存
一般讲的离线缓存有两种方案,一是在 install
事件中进行离线缓存,二是在 fetch
事件中进行缓存。
install
阶段缓存的话,需要提供一个需要缓存的资源列表,然后将这个列表中的资源进行缓存;fetch
阶段的缓存则针对请求,对每一次请求进行缓存,根据缓存的优先级,主要可以分为服务器优先、缓存优先模式,还有一些其他的介于这两者之间的缓存方案。
本文讲述的是 fecth
阶段离线缓存,采用服务器优先的策略,访问服务器失败之后再读取缓存,优点就是能够及时的读取服务器上数据的变更(全站离线缓存还包括了 html
等动态内容),缺点就是每次请求都优先访问服务器,不能体验到缓存的高速。
正因为上面的特点,所以会有其他中和的方案,例如
html
采用服务器优先,js/css/img
等静态资源才用缓存优先,这其中还可以做更复杂的逻辑。
服务器优先的缓存实现逻辑如下,就是通过 fecth
向服务器发起请求,如果请求失败则使用 catch
捕获异常然后读取缓存。
event.respondWith(caches.open(cacheName)
.then(cache => {
return fetch(event.request)
.then((response) => {
if (response.status === 200) {
cache.put(event.request, response.clone());
return response
}
return cache.match(event.request)
})
.catch(() => cache.match(event.request))
})
);
三、CDN并发请求
目前有很多免费的 CDN,他们针对莫些类型的开源包提供 CDN 服务,例如 jsDeliver
为 npm/gh
等提供服务,unpkg
为 npm
提供 CDN 服务,他们针对的是不同的开源库,要实现这些免费 CDN 间的切换,那么就需要对这些 CDN 的类型进行区分。
区分 CDN 类型主要是针对于对免费CDN的使用,他们通过一个固定的路径格式进行请求,区分类型即可实现多个 CDN 请求路径的拼接。
如果采用的是自建 CDN,或者是其他一些定制化的路径这种区分类型的方式就不合适了,可能FreeCDN
这种生成摘要文件的方式会更好一些。
那么如何区分呢?本文主要以匹配请求的路径前缀部分的主机名和路径进行 CDN 的区分,并将 CDN 分为了 theme
和 npm
两种类型,代码如下:
const cdn = {
theme: {
originUrl: `${location.origin}/themes/dream`,
handleRequest: url => {
if (url.indexOf(cdn.theme.originUrl) !== 0) return
const path = url.substring(cdn.theme.originUrl.length)
const version = new URLSearchParams(url.split('?')[1]).get("mew") || 'latest';
return [
url,
...cdn.npm.urlTemplates.map(value => `${value}/halo-theme-dream@${version}${path}`)
]
},
},
npm: {
urlTemplates: [
"https://unpkg.com",
"https://cdn.jsdelivr.net/npm",
"https://npm.elemecdn.com",
],
handleRequest: url => {
return handleUrls(cdn.npm.urlTemplates, url)
}
},
}
/**
* 使用模板替换url路径
*
* @param urlTemplates
* @param url
* @returns {*}
*/
function handleUrls(urlTemplates, url) {
for (let index in urlTemplates) {
if (url.indexOf(urlTemplates[index]) === 0) {
const path = url.substring(urlTemplates[index].length)
return urlTemplates.map(value => value + path);
}
}
}
其中 theme
为博客内部文件的 CDN 类型,因为路径稍微有些不同,所以需要进行比较特殊的 url 处理,npm
类型则直接替换请求的前缀即可,因为他们后面的路径都是一样的。
基于以上生成的 CDN 路径,然后进行 CDN 的并发请求,并以最先获得响应的 CDN 为准,放弃未响应的请求。
function handleRequest(req, isCdnAndCache) {
const reqUrl = req.url;
// 匹配 cdn
for (const type in cdn) {
const urls = cdn[type].handleRequest(reqUrl)
if (urls) return fetchAny(reqUrl, urls);
}
// 没有匹配到url,直接发起请求
return fetch(req);
}
// 发送所有请求
function fetchAny(originUrl, urls) {
// 中断一个或多个请求
const controller = new AbortController();
const signal = controller.signal;
// 遍历将所有的请求地址转换为promise
const PromiseAll = urls.map((url) => {
return new Promise(async (resolve, reject) => {
fetch(url, {signal})
.then(async res => { // 重新封装响应
const newHeaders = new Headers(res.headers)
newHeaders.set('service-worker-origin', originUrl)
return new Response(await res.arrayBuffer(), {
status: res.status,
headers: newHeaders,
});
})
.then((res) => {
if (res.status !== 200) reject(null);
controller.abort(); // 中断
resolve(res);
})
.catch(() => reject(null)); // 去除中断的错误信息
});
});
// 判断浏览器是否支持 Promise.any
if (!Promise.any) createPromiseAny();
// 谁先返回"成功状态"则返回谁的内容,如果都返回"失败状态"则返回null
return Promise.any(PromiseAll).catch(() => null);
}
// 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));
}
);
});
});
};
}
关于并发请求,和放弃未响应的请求,主要借助了
Promise.any
和AbortController
。
四、缓存版本控制
服务器资源可能是不断升级迭代的,缓存可能会不断的过期失效,那么我们就需要对旧版本的缓存进行删除,然后使用新的版本。
缓存版本控制这块本文分成了两方面,一方面是缓存存储桶名称上添加了版本号,对于失效版本的缓存在 activate
阶段中将被删除。另一方面是同存储桶内缓存文件的版本控制,可能服务器上只修改了少部分的文件,不希望对整个桶的缓存进行更新。
本文基于请求的 v/version/t
等参数进行版本判断,每一次请求将获取当前路径所有的缓存,然后比较版本,如果版本不匹配则重新从服务器获取资源,然后更新缓存,如果从服务器获取资源失败,则继续使用旧版本的缓存。
/**
* 判断两个url是否属于同一个请求,过滤掉版本参数
*
* @param urla
* @param urlb
* @returns {boolean}
*/
function isSameRequest(urla, urlb) {
// 除了这这些参数,其它的查询参数必须要一致,才认为是同一个请求
const white_query = new Set([
"mew", // 自定义的版本号
"v",
"version",
"t",
"time",
"ts",
"timestamp"
]);
const a_url = urla.split('?');
const b_url = urlb.split('?');
if (a_url[0] !== b_url[0]) {
return false;
}
const a_params = new URLSearchParams('?' + a_url[1]);
const b_params = new URLSearchParams('?' + b_url[1]);
// 显示所有的键
for (const key of a_params.keys()) {
if (white_query.has(key)) {//对于版本号的key 忽略
continue;
}
if (a_params.get(key) !== b_params.get(key)) {//其它key的值必须相等,比如type=POST 这种
return false;
}
}
return true;
}
/**
* 从缓存列表取得当前请求的缓存
*
* @param cache_response_list
* @param request_url
* @returns {null|any}
*/
function getMatchRequestResponse(cache_response_list, request_url) {
if (cache_response_list) {
for (const cache_response of cache_response_list) {
if ((cache_response.url || cache_response.headers.get('service-worker-origin')) === request_url) {
return cache_response;
}
}
}
return null;
}
// 劫持 HTTP Request
event.respondWith(
caches.open(cacheName).then(function (cache) {
// ignoreSearch 忽略请求参数进行查找,用于匹配不同版本
return cache.match(event.request).then(function (cacheResponse) {
// 直接返回缓存
if (cacheResponse) return cacheResponse;
return handleRequest(event.request, isCdnAndCache)
.then((response) => {
const responseClone = response.clone();
cache.matchAll(event.request, {"ignoreSearch": true})
.then(function (cache_response_list) {
// 删除旧版本的缓存文件
if (cache_response_list) {
for (const cache_response of cache_response_list) {
const responseUrl = cache_response.url || cache_response.headers.get('service-worker-origin')
if (isSameRequest(responseUrl, event.request.url)) {
cache.delete(responseUrl)
}
}
}
cache.put(event.request, responseClone);
})
return response;
})
.catch(error => {
console.error(error)
return cache.matchAll(event.request, {"ignoreSearch": true})
.then(function (cache_response_list) {
// 从缓存中取得历史版本的文件
if (cache_response_list) {
for (const cache_response of cache_response_list) {
if (isSameRequest(cache_response.url || cache_response.headers.get('service-worker-origin'), event.request.url)) {
return cache_response
}
}
}
})
});
})
})
);
判断
service-worker-origin
响应头是因为 CDN 并发请求时,所有响应都会被AbortController
中断,自己封装的Response
的url
参数是空的,所有需要这么个参数进行请求的url
判断。
避免在请求前直接使用cache.matchAll(event.request, {"ignoreSearch": true})
查询缓存,这个方法将查询全部缓存,性能较低,查询一次需要100-200ms
。
五、缓存策略
正如上所述,服务器优先的策略不能享受到缓存的高速,仅能实现网站的离线访问,所以本文优先采用 CDN 并发访问的方式,将请求区分成了三种级别。
- CDN 并发请求:用于使用免费 CDN 这类的请求,如
jsDeliver
的请求,请求后对资源进行缓存,并且缓存优先; - 缓存优先:对于
js/css
这类静态文件,采用缓存优先的方式,如果服务器资源修改则更新请求的版本参数,以此实现缓存更新; - 服务器优先:适用于除一二两点外的所有 GET 请求,对非本站的请求也进行了缓存,为的是实现网站的离线访问。
本文通过 cdnAndCacheList
、onlyCacheList
和 notCacheList
三个正则规则列表进行缓存的区分。notCacheList
匹配不采用缓存优先策略的请求,除此以外,符合 cdnAndCacheList
规则的请求都通过 CDN 并发的方式进行请求,不符合 cdnAndCacheList
规则,但符合 onlyCacheList
规则的请求采用缓存优先的方式进行请求。其他未被匹配到的请求都采用服务器优先的策略进行缓存。
六、sw.min.js完整实现
上述只是列出了各个方面的实现,此处提供 sw.min.js
完整的实现。
(function () {
if (self.document) {
const currentScriptUrl = document.currentScript.src;
const install = new URLSearchParams(currentScriptUrl.split("?")[1]).get("install")
if (install) {
navigator.serviceWorker.register(document.currentScript.src)
.catch(function (error) {
console.log('cache failed with ' + error); // registration failed
});
} else {
console.log('uninstall service worker.')
navigator.serviceWorker.getRegistrations().then(function (registrations) {
for (let registration of registrations) {
registration.active && registration.active.scriptURL && registration.active.scriptURL.indexOf("/sw.min.js") !== -1 && registration.unregister()
}
})
window.caches && caches.keys && caches.keys().then(function (keys) {
keys.forEach(function (key) {
console.log("delete cache", key);
caches.delete(key);
});
});
}
} else {
//可以进行版本修改,删除缓存
const version = "1.0.0";
const cacheName = `Dream-${version}`;
const offLine = new URLSearchParams(location.href.split("?")[1]).get("offLine")
// 需要走cdn和缓存的请求(cdn优先于缓存)
const cdnAndCacheList = [
new RegExp(`${location.origin}/themes`, "i"), //主题目录
/\/\/(unpkg\.com|npm\.elemecdn\.com|cdn\.jsdelivr\.net)\/.*/i, //公共cdn网站
]
//对这里面的请求只走缓存
const onlyCacheList = [
new RegExp(`${location.origin}/upload`, "i"), //图片等附件目录
/\/\/cdn.jsdelivr.net\/gh.*/i, //gh目前没有可用cdn源
];
// 不缓存,不走cdn
const notCacheList = [
new RegExp(`${location.origin}/(admin|api)`, "i"), //管理后台
]
const cdn = {
theme: {
originUrl: `${location.origin}/themes/dream`,
handleRequest: url => {
if (url.indexOf(cdn.theme.originUrl) !== 0) return
const path = url.substring(cdn.theme.originUrl.length)
const version = new URLSearchParams(url.split('?')[1]).get("mew") || 'latest';
return [
url,
...cdn.npm.urlTemplates.map(value => `${value}/halo-theme-dream@${version}${path}`)
]
},
},
npm: {
urlTemplates: [
"https://unpkg.com",
"https://cdn.jsdelivr.net/npm",
"https://npm.elemecdn.com",
],
handleRequest: url => {
return handleUrls(cdn.npm.urlTemplates, url)
}
},
}
/**
* 使用模板替换url路径
*
* @param urlTemplates
* @param url
* @returns {*}
*/
function handleUrls(urlTemplates, url) {
for (let index in urlTemplates) {
if (url.indexOf(urlTemplates[index]) === 0) {
const path = url.substring(urlTemplates[index].length)
return urlTemplates.map(value => value + path);
}
}
}
//添加缓存
self.addEventListener('install', function (event) {
console.log('install service worker.')
event.waitUntil(self.skipWaiting()) //这样会触发activate事件
});
// 激活
self.addEventListener('activate', function (event) {
console.log('service worker activate.')
const mainCache = [cacheName];
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames.map(function (cacheName) {
if (mainCache.indexOf(cacheName) === -1) {//没有找到该版本号下面的缓存
// When it doesn't match any condition, delete it.
console.info('version changed, clean the cache, SW: deleting ' + cacheName);
return caches.delete(cacheName);
}
})
);
})
);
return self.clients.claim();
});
/**
* 判断ur是否符合list列表中的正则要求
*
* @param list
* @param url
* @returns {boolean}
*/
function isExitInCacheList(list, url) {
return list.some(function (value) {
return value.test(url)
})
}
/**
* 判断两个url是否属于同一个请求,过滤掉部分参数
*
* @param urla
* @param urlb
* @returns {boolean}
*/
function isSameRequest(urla, urlb) {
// 除了这这些参数,其它的查询参数必须要一致,才认为是同一个请求
const white_query = new Set([
"mew", // 自定义的版本号
"v",
"version",
"t",
"time",
"ts",
"timestamp"
]);
const a_url = urla.split('?');
const b_url = urlb.split('?');
if (a_url[0] !== b_url[0]) {
return false;
}
const a_params = new URLSearchParams('?' + a_url[1]);
const b_params = new URLSearchParams('?' + b_url[1]);
// 显示所有的键
for (const key of a_params.keys()) {
if (white_query.has(key)) {//对于版本号的key 忽略
continue;
}
if (a_params.get(key) !== b_params.get(key)) {//其它key的值必须相等,比如type=POST 这种
return false;
}
}
return true;
}
// 拦截请求使用缓存的内容
self.addEventListener('fetch', function (event) {
if (event.request.method !== "GET") {
return false;
}
const isCdnAndCache = isExitInCacheList(cdnAndCacheList, event.request.url);
// 不符合缓存要求的
if (!(isCdnAndCache || isExitInCacheList(onlyCacheList, event.request.url)) || isExitInCacheList(notCacheList, event.request.url)) {
if (!offLine) { // 不需要离线
return false
}
// return false;
event.respondWith(caches.open(cacheName)
.then(cache => {
return fetch(event.request)
.then((response) => {
if (response.status === 200) cache.put(event.request, response.clone());
return response
})
.catch(() => cache.match(event.request))
})
);
return true;
}
// 劫持 HTTP Request
event.respondWith(
caches.open(cacheName).then(function (cache) {
// ignoreSearch 忽略请求参数进行查找,用于匹配不同版本
return cache.match(event.request).then(function (cacheResponse) {
// 直接返回缓存
if (cacheResponse) return cacheResponse;
return handleRequest(event.request, isCdnAndCache)
.then((response) => {
const responseClone = response.clone();
cache.matchAll(event.request, {"ignoreSearch": true})
.then(function (cache_response_list) {
// 删除旧版本的缓存文件
if (cache_response_list) {
for (const cache_response of cache_response_list) {
const responseUrl = cache_response.url || cache_response.headers.get('service-worker-origin')
if (isSameRequest(responseUrl, event.request.url)) {
cache.delete(responseUrl)
}
}
}
cache.put(event.request, responseClone);
})
return response;
})
.catch(error => {
console.error(error)
return cache.matchAll(event.request, {"ignoreSearch": true})
.then(function (cache_response_list) {
// 从缓存中取得历史版本的文件
if (cache_response_list) {
for (const cache_response of cache_response_list) {
if (isSameRequest(cache_response.url || cache_response.headers.get('service-worker-origin'), event.request.url)) {
return cache_response
}
}
}
})
});
})
})
);
});
/**
* 处理请求
* @param req
* @param isCdnAndCache
* @returns {Promise<Response>|*}
*/
function handleRequest(req, isCdnAndCache) {
// 不是cdn缓存的话,直接进行查询并返回
if (!isCdnAndCache) return fetch(req);
const reqUrl = req.url;
// 匹配 cdn
for (const type in cdn) {
const urls = cdn[type].handleRequest(reqUrl)
if (urls) return fetchAny(reqUrl, urls);
}
// 没有匹配到url,直接发起请求
return fetch(req);
}
// 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(originUrl, urls) {
// 中断一个或多个请求
const controller = new AbortController();
const signal = controller.signal;
// 遍历将所有的请求地址转换为promise
const PromiseAll = urls.map((url) => {
return new Promise(async (resolve, reject) => {
fetch(url, {signal})
.then(async res => { // 重新封装响应
const newHeaders = new Headers(res.headers)
newHeaders.set('service-worker-origin', originUrl)
return new Response(await res.arrayBuffer(), {
status: res.status,
headers: newHeaders,
});
})
.then((res) => {
if (res.status !== 200) reject(null);
controller.abort(); // 中断
resolve(res);
})
.catch(() => reject(null)); // 去除中断的错误信息
});
});
// 判断浏览器是否支持 Promise.any
if (!Promise.any) createPromiseAny();
// 谁先返回"成功状态"则返回谁的内容,如果都返回"失败状态"则返回null
return Promise.any(PromiseAll).catch(() => null);
}
}
})();
使用方法:
通过 script
标签进行引用,支持 offLine
和 install
两个 GET 参数:
offLine:是否开启服务器优先的缓存
install:默认操作为移除 service worker
,添加该参数后将安装 sw.min.js
。
<!-- 安装 sw -->
<script src="/sw.min.js?offLine=true&mew=1.0.4&install=true"></script>
<!-- 移除 -->
<script src="/sw.min.js?offLine=true&mew=1.0.4"></script>