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 脚本主要作用是用于监听 GitHubcombinenpm 开源库的访问,识别某个请求是否属于某个开源库,如果是的话将对该库的多个 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;
        }
    }
});