最近大家针对preload、HTTP/2 push和ServiceWorker的浏览器缓存实现展开了激烈的讨论,而这也引起了很多人的疑惑。
鉴于此,我想讲个故事来让大家了解一个请求如何完成他的使命并找到匹配的缓存资源,以下内容均基于 Chromium 的术语,不过其余浏览器的实现本质上没有太大的差异。
Questy 的旅程
Questy 是一个请求。她是在渲染引擎内(也叫渲染器)诞生的。她渴望能在这个标签页关闭前找到一个让她的“人生”再无遗憾的资源。
所以 Questy 展开了她追求幸福的旅程。 但是她会在哪里找到一个恰恰适合的资源呢?
此时离她最近的是……
内存缓存(Memory Cache)
内存缓存中包含了大量的资源。他包含了所有渲染引擎请求的资源。这些资源都是现有文档的一部分。在文档的生命周期中他们都会被储存在此。这意味着,如果 Questy 寻找的资源已经被文档中的其余部分加载了,那么他们会在此相遇。
确切来说,“短期内存缓存”这个名字可能会更适合。因为内容缓存仅在导航结束前保存这些资源,在某些情况下,时间甚至会更短。
事实上,很多种情况都会导致 Questy 寻找的资源已经被加载。
预加载器(preloader)可能是最常发生的情况。如果 Questy 是由 HTML 解析器创造的 DOM节点所激发的,那么她很可能会发现,她所寻找的资源早已在 HTML 标记化阶段加载完毕了。
显示 preload 指令()则是另一种较为可能发生的情况。该指令会让浏览器预加载资源并存储在内存缓存中。
除此之外,还有可能是因为所请求的资源与之前的 DOM 节点或者 CSS 规则所需要的资源相同。例如,一个页面中可能会含有多个具有相同 src 属性的<img>元素,但是他们会得到同一个资源。而实现这种机制的正是内存缓存。
然而,内存缓存不会轻易匹配我们的资源请求。当然了,为了使请求和资源相匹配,他们必须要有相同的 URL 。不过,这还不是全部。他们还必须要有相同的资源类型(这样子一个脚本资源才不会被一个图片请求所匹配),相同的 CORS 策略和一些其他特性。
- 规范并没有十分地明确定义内存缓存所需要匹配的特性,所以不同的浏览器的实现可能会有一定的差异。
有一样东西是内存缓存不关心的,那就是 HTTP 语义。无论资源的头部是是否带有 max-age=0 或者 no-cache 、Cache-Control标签,内存缓存都不关心。因为在当前导航中,资源是可以重用的,所以 HTTP 语义并不重要。
唯一例外的是no-store指令。在某些特定的情况下浏览器会尊重他。(例如,当资源被单独节点重用时)。
所以,Questy 走上前询问内存缓存是否有匹配的资源。唉,然而并没有。
Questy 并没有放弃。她走过资源计时器和开发者工具的网络注册点。在那里,她注册为寻找资源的请求(这意味着如果她能找到匹配的资源,则会出现在开发中工具和资源计时器中)。
完成了这些官方登记后,她继续向前……
Service Worker 缓存
和内存缓存不一样,Service Woker喜欢不走寻常路。他的行为难以预测。因为他只遵循开发者告诉他的规则。
首先,Service Worker只有安装后才会存在。而且因为他的逻辑是由开发者编写的 JavaScript 而不是浏览器控制的,所以 Questy 完全不知道她能不能在这里找到那个他?那个资源长成什么的?他是被存储在缓存里吗?还是说他是由 Service Worker 的主人精心伪造的响应?
这些问题没有人可以回答她。因为 Service Worker 自成一套,无论是资源的匹配方式还是响应的包装方法,他们都能按照自己的的想法去完成。
Service Worker 拥有和缓存相关的 API ,这让他可以储存资源。和内存储存不同的是这种存储方式是持久的。即使该标签页被关闭甚至浏览器重启,这些被存储的资源都不会丢失。只有当开发者明确表示要移除他们的时候(使用 cache.delete(resource)),他们才会被移除。另外一种情况就是当浏览器的存储空间不足时,他会将整个 Service Worker 缓存还有其他源存储如 indexedDB、localStorage 等都清除掉。也因此,Service Worker 能确保他的存储和其他源存储是同步的。
Service Worker 只负责特定的域,换言之,他最多只能管理一个 host。因此,Service Worker 只能控制来自特定域内的文档的请求。
Questy 走向 Service Worker 询问他有没有合适的资源。可惜的是 Service Worker 从来没有见过那个域的资源,所以他也找不到 Questy 寻找的请求了。于是,Service Worker 让 Questy 继续前行(通过 fetch()),从而在网络栈这片神奇的土地里继续寻找她需要的资源。
而一旦进入网络栈,最容易找到资源的地方就是……
HTTP 缓存
HTTP 缓存(有时候也被他的朋友成为“磁盘缓存”)和 Questy 之前遇到过的缓存不太一样。
一方面,他们的存储是持久的,而且能被不同的会话甚至不同的网站重用。如果一个资源被一个网站下载了,他也可以被其他网站重用,
而另一方面,HTTP 缓存遵循 HTTP 语义(名字早已暗示了一切)。他乐于提供他认为觉得是“新鲜”的资源(基于由响应的缓存头声明的生命周期)、校验那些需要重新验证的资源、并拒绝存储那些它不应该存储的资源。
既然他是一个持久性的缓存,他也需要移除资源。但和 Service Worker 不一样的事,他会在觉得他需要空间来存储更重要或者会被更多人需要的资源时,逐个移除那些旧资源。
HTTP 缓存拥有一个基于内存的组件。他负责为请求匹配资源。可是一旦资源匹配成功,它需要从磁盘中获取资源内容,这是一个较为昂贵的操作。
上文我们提到 HTTP 缓存遵循 HTTP 语义。这基本是正确的。除了一个例外情况,HTTP 缓存会存储一些资源一段时间。浏览器能够为下次导航预取资源。我们可以通过显示的指令()或者依靠浏览器内部机制完成。这些被预取的资源会被保存下来直到下次导航,尽管它们可能是不允许缓存的。所以当预取资源到达 HTTP 缓存时,它会被缓存(并且不需要校验就会被提供)大概五分钟。
尽管 HTTP 缓存看起来十分的严厉,但 Questy 还是鼓起勇气上前询问有没有匹配的资源。然而答案依旧是没有。
她还是得继续随着网络往前走。这段旅程时可怕而且未知的,然而 Questy 知道无论如何她都要找到她需要的资源。所以她只能继续。这时候她找到了一个对应的 HTTP/2 会话。并且准备通过网络继续前行,这时候她忽然看到了……
推送“缓存”
推送缓存(其实他更应该被描述为“待认领的推送流存储器”,不过那实在是太拗口了)是存储 HTTP/2 推送资源的地方。它们是 HTTP/2 会话的一部分,这有几个特殊的含义。
这个容器并不是持久的。当会话结束后,未被认领的资源(例如,从来没有被请求匹配到的)就会被移除。如果资源是由不同的 HTTP/2 会话获取的,他们并不会匹配。除此之外,推送缓存只会存储资源一段时间(在基于 Chromium 的浏览器里,这个时长约为五分钟)。
推送缓存根据请求的 URL 和请求头匹配相应,但他不遵循严格的 HTTP 语义。
- 规范里也没有明确定义推送缓存,所以再各个浏览器、系统或者 HTTP/2 客户端间的实现可能会不一样。
尽管信心不大,Questy 还是上前询问是否有匹配的请求。令人惊讶的是,他真的有!!Questy 喜出望外的认领了这个资源(这也意味着它将这个 HTTP/2 流从待认领容器中移除)。现在她可以回去渲染这个资源了。
在他们回程的路上,他们走过了 HTTP 缓存,并且话费了一些时间去复制了一份资源以备日后使用。
离开网络栈后,他们回到 Service Worker 的辖区,而 Service Worker 也将一份资源的拷贝存储到自己的缓存中才让他们回到渲染器里。
最终,一旦它们会到渲染器,内存缓存就会保存一份资源的引用(而不是拷贝)。这样子在稍后如果在同一个导航会话中需要这份资源,他就可以将相同的资源分配给他。
于是,它们就幸福快活的住在了一起,直到文档被移除,然后他们都被垃圾回收了。
不过那是另外一天的故事了。
要点
所以,从 Questy 旅程中我们能学习到什么呢?
- 不同的请求可以从不同的浏览器缓存中匹配的资源。
- 请求匹配资源的缓存的不同会影响这个请求是否会被开发者工具和资源计时器所展示。
- 推送资源不会被持续存储除非他们被请求所认领。
- 不能存储的预加载资源在下一个导航时不会存在。这是预加载(preload)和预取(prefetch)间的最大区别。
- 因为还有很多地方规范没有明确定义,所以不同的浏览器实现会有差异。我们需要弥补这些差异。
总而言之,如果你使用预加载,HTTP/2 推送, Service Worker 又或者其他高级技术来加速你的网站,你可能会注意到内部缓存的实现情况。了解这些内部缓存和他们的运作方式能让你更好的解决问题并且减少不必要的麻烦。