Skip to content

浏览器缓存 ✨

什么是浏览器缓存

在正式开始讲解浏览器缓存之前,我们先来回顾一下整个 Web 应用的流程。

image-20211203143550954

上图展示了一个 Web 应用最最简单的结构。客户端向服务器端发送 HTTP 请求,服务器端从数据库获取数据,然后进行计算处理,之后向客户端返回 HTTP 响应。

那么上面整个流程中,哪些地方比较耗费时间呢?总结起来有如下两个方面:

  • 发送请求的时候

  • 涉及到大量计算的时候

一般来讲,上面两个阶段比较耗费时间。

首先是发送请求的时候。这里所说的请求,不仅仅是 HTTP 请求,也包括服务器向数据库发起查询数据的请求。

其次是大量计算的时候。一般涉及到大量计算,主要是在服务器端和数据库端,服务器端要进行计算这个很好理解,数据库要根据服务器发送过来的查询命令查询到对应的数据,这也是比较耗时的一项工作。

因此,单论缓存的话,我们其实在很多地方都可以做缓存。例如:

  • 数据库缓存
  • CDN 缓存
  • 代理服务器缓存
  • 浏览器缓存
  • 应用层缓存

针对各个地方做出适当的缓存,都能够很大程度的优化整个 Web 应用的性能。但是要逐一讨论的话,是一个非常大的工程量,所以本文我们主要来看一下浏览器缓存,这也是和我们前端开发息息相关的。

整个浏览器的缓存过程如下:

image-20211203143612695

从上图我们可以看到,整个浏览器端的缓存其实没有想象的那么复杂。其最基本的原理就是:

  • 浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识

  • 浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中

以上两点结论就是浏览器缓存机制的关键,它确保了每个请求的缓存存入与读取,只要我们再理解浏览器缓存的使用规则,那么所有的问题就迎刃而解了。

接下来,我将从两个维度来介绍浏览器缓存:

  • 缓存的存储位置

  • 缓存的类型

按照缓存位置分类

从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络。这四种依次为:

  • Service Worker

  • Memory Cache

  • Disk Cache

  • Push Cache

Service Worker

Service Worker 是运行在浏览器背后的独立线程,能拦截请求、读写 Cache Storage,实现更灵活的离线与缓存策略(常用于 PWA)。

前置要求

  • 必须在 HTTPS(或 http://localhost)下注册。
  • 缓存与拦截以“作用域”为单位(注册路径向下生效),与站点隔离。

实现流程概览:

  1. 注册 SW 脚本
  2. install 阶段预缓存关键资源(App Shell)
  3. fetch 阶段按策略命中缓存或回源
  4. 版本更新时清理旧缓存并接管客户端 image-20211203143635717
html
<!-- 页面注册:/sw.js -->
<script>
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("/sw.js");
  }
</script>
js
// stale-while-revalidate 示例
const CACHE_NAME = "app-cache-v1";
const ASSETS = ["/", "/styles.css", "/app.js", "/logo.png"];

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE_NAME).then((c) => c.addAll(ASSETS)));
});

self.addEventListener("fetch", (event) => {
  const req = event.request;
  event.respondWith(
    caches.match(req).then((cached) => {
      const update = fetch(req).then((res) => {
        const copy = res.clone();
        caches.open(CACHE_NAME).then((c) => c.put(req, copy));
        return res;
      });
      if (cached) {
        event.waitUntil(update);
        return cached;
      }
      return update;
    })
  );
});

策略选型

  • cache-first:优先命中缓存,适合稳定静态资源(图标、字体)。
  • network-first:优先回源,适合需最新数据的 API/页面。
  • stale-while-revalidate:先用旧副本、后台更新,兼顾速度与新鲜度。

观察与更新

  • DevTools 的 Application → Service Workers 可查看注册与版本;Network 面板可看到由 Service Worker 处理的请求。
  • 变更缓存版本时可在 activate 中删除旧缓存,并配合 clients.claim/skipWaiting 加速新版本接管。

Memory Cache

Memory Cache 也就是内存中的缓存,主要包含的是当前中页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。

读取内存中的数据肯定比磁盘快,内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。

那么既然内存缓存这么高效,我们是不是能让数据都存放在内存中呢?

这是不可能的。计算机中的内存一定比硬盘容量小得多,操作系统需要精打细算内存的使用,所以能让我们使用的内存必然不多。

当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存。

image-20211203143700033

Memory Cache 机制保证了一个页面中如果有两个相同的请求(例如两个 src 相同的 <img>,两个 href 相同的 <link>),都实际只会被请求最多一次,避免浪费。

Disk Cache

Disk Cache持久化在硬盘上的 HTTP 缓存,读取速度相对内存更慢,但容量更大、生命周期更长,也是浏览器最常用的缓存层。

  • 缓存决策:根据响应头(如 Cache-Control/Expires/ETag/Last-Modified/Vary)决定是否缓存、能否复用、何时过期或需要再验证。
  • 作用范围:命中率高,静态资源(HTML/CSS/JS/图片/字体等)大多最终落到 Disk Cache
http
HTTP/1.1 200 OK
Cache-Control: public, max-age=2592000
ETag: "abc123"
Vary: Accept-Encoding

重要变化(缓存分区)

  • 现代浏览器(含 Chrome)对 HTTP 缓存实施“分区(Partitioned HTTP Cache)”,通常按“顶级站点 + 子资源来源”进行分区。即使 URL 完全相同,跨站点上下文不一定共享缓存(隐私与安全考虑)。
  • 因此,早期“跨站点同 URL 不再请求”的经验不再适用,应以分区策略为准。

复用判定要点

  • 复用必须满足:URL 一致、缓存分区一致、Vary 条件一致、缓存仍未过期或经条件请求(If-None-Match/If-Modified-Since)验证通过。

容量与淘汰(简明直觉)

  • 磁盘缓存容量有限,达到上限会触发逐出(Eviction)。不同浏览器实现不同;Chromium 网络栈采用以 LRU 为基础、结合重用与年龄排名的策略,倾向淘汰“很久未用、复用少、体积大”的条目。

Push Cache

Push Cache 翻译成中文叫做“推送缓存”,是属于 HTTP/2 中新增的内容。

当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在 Chrome 浏览器中只有 5 分钟左右,同时它也并非严格执行 HTTP/2 头中的缓存指令。

Push Cache 在国内能够查到的资料很少,也是因为 HTTP2 在国内还不够普及。

这里推荐阅读 Jake ArchibaldHTTP/2 push is tougher than I thought 这篇文章。

文章中的几个结论:

  • 所有的资源都能被推送,并且能够被缓存,但是 EdgeSafari 浏览器支持相对比较差

  • 可以推送 no-cacheno-store 的资源

  • 一旦连接被关闭,Push Cache 就被释放

  • 多个页面可以使用同一个 HTTP/2 的连接,也就可以使用同一个 Push Cache。这主要还是依赖浏览器的实现而定,出于对性能的考虑,有的浏览器会对相同域名但不同的 tab 标签使用同一个 HTTP 连接。

  • Push Cache 中的缓存只能被使用一次

  • 浏览器可以拒绝接受已经存在的资源推送

  • 你可以给其他域名推送资源


如果一个请求在上述几个位置都没有找到缓存,那么浏览器会正式发送网络请求去获取内容。之后为了提升之后请求的缓存命中率,自然要把这个资源添加到缓存中去。具体来说:

  • 根据 Service Worker 中的 handler 决定是否存入 Cache Storage (额外的缓存位置)。Service Worker 是由开发者编写的额外的脚本,且缓存位置独立,出现也较晚,使用还不算太广泛。

  • Memory Cache 保存一份资源的引用,以备下次使用。Memory Cache 是浏览器为了加快读取缓存速度而进行的自身的优化行为,不受开发者控制,也不受 HTTP 协议头的约束,算是一个黑盒。

  • 根据 HTTP 头部的相关字段( Cache-control、Pragma 等 )决定是否存入 Disk CacheDisk Cache 也是平时我们最熟悉的一种缓存机制,也叫 HTTP Cache (因为不像 Memory Cache,它遵守 HTTP 协议头中的字段)。平时所说的强制缓存,协商缓存,以及 Cache-Control 等,也都归于此类。

按照缓存类型分类

按照缓存类型来进行分类,可以分为强制缓存协商缓存。需要注意的是,无论是强制缓存还是协商缓存,都是属于 Disk Cache 或者叫做 HTTP Cache 里面的一种。它们都由 HTTP 响应头控制(如 Cache-Control/Expires/ETag/Last-Modified ),最终决定的是“Disk Cache 中的条目是否可直接复用或需再验证”。

强制缓存

强制缓存的含义是:当浏览器发现某资源“在有效期内”,直接使用本地副本,不与服务器交互

强制缓存能直接减少请求数,是提升最大的缓存策略。通常优先考虑为静态资源设置强缓存。

命中逻辑

  • 若存在明确的强缓存指令(Cache-Control: max-ageExpires),且未过期 → 直接命中。
  • 若无明确指令,浏览器可能使用启发式(如基于 Last-Modified)给出短期有效期,但不可靠。
  • 强缓存失效时,进入“协商缓存”(条件请求)流程。

强缓存的相关字段包括 Cache-ControlExpires(后者用于兼容旧版)。

Expires

这是 HTTP 1.0 的字段,表示缓存到期时间,是一个绝对的时间 (当前时间+缓存时间),如:

Expires: Thu, 10 Nov 2017 08:45:11 GMT

在响应消息头中,设置这个字段之后,就可以告诉浏览器,在未过期之前不需要再次请求。

但是,这个字段设置时有两个缺点:

  • 由于是绝对时间,用户可能会将客户端本地的时间进行修改,而导致浏览器判断缓存失效,重新请求该资源。此外,即使不考虑自行修改的因素,时差或者误差等因素也可能造成客户端与服务端的时间不一致,致使缓存失效。

  • 写法太复杂了。表示时间的字符串多个空格,少个字母,都会导致变为非法属性从而设置失效。

Cache-control

HTTP/1.1 中,Cache-Control 定义了相对有效期与缓存可见性,常用指令如下(可组合使用):

  • max-age=<sec>:最大有效期(秒)。在有效期内直接使用缓存。
  • s-maxage=<sec>:仅用于共享缓存(如 CDN/代理);优先级高于 max-age
  • public/private:允许任何缓存/仅允许私有缓存(浏览器)。
  • immutable:在有效期内视为不可变,避免因“回退/前进”触发再验证(适用于指纹化静态资源)。
  • must-revalidate:过期后必须与服务器再验证,不能“离线复用”。
  • no-cache:必须再验证才能使用,但仍可存储本地副本(进入协商缓存)。
  • no-store:完全不存储,无强缓存与协商缓存。
  • 扩展:stale-while-revalidate=<sec>(过期后可短暂继续用旧副本同时后台再验证)、stale-if-error=<sec>(出错时允许短暂使用旧副本)。

示例:

http
Cache-Control: public, max-age=31536000, immutable
Expires: Tue, 19 Nov 2026 00:00:00 GMT

组合与优先级

  • Cache-Control 优先于 Expires;二者同时设置时,以前者为准。
  • immutable 与长 max-age 适合指纹化资源;非指纹化页面不建议长时间强缓存。

max-age=0no-cache 的差异

  • 规范含义不同:max-age=0 表示“已过期,应再验证(SHOULD)”;no-cache 表示“必须再验证(MUST)”。
  • 实际浏览器实现常趋同;若需严格行为可使用 max-age=0, must-revalidate

HTTP/1.1 之前,如果想使用 no-cache,通常是使用 Pragma 字段,如 Pragma: no-cache(这也是 Pragma 字段唯一的取值)。

但是这个字段只是浏览器约定俗成的实现,并没有确切规范,因此缺乏可靠性。它应该只作为一个兼容字段出现,在当前的网络环境下其实用处已经很小。

总结一下,自从 HTTP/1.1 开始,Expires 逐渐被 Cache-control 取代。

Cache-control 是一个相对时间,即使客户端时间发生改变,相对时间也不会随之改变,这样可以保持服务器和客户端的时间一致性。而且 Cache-control 的可配置性比较强大。Cache-control 的优先级高于 Expires

为了兼容 HTTP/1.0HTTP/1.1,实际项目中两个字段我们都会设置。

协商缓存

强制缓存失效(超过规定时间)时,就需要使用协商缓存,由服务器决定缓存内容是否失效。

流程上说,浏览器先请求缓存数据库,返回一个缓存标识。之后浏览器拿这个标识和服务器通讯。如果缓存未失效,则返回 HTTP 状态码 304 表示继续使用,于是客户端继续使用缓存

image-20211203143800447

如果失效,则返回新的数据和缓存规则,浏览器响应数据后,再把规则写入到缓存数据库。

image-20211203143820739

协商缓存在请求数上和没有缓存是一致的,但如果是 304 的话,返回的仅仅是一个状态码而已,并没有实际的文件内容,因此在响应体体积上的节省是它的优化点。

它的优化主要体现在“响应”上面通过减少响应体体积,来缩短网络传输时间。所以和强制缓存相比提升幅度较小,但总比没有缓存好。

协商缓存是可以和强制缓存一起使用的,作为在强制缓存失效后的一种后备方案。实际项目中他们也的确经常一同出现。

对比缓存有 2 组字段(不是两个):

  • Last-Modified & If-Modified-Since

  • Etag & If-None-Match

Last-Modified & If-Modified-Since

  1. 服务器通过 Last-Modified 字段告知客户端,资源最后一次被修改的时间,例如:

    Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT
  2. 浏览器将这个值和内容一起记录在缓存数据库中。

  3. 下一次请求相同资源时时,浏览器从自己的缓存中找出“不确定是否过期的”缓存。因此在请求头中将上次的 Last-Modified 的值写入到请求头的 If-Modified-Since 字段

  4. 服务器会将 If-Modified-Since 的值与 Last-Modified 字段进行对比。如果相等,则表示未修改,响应 304;反之,则表示修改了,响应 200 状态码,并返回数据。

但是他还是有一定缺陷的:

  • 如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒。

  • 如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,所以起不到缓存的作用。

因此在 HTTP/1.1 出现了 ETagIf-None-Match

Etag & If-None-Match

为了解决上述问题,出现了一组新的字段 EtagIf-None-Match

Etag 存储的是文件的特殊标识(一般都是一个 Hash 值),服务器存储着文件的 Etag 字段。

之后的流程和 Last-Modified 一致,只是 Last-Modified 字段和它所表示的更新时间改变成了 Etag 字段和它所表示的文件 hash,把 If-Modified-Since 变成了 If-None-Match

浏览器在下一次加载资源向服务器发送请求时,会将上一次返回的 Etag 值放到请求头里的 If-None-Match 里,服务器只需要比较客户端传来的 If-None-Match 跟自己服务器上该资源的 ETag 是否一致,就能很好地判断资源相对客户端而言是否被修改过了。

如果服务器发现 ETag 匹配不上,那么直接以常规 GET 200 回包形式将新的资源(当然也包括了新的 ETag)发给客户端;如果 ETag 是一致的,则直接返回 304 告诉客户端直接使用本地缓存即可。

image-20211203143850009

两者之间的简单对比:

  • 首先在精确度上,Etag 要优于 Last-ModifiedLast-Modified 的时间单位是秒,如果某个文件在 1 秒内改变了多次,那么 Last-Modified 其实并没有体现出来修改,但是 Etag 是一个 Hash 值,每次都会改变从而确保了精度。

  • 第二在性能上,Etag 要逊于 Last-Modified,毕竟 Last-Modified 只需要记录时间,而 Etag 需要服务器通过算法来计算出一个 Hash 值。

  • 第三在优先级上,服务器校验优先考虑 Etag,也就是说 Etag 的优先级高于 Last-Modified

在实际开发中则是两者结合使用。

缓存读取规则

接下来我们来对上面所讲的缓存做一个总结。

当浏览器要请求资源时:

  1. Service Worker 中获取内容( 如果设置了 Service Worker

  2. 查看 Memory Cache

  3. 查看 Disk Cache。这里又细分:

    • 如果有强制缓存且未失效,则使用强制缓存,不请求服务器。这时的状态码全部是 200

    • 如果有强制缓存但已失效,使用协商缓存,比较后确定 304 还是 200

  4. 发送网络请求,等待网络响应

  5. 响应内容存入 Disk Cache (如果 HTTP 响应头信息有相应配置的话)

  6. 响应内容的引用存入 Memory Cache (无视 HTTP 头信息的配置)

  7. 把响应内容存入 Service WorkerCache Storage( 如果设置了 Service Worker

其中针对第 3 步,具体的流程图如下:

image-20211203143918845

浏览器行为

在了解了整个缓存策略或者说缓存读取流程后,我们还需要了解一个东西,那就是用户对浏览器的不同操作,会触发不同的缓存读取策略。

对应主要有 3 种不同的浏览器行为:

  • 打开网页,地址栏输入地址:查找 Disk Cache 中是否有匹配。如有则使用;如没有则发送网络请求。

  • 普通刷新 (F5):因为 TAB 并没有关闭,因此 Memory Cache 是可用的,会被优先使用(如果匹配的话)。其次才是 Disk Cache

  • 强制刷新 ( Ctrl + F5 ):浏览器不使用缓存,因此发送的请求头部均带有 Cache-control: no-cache(为了兼容,还带了 Pragma: no-cache )。服务器直接返回 200 和最新内容。

实操案例

实践才是检验真理的唯一标准。上面已经将理论部分讲解完毕了,接下来我们就来用实际代码验证一下上面所讲的验证规则。

下面是使用 Node.js 搭建的服务器:

js
/**
 * 浏览器缓存演示服务器
 * 本示例演示了两种HTTP缓存控制机制:
 * 1. 强制缓存(Cache-Control):通过max-age指定资源有效期
 * 2. 协商缓存(ETag):通过资源标识符进行缓存验证
 */

// 引入必要的Node.js核心模块
const http = require("http"); // HTTP服务器模块
const path = require("path"); // 路径处理模块
const fs = require("fs"); // 文件系统模块

// 创建ETag值用于缓存验证
let hashStr = "A hash string."; // 用于生成哈希的原始字符串
// 使用crypto模块计算哈希值,作为ETag标识符
// 1. 创建SHA1哈希对象
// 2. 更新哈希内容
// 3. 生成base64编码的哈希字符串
let hash = require("crypto")
  .createHash("sha1")
  .update(hashStr)
  .digest("base64");

// 创建HTTP服务器
http
  .createServer(function (req, res) {
    const url = req.url; // 获取到请求的路径
    let fullPath; // 用于拼接完整的路径

    /**
     * 协商缓存(ETag)机制实现
     * 1. 浏览器发送请求时,会在请求头中包含if-none-match字段(如果之前收到过ETag)
     * 2. 服务器比较if-none-match与当前资源的ETag值
     * 3. 如果匹配,说明资源未变化,返回304状态码(Not Modified)
     * 4. 浏览器收到304响应后,会使用本地缓存的资源,不下载新内容
     */
    if (req.headers["if-none-match"] == hash) {
      res.writeHead(304); // 返回304状态码,表示资源未修改
      res.end(); // 结束响应,不返回任何内容
      return;
    }
    // 根据请求的URL确定要返回的文件路径
    if (url === "/") {
      // 请求根路径时返回主页
      fullPath = path.join(__dirname, "static/html") + "/index.html";
    } else {
      // 其他请求根据URL查找对应的静态文件
      fullPath = path.join(__dirname, "static", url);

      /**
       * 设置HTTP响应头,实现缓存控制
       * 1. Cache-Control: max-age=5
       *    - 强制缓存机制:告诉浏览器在5秒内可以直接使用缓存,不需要向服务器验证
       *    - 5秒后缓存过期,浏览器需要重新验证资源是否有效
       *
       * 2. Etag: hash
       *    - 协商缓存机制:为资源设置唯一标识符
       *    - 浏览器下次请求时会发送if-none-match头,包含此ETag值
       *    - 服务器可以通过比较ETag决定是否返回304状态码
       */
      res.writeHead(200, {
        "Cache-Control": "max-age=5", // 设置缓存有效期为5秒
        Etag: hash, // 设置资源的ETag值
      });
    }
    // 读取文件内容并返回给客户端
    fs.readFile(fullPath, function (err, data) {
      if (err) {
        res.end(err.message);
      } else {
        // 读取文件成功,返回文件内容给浏览器
        // 浏览器会根据之前设置的缓存头信息决定如何处理这些内容
        res.end(data);
      }
    });
  })
  // 启动服务器,监听3000端口
  .listen(3000, function () {
    console.log("服务器已启动,监听 3000 端口...");
    console.log("浏览器缓存演示:");
    console.log("1. 首次访问:所有资源都会下载");
    console.log("2. 5秒内刷新:静态资源使用强制缓存(不发请求)");
    console.log("3. 5秒后刷新:发送带有ETag的请求,服务器返回304状态码");
    console.log("访问地址:http://localhost:3000");
  });

在上面的代码中,我们使用 Node.js 创建了一个服务器,根据请求头的 if-none-match 字段接收从客户端传递过来的 Etag 值,如果和当前的 Hash 值相同,则返回 304 的状态码。

在资源方面,我们除了主页没有设置缓存,其他静态资源我们设置了 5 秒的缓存,并且设置了 Etag 值。

注:上面的代码只是服务器部分代码,完整代码请参阅本章节所对应的代码。

效果如下:

2021-12-03 14.02.26

可以看到,第一次请求时因为没有缓存,所以全部都是从服务器上面获取资源,之后我们刷新页面,是从 memory cache 中获取的资源,但是由于我们的强缓存只设置了 5 秒,所以之后再次刷新页面,走的就是协商缓存,返回 304 状态码。

但是在这个示例中,如果我们修改了服务器的静态资源,客户端是没办法实时的更新的,因为静态资源是直接返回的文件,只要静态资源的文件名没变,即使该资源的内容已经发生了变化,服务器也会认为资源没有变化。

那怎么解决呢?

解决办法也就是我们在做静态资源构建时,在打包完成的静态资源文件名上根据它内容 Hash 值添加上一串 Hash 码,这样在 CSS 或者 JS 文件内容没有变化时,生成的文件名也就没有变化,反映到页面上的话就是 url 没有变化。

如果你的文件内容有变化,那么对应生成的文件名后面的 Hash 值也会发生变化,那么嵌入到页面的文件 url 也就会发生变化,从而可以达到一个更新缓存的目的。这也是为什么在使用 webpack 等一些打包工具时,打包后的文件名后面会添加上一串 Hash 码的原因。

目前来讲,这在前端开发中比较常见的一个静态资源缓存方案。

缓存的最佳实践

频繁变动的资源

Cache-Control: no-cache

对于频繁变动的资源,首先需要使用 Cache-Control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。

这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。

不常变化的资源

Cache-Control: max-age=31536000

通常在处理这类资源时,给它们的 Cache-Control 配置一个很大的 max-age=31536000 (一年),这样浏览器之后请求相同的 URL 会命中强制缓存。

为了解决更新的问题,就需要在文件名(或者路径)中添加 Hash, 版本号等动态字符,之后更改动态字符,从而达到更改引用 URL 的目的,让之前的强制缓存失效 (其实并未立即失效,只是不再使用了而已)。

在线提供的类库(如 jquery-3.3.1.min.js、lodash.min.js 等)均采用这个模式。