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