​ServiceWorker​​​ 担任了服务器与浏览器的中间人角色,如果网站中注册了 ​​ServiceWorker​​ 那么它可以拦截当前网所有的请求,并做相应的处理动作。

​ServiceWorker​​​ 内容就是一段 ​​JavaScript​​​ 脚本,内部可以编写相应的处理逻辑,比如对请求进行缓存处理,能直接使用缓存的就直接返回缓存不再转给服务器,从而大大提高浏览体验。有些开源工具包存在多个 ​​CDN​​​ 站点,使用 ​​ServiceWorker​​​ 可以实现自动寻找访问最快的站点,如果某个站点发生错误,可以自动切换,​​FreeCDN​​ 便是借此实现的。

一、概念

​ServiceWorker​​ 主要特性有:

  1. ​ServiceWorker​​​ 是一个独立于​​JavaScript​​ 主线程的独立线程,在里面执行需要消耗大量资源的操作不会堵塞主线程;
  2. 具有离线缓存的能力,可以访问​​cache​​,可以让开发者自己控制和管理缓存内容和版本;
  3. 支持消息推送。

由于 ​​ServiceWorker​​ 的这些特殊功能,导致其有比较高的安全性控制。

  1. ​ServiceWorker​​ 脚本的路径与当前站点同不能跨域;
  2. ​ServiceWorker​​​ 的作用域必须小于或等于​​ServiceWorker​​​ 脚本所在的路径,如脚本在​​js/​​​ 路径下,则安装后的​​ServiceWorker​​ 最多也只能在该路径下生效;
  3. 如果站点是​​Https​​​ 站点,那么​​ServiceWorker​​​ 脚本也必须是​​Https​​,并且证书必须针对当前域名有效;
  4. 或许还有其他限制……

​ServiceWorker​​ 采用事件触发的机制,接触比较多的主要有三个事件:

  1. ​install​​ 事件,在脚本加载完成是执行;
  2. ​active​​​ 事件,在脚本激活时执行,一般在此处通过调用​​self.clients.claim( )​​ 取得页面的控制权,也可以做一些缓存的更新操作。
  3. ​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;
}
}
});