浏览器缓存、CacheStorage、Web Worker 与 Service Worker

@youngwind 2018-02-06 04:48:05发表于 youngwind/blog

前言

最近在翻红宝书,看到 Web Worker 那章,猛然意识到,通过它竟然可以把几个缓存相关的概念串起来,甚是有趣,撰文记之。最后我也写了一个完整的离线应用 Demo,以供运行调试。

浏览器缓存

传统意义上的浏览器缓存,分为强缓存协商缓存,其共同点都是通过设置 HTTP Header 实现。关于两者的异同已经被讨论得很多,我就不赘述了,附两个参考资料。

  1. 浏览器的缓存机制, By Aitter
  2. http协商缓存VS强缓存, By wonyun

这种浏览器缓存(我称之为 Header 缓存)有两个共同的缺点:

  1. 当没有网络的时候,应用无法访问,因为 HTML 页面总得去服务器获取。
  2. 缓存不可编程,无法通过 JS 来精细地对缓存进行增删改查。

应用缓存

为了在无网络下也能访问应用,HTML5 规范中设计了应用缓存(Application Cache)这么一个新的概念。通过它,我们可以做离线应用。然而,由于这个 API 的设计有太多的缺陷,被很多人吐槽,最终被废弃。废弃的原因可以看看这些讨论:

  1. 为什么app cache没有得到大规模应用?它有哪些硬伤吗?
  2. Application Cache is a Douchebag, By Jake Archibald

PS:我当年毕设也用到过这种技术,没想到短短几年就被废弃了,技术迭代何其之快也!

CacheStorage

为了能够精细地、可编程地控制缓存,CacheStorage 被设计出来。有了它,就可以用 JS 对缓存进行增删改查,你也可以在 Chrome 的 DevTools 里面直观地查看。对于传统的 Header 缓存,你是没法知道有哪些缓存,更加没法对缓存进行操作的。你只能被动地修改 URL 让浏览器抛弃旧的缓存,使用新的资源。

image

PS:CacheStorage 并非只有在 Service Worker 中才能用,它是一个全局性的 API,你在控制台中也可以访问到 caches 全局变量。

Web Worker

一直以来,一个网页只会有两个线程:GUI 渲染线程和 JS 引擎线程。即便你的 JS 写得再天花乱坠,也只能在一个进程里面执行。然而,JS 引擎线程和 GUI 渲染线程是互斥的,因此在 JS 执行的时候,UI 页面会被阻塞住。为了在进行高耗时 JS 运算时,UI 页面仍可用,那么就得另外开辟一个独立的 JS 线程来运行这些高耗时的 JS 代码,这就是 Web Worker

Web Worker 有两个特点:

  1. 只能服务于新建它的页面,不同页面之间不能共享同一个 Web Worker。
  2. 当页面关闭时,该页面新建的 Web Worker 也会随之关闭,不会常驻在浏览器中。

PS:还有一个相关的概念:Shared Worker,不过这个东西比较复杂,我并未深入研究,感兴趣的读者可以了解,也可以看看 Shared Worker 跟 Service Worker 的区别

Service Worker

终于说到本文的主角了。Service Worker 与 Web Worker 相比,相同点是:它们都是在常规的 JS 引擎线程以外开辟了新的 JS 线程。不同点主要包括以下几点:

  1. Service Worker 不是服务于某个特定页面的,而是服务于多个页面的。(按照同源策略)
  2. Service Worker 会常驻在浏览器中,即便注册它的页面已经关闭,Service Worker 也不会停止。本质上它是一个后台线程,只有你主动终结,或者浏览器回收,这个线程才会结束。
  3. 生命周期、可调用的 API 等等也有很大的不同。

总而言之,Service Worker 是 Web Worker 进一步发展的产物。关于如何使用 Service Worker,可以参考下面的资料。

  1. 借助Service Worker和cacheStorage缓存及离线开发, By 张鑫旭
  2. 使用Service Worker做一个PWA离线网页应用, By 会编程的银猪
  3. 【译】理解Service Worker, 作者 By Adnan Chowdhury, 译者 By 安秦

我也写了一个 Service Worker 用作离线应用的 Demo,大家可以调试观察。下面我们讨论几个 Service Worker 容易被忽略的地方,以我的 Demo 为例。

Service Worker 只是 Service Worker

一开始我以为 Service Worker 就是用来做离线应用的,后来渐渐研究才发现不是这样的。→ Service Worker 只是一个常驻在浏览器中的 JS 线程,它本身做不了什么。它能做什么,全看跟哪些 API 搭配使用

  1. 跟 Fetch 搭配,可以从浏览器层面拦截请求,做数据 mock;
  2. 跟 Fetch 和 CacheStorage 搭配,可以做离线应用;
  3. 跟 Push 和 Notification 搭配,可以做像 Native APP 那样的消息推送,这方面可以参考 villainhr 的文章:Web 推送技术
  4. ……

假如把这些技术融合在一起,再加上 Manifest 等,就差不多成了 PWA 了。
总之,Service Worker 是一种非常关键的技术,有了它,我们能更接近浏览器底层,能做更多的事情。

The idea is that we, as browser developers, acknowledge that we are not better at web development than web developers. And as such, we shouldn't provide narrow high-level APIs that solve a particular problem using patterns we like, and instead give you access to the guts of the browser and let you do it how you want, in a way that works best for your users.

出处:https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle#handling_updates

初次访问不会触发 fetch 事件

按照官方给的 Demo,Service Worker 注册的代码是放在 HTML 的最后。但是,当我尝试把 Service Worker 的注册代码提到最开头,并且 console 出时间戳,我发现一个现象:即便 Service Worker 注册成功之后再请求资源,这些资源也不会触发 fetch 请求,只有再次访问页面才会触发 fetch 事件。这是为什么呢?后来我在官方文档中找到了答案:如果你的页面加载时没有 Service Worker,那么它所依赖的其他资源请求也不会触发 fetch 事件

The first time you load the demo, even though dog.svg is requested long after the service worker activates, it doesn't handle the request, and you still see the image of the dog. The default is consistency, if your page loads without a service worker, neither will its subresources. If you load the demo a second time (in other words, refresh the page), it'll be controlled. Both the page and the image will go through fetch events, and you'll see a cat instead.

出处:https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle#activate

cache.add VS cache.put

在 install 事件中用 cache.addAll,在 fetch 事件中用 cache.put,add 和 put 有什么区别吗?→ cache.add = fetch + cache.put

The add() method of the Cache interface takes a URL, retrieves it, and adds the resulting response object to the given cache. The add() method is functionally equivalent to the following:

fetch(url).then(function(response) {
  if (!response.ok) {
    throw new TypeError('bad response status');
  }
  return cache.put(url, response);
})

出处:https://developer.mozilla.org/en-US/docs/Web/API/Cache/add

event.waitUntil 和 event.respondWith

先说 event.waitUntil

  1. 只能在 Service Worker 的 install 或者 activate 事件中使用;
  2. 看起来像是一个 callback,但是,即便你不使用它,程序也可能正常运行。如果你传递了一个 Promise 给它,那么只有当该 Promise resolved 时,Service Worker 才会完成 install;如果 Promise rejected 掉,那么整个 Service Worker 便会被废弃掉。因此,cache.addAll 里面,只要有一个资源获取失败,整个 Service Worker 便会失效。

再说 event.respondWith

  1. 只能在 Service Worker 的 fetch 事件中使用;
  2. 作用相当于一个 callback,当传入的 Promise resolved 之后,才会将对应的 response 返回给浏览器。

总之,虽然 event.waitUntil 和 event.respondWith 中的 event 都是继承于 Event 类,但是它们与常见的 event 对象差异很大,这些方法也只有在 Service Worker 的那些对应的事件中才存在

资源的更新

以前我们用强缓存的时候,如果资源需要更新,那么我们只需要改变资源的 URL,换上新的 MD5 戳就好了。如果使用 Service Worker + CacheStorage + Fetch 做离线应用,又该如何处理资源的更新呢?

  1. 当有任何的资源(HTML、JS、Image、甚至是 sw.js 本身)需要更新时,都需要改变 sw.js。因为有了 sw.js,整个应用的入口变成了 sw.js,而非原先的 HTML。每当用户访问页面时,不管你当前是不是命中了缓存,浏览器都会请求 sw.js,然后将新旧 sw.js 进行字节对比,如果不一样,说明需要更新。因此,你能看到在 Demo 中,我们有一个 VERSION 字段,它不仅代表 sw.js 本身的版本,更代表整个应用的版本

  2. 不要试图通过改变 sw.js 的名字(如改成 sw_v2.js)来触发浏览器的更新,因为 HTML 本身会被 sw.js 缓存,而缓存的 HTML 中永远都指向 sw.js,导致浏览器无法得知 sw_v2.js 的更新。虽然,你可以像上面提到的文章:使用Service Worker做一个PWA离线网页应用 那样,再结合其他的手段来判断 HTML 的更新状态,但是会更加复杂,官方并不推荐。

    you may consider giving each version of your service worker a unique URL. Don't do this! This is usually bad practice for service workers, just update the script at its current location.

    出处:https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle#avoid_changing_the_url_of_your_service_worker_script

  3. 每次 sw.js 的更新,都会根据 VERSION 字段新建一个缓存空间,然后把新的资源缓存在里面。等到旧的 sw.js 所控制的网页全部关闭之后,新的 sw.js 会被激活,然后 在 activate 事件中删除旧缓存空间。这样既能保证在同时打开多个网页时更新 sw.js 不出差错,也能及时删除冗余的缓存

双重缓存

上面我们谈到,当新的 sw.js install 的时候,会重新 fetch addAll 里面的所有资源,不管里面的资源是否需要更新,这显然违背了 Web 增量下载的原则,怎么办呢? → 结合使用强缓存和 Service Worker,做一个双重缓存。强缓存在前, Service Worker 在后。举个例子,假如有两个强缓存 a_v1.js 和 b_v1.js,现在 a 不变,b 要改成 b_v2.js,修改 sw.js 的 addAll 和 VERSION。当新的 sw.js install 的时候,addAll 要 fetch a_v1.js ,但是浏览器发现 a_v1.js 是强缓存,所以根本不会发起网络请求,只有 b_v2.js 才会发起网络请求。具体的可以调试我的 Demo 查看现象。

关于这种方法,有两点要说明一下。

  1. 需要在 cache.addAll 中指定资源的版本号,就如同在 html 中指定那般。因为在使用 Service Worker 之后,HTML 只是加载资源的入口,判断资源是否改变的功能,已经转移到 sw.js 中了。
    return cache.addAll([
        './',
        'getList',
        'img/avatar_v1.jpg',
        'js/index_v2.js',
        'js/jquery_v1.js'
    ]);
  2. 上面提到的文章:使用Service Worker做一个PWA离线网页应用 中也有提到这种多重缓存的做法,但是作者认为浏览器会先读取 Service Worker,没有的话才会读取强缓存,这与我的 Demo 实践结果不相符。

总结

写到这儿,也差不多结束了,对于 Service Worker,我还有很多不懂的地方。围绕着 Service Worker 的这一系列新兴 API,代表着更好的 Web 体验,也代表着 Web 的未来,以后仍需多加关注学习。