R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

Service Worker 踩坑指南(缓存与 WebP)

背景

前天晚上在清理 Chrome 插件的时候,发现了好久之前因好奇而安装的 Lighthouse 插件,它是 Chrome 团队出的一个自动化页面审计工具,可以帮助开发者优化页面。继续怀着好奇的心理,我打开了我的网站主页,然后点了一下 Generate report 按钮,得分惨不忍睹……

0

嗯由于特别惨不忍睹,所以我就不放结果了,放一张 Lighthouse 的界面代替好了……当然,优化的过程倒是不算太难,按照网上搜到的教程添加 Service Worker 和 Manifest,然后给一些标签加上推荐的属性就可以了。

由于其它的内容都没什么难度,因此我便把研究的目光转向了 Service Worker。

什么是 Service Worker

网上到处都是,不赘述了……不过有几点值得注意:

  1. 它与 Web Worker 是完全不同的两个东西,Service Worker 完全只是一个浏览器与服务器之间的代理;
  2. Service Worker 必须运行在可信的域名下,而且还有严格的 Scope 的限制,毕竟它可以拦截并修改所有的数据,如果被黑客植入网站,甚至可以用来做与“缓存投毒”相似的攻击。

如何调试 Service Worker

刚才提到 Service Worker 必须运行在可信的域名下(否则会报 Error),除了 HTTPS 以外就只有 localhost 了,然而我的网站是通过域名来加载不同子站的,因此 localhost 无法使用。上网查了一会儿,发现可以通过添加如下的两个参数启动 Chrome:

./chrome --user-data-dir=/path/to/your/tmpdir --unsafely-treat-insecure-origin-as-secure=http://your-dev-host

其中 unsafely-treat-insecure-origin-as-secure 大家都明白,就不讲了;要注意这里的 user-data-dir 是必选的,可能是 Chrome 认为随便指定一个可信域名太危险了,必须要把这个进程的目录与正常的目录隔离开。此时再进入 http://your-dev-host,就能正常注册 Service Worker 啦!

打开的 Chrome 窗口中会有一行提示,说“你这样操作会降低安全性”,所以平时千万不要这么做。我在调试完成、关掉这个 Chrome 进程后,也把上面指定的临时文件夹删掉了。

Service Worker 毕竟也是一段 JS,因此调试方法与普通 JS 一致,可以下断点可以加 debugger,只是它 console.log 的位置不太一样,在 Console 面板的最上方可以点击显示着 top 的那个下拉框,找到对应的选项即可。

通过 Service Worker 实现缓存

其实这个网上也到处都是,然而由于有着严格的安全限制,因此我要想把它种到每个域名的根路径中,需要对 Nginx 做一些配置(虽然也不算难就是了):

server {
    listen       80;
    server_name  ~^(?<subdomain>.+)\.rexskz\.info$;
    location /sw.js {
        root /path/to/wwwroot/sw;
    }
    # several lines hidden...
    location / {
        root /path/to/wwwroot/$subdomain;
    }
}

至于 sw.js 的内容,我一开始是直接抄的 这里,很好用,只要把里面的 offlineResourcesignoreFetch 两个变量结合自己网站的情况改一改就行了。这段代码的思路大概是:当 Service Worker 收到一个 Fetch 请求的时候,会去做如下检查:

  1. 对于符合 ignoreFetch 中任意一个正则的网址,或者非 GET 请求,始终拉取新数据,拉取失败时显示 offlineResources 中对应的内容;
  2. 对于 Accept 头中包含 text/html 的请求,优先拉取新数据,拉取失败时使用缓存数据,若没有缓存则显示 offlineResources 中对应的内容;
  3. 对于其它资源,优先使用缓存数据,若没有缓存则拉取新数据,拉取失败时显示 offlineResources 中对应的内容。

怎么样,是不是很简单呢?

使用 Service Worker 修改图片请求

WebP 是一个大家都知道、但某些浏览器打死也不支持的图片格式。虽然网上有很多解决方案,但是对于个人网站来说,全站适配 WebP 的成本还是挺高的,例如我的这个技术博客用的是 Typecho,但 狗粮站 用的是 Ghost,还有其它的一些奇怪的网页。因此在折腾 Service Worker 的时候,我在想,既然这玩意儿能充当代理的角色,那肯定也可以把图片格式转换一下咯?

七牛图床的解决方案

反正我用的全都是七牛的图床,图片后缀也只有 JPG 和 PNG,转 WebP 只需要在 URL 后面加点东西就好了:

function onFetch(event) {
    const request = event.request;
    if (/\.jpg$|\.png$/.test(request.url)) {
        // 只在支持 WebP 的浏览器下修改 URL
        // 至于某狐、某边和某动物园目前是没法享受这种格式了
        if (request.headers.has('accept') && /webp/.test(request.headers.get('accept'))) {
            switch (request.url.match(/\.([a-z]+)$/i)[1]) {
                case 'jpg':
                    newUrl = request.url + '?imageMogr2/format/webp/quality/95';
                    break;
                case 'png':
                    newUrl = request.url + '?imageMogr2/format/webp/quality/100';
                    break;
            }
        }
    }
    // several lines hidden...
    // 问题:如何用 newUrl 替换掉 event.request 中的 URL
}

问题出现!我的第一反应是看看代码中有没有现成的方案,找到了这一段:

// 这里居然用了 fetch!
return fetch(request).then(response => {
    log('(network)', request.method, request.url);
    return response;
}).catch(() => {
    return offlineResponse(request);
});

原来 Request 跟最新的 Fetch API 是相辅相成的!我激动得写下了如下代码:

let request = event.request;
// newUrl = xxx
event.respondWith(fetch(newUrl));

然后……就出现了跨域问题。

跨域问题的解决

我又去翻了一下 Request 的文档,发现可以用它的构造函数造出个新的 request 然后直接替掉:

let request = event.request;
// newUrl = xxx
request = new Request(newUrl, request);

在想为什么可以跨域,于是发现 Request 中有一个叫 mode 的属性,还是可以在构造函数中自定义的,于是心里咯噔了一下:Request 难道可以用来跨域?于是我在 F12 的 Console 里试了试:

// 当前页面的域名是另一个
req = new Request('https://www.rexskz.info/manifest.json', { mode: 'no-cors' })
await fetch(req)

然后就被 Chrome 的 CORB(嗯对,不是大家熟悉的 CORS)给拦住了。看来还是没问题的嘛。

P.S. CORB 是 Chrome 用来降低侧信道攻击的危害而搞出来的东西。

一个无法优雅解决的问题

Service Worker 的注册是异步的,而且经过了那么多次的试验,它似乎总是在最后才加载完成,那岂不是意味着第一次访问我的网站时还是加载的原始格式的图片?于是我开始想办法如何让它阻塞的加载。

文档 发现,可以在注册后的 then 中监听它的状态,如果是 activated 则表示可以使用了(可以在这儿提示用户“页面已缓存”)。于是我脑洞大开,想一开始把 <body> 中的东西都设成 display: none,加载完之后再搞回来。然而我还是太年轻,浏览器看到这样隐藏的元素,有时候也会加载其中的图片资源,具体可以看参考一下 《Web 图片资源的加载与渲染时机》,因此我只能通过去掉 DOM 的方式来阻止图片的加载。

对于 Vue 等框架编写的网站来说很简单,只需要用一个条件(例如 v-if)来控制 DOM 是否出现即可,但对于我的网站来说,暂时没有特别优雅的实现方法。虽然可以一开始将网页渲染成这样:

<body>
    <!-- 我瞎编的,只要不是 text/javascript 就行 -->
    <script type="text/sw-placeholder">
        原本要渲染的 HTML 内容
    </script>
</body>

然后当 activated 的时候将里面的内容提出来扔到 <body> 中,但是这样实在是太不优雅,所以还是算了。

当然,即使没有解决这个问题,网站也得到了很好的优化,毕竟 WebP 体积特别小,在 Service Worker 的缓存空间有限的情况下,可以多存储一些文件。


最后附上一张图,是我现在网站的 Lighthouse 报告:

1

我已经很满意了,剩下的优化有时间再去折腾吧。

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。

这是我们共同度过的

第 1114 天