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 事件中进行缓存。

  1. install 阶段缓存的话,需要提供一个需要缓存的资源列表,然后将这个列表中的资源进行缓存;
  2. 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 服务,例如 jsDelivernpm/gh 等提供服务,unpkgnpm 提供 CDN 服务,他们针对的是不同的开源库,要实现这些免费 CDN 间的切换,那么就需要对这些 CDN 的类型进行区分。

区分 CDN 类型主要是针对于对免费CDN的使用,他们通过一个固定的路径格式进行请求,区分类型即可实现多个 CDN 请求路径的拼接。
如果采用的是自建 CDN,或者是其他一些定制化的路径这种区分类型的方式就不合适了,可能 FreeCDN 这种生成摘要文件的方式会更好一些。

那么如何区分呢?本文主要以匹配请求的路径前缀部分的主机名和路径进行 CDN 的区分,并将 CDN 分为了 themenpm 两种类型,代码如下:

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.anyAbortController

四、缓存版本控制

服务器资源可能是不断升级迭代的,缓存可能会不断的过期失效,那么我们就需要对旧版本的缓存进行删除,然后使用新的版本。

缓存版本控制这块本文分成了两方面,一方面是缓存存储桶名称上添加了版本号,对于失效版本的缓存在 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 中断,自己封装的 Responseurl 参数是空的,所有需要这么个参数进行请求的 url 判断。
避免在请求前直接使用 cache.matchAll(event.request, {"ignoreSearch": true}) 查询缓存,这个方法将查询全部缓存,性能较低,查询一次需要 100-200ms

五、缓存策略

正如上所述,服务器优先的策略不能享受到缓存的高速,仅能实现网站的离线访问,所以本文优先采用 CDN 并发访问的方式,将请求区分成了三种级别。

  1. CDN 并发请求:用于使用免费 CDN 这类的请求,如 jsDeliver 的请求,请求后对资源进行缓存,并且缓存优先;
  2. 缓存优先:对于 js/css 这类静态文件,采用缓存优先的方式,如果服务器资源修改则更新请求的版本参数,以此实现缓存更新;
  3. 服务器优先:适用于除一二两点外的所有 GET 请求,对非本站的请求也进行了缓存,为的是实现网站的离线访问。

本文通过 cdnAndCacheListonlyCacheListnotCacheList 三个正则规则列表进行缓存的区分。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 标签进行引用,支持 offLineinstall 两个 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>