ServiceWorker
担任了服务器与浏览器的中间人角色,如果网站中注册了 ServiceWorker
那么它可以拦截当前网所有的请求,并做相应的处理动作。
ServiceWorker
内容就是一段 JavaScript
脚本,内部可以编写相应的处理逻辑,比如对请求进行缓存处理,能直接使用缓存的就直接返回缓存不再转给服务器,从而大大提高浏览体验。有些开源工具包存在多个 CDN
站点,使用 ServiceWorker
可以实现自动寻找访问最快的站点,如果某个站点发生错误,可以自动切换,FreeCDN
便是借此实现的。
一、概念
ServiceWorker
主要特性有:
-
ServiceWorker
是一个独立于JavaScript
主线程的独立线程,在里面执行需要消耗大量资源的操作不会堵塞主线程; - 具有离线缓存的能力,可以访问
cache
,可以让开发者自己控制和管理缓存内容和版本; - 支持消息推送。
由于 ServiceWorker
的这些特殊功能,导致其有比较高的安全性控制。
-
ServiceWorker
脚本的路径与当前站点同不能跨域; -
ServiceWorker
的作用域必须小于或等于ServiceWorker
脚本所在的路径,如脚本在js/
路径下,则安装后的ServiceWorker
最多也只能在该路径下生效; - 如果站点是
Https
站点,那么ServiceWorker
脚本也必须是Https
,并且证书必须针对当前域名有效; - 或许还有其他限制……
ServiceWorker
采用事件触发的机制,接触比较多的主要有三个事件:
-
install
事件,在脚本加载完成是执行; -
active
事件,在脚本激活时执行,一般在此处通过调用self.clients.claim( )
取得页面的控制权,也可以做一些缓存的更新操作。 -
fetch
事件,拦截到请求时执行,ServiceWorker
最为重要的部分,决定了是采用缓存还是发起新的请求。
二、两个 ServiceWorker 的脚本实现
脚本来源于网络,本文摘抄记录了一下。
2.1 脚本一
该 ServiceWorker
脚本主要作用是用于监听 GitHub
、combine
、npm
开源库的访问,识别某个请求是否属于某个开源库,如果是的话将对该库的多个 CDN 站点同时发起请求,然后选响应最快的一个站点。
同时也具备脚本缓存功能,但不是很完善。
const cacheStorageKey = "check-dream-2.0"
const origin = [
"https://blog.nineya.com/",
];
const cdn = {
gh: {
jsdelivr: "https://cdn.jsdelivr.net/gh",
pigax_jsd: "https://u.pigax.cn/gh",
pigax_chenyfan_jsd: "https://cdn-jsd.pigax.cn/gh",
tianli: "https://cdn1.tianli0.top/gh",
},
combine: {
jsdelivr: "https://cdn.jsdelivr.net/combine",
pigax_jsd: "https://u.pigax.cn/combine",
pigax_chenyfan_jsd: "https://cdn-jsd.pigax.cn/combine",
tianli: "https://cdn1.tianli0.top/combine",
},
npm: {
eleme: "https://npm.elemecdn.com",
jsdelivr: "https://cdn.jsdelivr.net/npm",
zhimg: "https://unpkg.zhimg.com",
unpkg: "https://unpkg.com",
pigax_jsd: "https://u.pigax.cn/npm",
pigax_unpkg: "https://unpkg.pigax.cn/",
pigax_chenyfan_jsd: "https://cdn-jsd.pigax.cn/npm",
tianli: "https://cdn1.tianli0.top/npm",
},
};
// 脚本加载完毕执行时
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(cacheStorageKey)
.then(function () {
return self.skipWaiting();
})
)
});
// 监听所有请求
self.addEventListener("activate", (event) => {
event.waitUntil(
//获取所有cache名称
caches.keys().then(function (cacheNames) {
return Promise.all(
//移除不是该版本的所有资源
cacheNames.filter(function (cacheName) {
return cacheName !== cacheStorageKey
}).map(function (cacheName) {
return caches.delete(cacheName)
})
)
}).then(function () {
//在新安装的 SW 中通过调用 self.clients.claim( ) 取得页面的控制权,这样之后打开页面都会使用版本更新的缓存。
return self.clients.claim()
})
)
});
self.addEventListener("fetch", (event) => {
event.respondWith(caches.match(event.request).then(response => {
if (response != null) {
return response;
}
handleRequest(event.request)
.then((result) => {
caches
.open(cacheStorageKey)
.then(cache => {
cache.put(event.request, result)
});
return result;
})
.catch(() => 0);
}))
});
// 返回响应
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 = [location.origin, origin];
// 遍历判断当前请求是否是源站主机
const is = origins.find((i) => {
const {hostname} = new URL(i);
const reg = new RegExp(hostname);
return urlStr.match(reg);
});
// 不是源站则直接请求返回结果
if (!is) return fetch(urlStr).then(progress);
// 如果以上都不是,则将当前访问的url参数追加到所有源站后,统一请求。
// 谁先返回则使用谁的返回结果
origins = origins.map((i) => i + urlObj.pathname + urlObj.search);
return fetchAny(origins);
}
// 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(async (resolve, reject) => {
fetch(url, {signal})
.then(progress)
.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)
.then(res => res)
.catch(() => null);
}
2.2 脚本二
该脚本主要功能是对指定的路径进行本地缓存,并可以对缓存进行版本指定。
var assetsToCache = [];
//对request url 进行匹配的,而不是当前的页面地址匹配
const caceheList = [
"themes/",//handsome内置js
"upload/",// 文章中的图片
"vditor",
"jquery",
"bootstrap",
"mathjax",
"mdui",
"?action=get_search_cache",
"hm.js" //百度统计js
];
const notCacheList = [
"/admin/"
]
//添加缓存
self.addEventListener('install', function(event) {
event.waitUntil(self.skipWaiting()) //这样会触发activate事件
});
// self.addEventListener('message', function (event) {
// console.log("recv message" + event.data);
// if (event.data === 'skipWaiting') {
// self.skipWaiting();
// console.log("skipwaiting");
// }
// })
//可以进行版本修改,删除缓存
var version = "9.0.0";
var versionTag = "62625b61dfa93";
var CACHE_NAME = version+versionTag;
self.addEventListener('activate', function(event) {
// console.log('activated!');
var mainCache = [CACHE_NAME];
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();
});
function isExitInCacheList(list,url){
return list.some(function (value) {
return url.indexOf(value) !== -1
})
}
var CDN_ADD = "" //博客本地图片资源替换
var BLOG_URL = "https://www.ihewro.com" //博客本地图片资源替换
function fetchLocal(event){
// console.log("fectch error",CDN_ADD,BLOG_URL)
// 判断地址前缀是否是CDN_ADD,进行回退
if (CDN_ADD!="" && BLOG_URL!= CDN_ADD && event.request.url.indexOf(CDN_ADD)!==-1){
const new_request_url = event.request.url.replace(CDN_ADD,BLOG_URL);
return caches.open(CACHE_NAME).then(function(cache) {
return fetch(new_request_url).then(function (response) {
if (response.status < 400) {//回退成功,则进行缓存,这个地方肯定是可以获取到status因为地址替换成本地的了
// console.log("【yes2】 put in the cache" + event.request.url);
console.log("fetch retry [success],old_url:%s ,new_url:%s",event.request.url,new_request_url);
cache.put(event.request, response.clone());
}else{
console.warn("fetch retry [error:%s],old_url:%s,new_url:%s",response.status,event.request.url,new_request_url);
}
// console.log(response);
return response;
}).catch(function (error){
console.warn("fetch retry [error2:%s],old_url:%s,new_url:%s",error,event.request.url,new_request_url);
// throw error;
});
})
}else{
console.warn("fetch error and [not retry]",event.request.url);
return false;
}
}
function is_same_request(urla,urlb){
const white_query =new Set([// 除了这这些参数,其它的查询参数必须要一致,才认为是同一个请求
"t",
"v",
"version",
"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 (var 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;
}
function getMatchRequestResponse(cache_response_list,request_url) {
if (cache_response_list){
for (const cache_response of cache_response_list) {
// console.log(cache_response.url,request_url)
if (is_same_request(cache_response.url,request_url)){
return cache_response;
}
}
}
return null;
}
// 拦截请求使用缓存的内容
self.addEventListener('fetch', function(event) {
// console.log('Handling fetch event for', event.request.url);
if(event.request.method !== "GET") {
return false;
}else{
if (isExitInCacheList(caceheList, event.request.url) && !isExitInCacheList(notCacheList, event.request.url)){
// 只捕获需要加入cache的请求
// 劫持 HTTP Request
// console.log(event.request.url);
event.respondWith(
caches.open(CACHE_NAME).then(function(cache) {
// var start = performance.now();
// return cache.match(event.request,{"ignoreSearch":true}).then(function(cache_response) {
return cache.matchAll(event.request,{"ignoreSearch":true}).then(function(cache_response_list) {
const cache_response = getMatchRequestResponse(cache_response_list,event.request.url);
// var end = performance.now();
// console.log("match all cost:",end - start,"ms",event.request.url)
if (cache_response && cache_response.url === event.request.url) {//地址(包含查询参数)完全一致才返回缓存
// 使用 Service Worker 回應
// console.log("【cache】use the cache " + event.request.url)
return cache_response;
} else {
// console.log("not use cache",event.request.url);
return fetch(event.request)
.then(function(response) {
//判断查询参数里面是否存在type参数,如果存在
// 由于跨域访问导致获得response是非透明响应无法获取响应码(响应码是0
//https://fetch.spec.whatwg.org/#concept-filtered-response-opaque
if (response.status < 400){//对于响应码为0,暂时无法进一步判断,只能全部认为加载成功
//跨域的地址 服务器端的错误目前不会回退,只能直接加到cache里面,如果服务器问题解除需要更新缓存
// console.log("【yes】 put in the cache" + event.request.url);
if (cache_response && is_same_request(cache_response.url,event.request.url)){//删除旧版本号的资源
// console.log("存在缓存,但是可查询的字符串版本号不一致,所以需要删除缓存",cache_response.url,event.request.url)
cache.delete(cache_response.url);
}
cache.put(event.request, response.clone());
}else{
console.warn("response is not ok",response.status,response.statusText,event.request.url);
const new_response = fetchLocal(event);
if (new_response){
return new_response;
}else {//在获取response 失败的时候,优先考虑可以回退旧版本的response里面
if (cache_response && is_same_request(cache_response.url,event.request.url)){
return cache_response;
}
return response;
}
}
return response;
})
.catch(function(error) {
console.error(error)
const response = fetchLocal(event);
if (response){
return response;
}else {
// console.log('Fetching request url ,' +event.request.url+' failed:', error);
// throw error;
}
});
}
})
})
);
}else{
return false;
}
}
});