什么是浏览器缓存
我们都知道,一个典型的Web应用请求-响应流程通常如下所示:
客户端 -> 发起HTTP请求 -> 服务器端接收请求 ->查询数据库 -> 执行业务逻辑处理 -> 构造HTTP响应 -> 返回客户端
在这一链路中,性能瓶颈往往集中在两个关键阶段:网络请求阶段和服务端计算阶段。
- 网络请求阶段:主要包括客户端发起的HTTP请求以及服务端对数据库的查询请求;
- 服务端计算阶段:主要包括业务逻辑处理和数据库查询结果的加工处理。
为了提高Web应用的响应速度和用户体验,引入缓存技术成为了优化Web应用性能的关键手段之一。通过在不同层级引入缓存,可以显著减少重复计算和网络开销,从而提高页面响应效率。常见的缓存策略包括:
- 数据库缓存
- CDN(内容分发网络)缓存
- 代理服务器缓存(如反向代理)
- 浏览器缓存
- 应用层缓存(如Redis、Memcached)
本文将从前端视角出发,重点探讨浏览器缓存的原理、类型及其实践。
浏览器缓存的基本原理
浏览器缓存工作流程:
- 浏览器发起第一次HTTP请求:用户在浏览器中访问某个资源(如页面、图片、脚本等),浏览器向缓存系统发送首次请求。
- 缓存未命中(无缓存结果):缓存系统检查是否存在该资源的缓存副本,由于是首次请求,缓存中没有对应数据,因此返回“未命中”状态。
- 浏览器向服务器发起请求:由于缓存未命中,浏览器继续向后端服务器发送原始HTTP请求以获取资源。
- 服务器返回请求资源:服务器接收到请求后,处理并返回所需要的资源内容(如HTML、CSS、JS文件等)。
- 资源存入缓存中:浏览器在接收到服务器返回的资源后,将其存储到本地缓存中,以便后续请求时快速读取,避免重复向服务器发起请求。
整个浏览器缓存的过程基于两个核心原则:
- 浏览器每次发起请求时,都会先在浏览器缓存中查找该请求的结果和缓存标识
- 浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中
接下来将从两个维度来介绍浏览器缓存:
- 缓存的存储位置
- 缓存的类型
按照缓存位置分类
浏览器缓存存存储位置上分为四种,按照优先级依次查找:
- Service Worker
- Memory Cache
- Disk Cache
- Push Cache
Service Worker
Service Worker(简称:SW)是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。值得注意的是,使用SW,传输协议必须是HTTPS,因为SW中涉及到请求拦截,所以必须使用HTTPS协议来保障安全。
SW的的缓存与浏览器的其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存、并且缓存是持续性的,常用于PWA离线访问场景。
SW的工作流程:
- 注册Service Worker
- 监听install事件并缓存文件
- 拦截请求查询缓存、存在则直接返回,否则继续网络请求
通常,当SW没有命中缓存时,需要去调用fetch函数获取数据,也就是说,如果没有在SW命中缓存时,会根据缓存优先级查找数据。但不管我们是从Memory Cache中还是请求网络中获取数据,浏览器都会显示我们是从SW中获取的内容。
Memory Cache
Memory Cache(简称:MC)是内存中的缓存,主要包含的是当前页面中已经抓取的资源,例如页面上已经下载的样式、脚本、图片等。
读取内存的数据肯定比磁盘高效,但毕竟内存空间有限,所以缓存有效期很短,一般会随着进程的结束而释放,也就是说当我们关闭Tab页面,内存中的缓存也就被释放了。
当我们访问过页面后,再次刷新页面,可以发现很多数据都来自内存缓存。如下图所示:
MC机制保证了一个页面如果有两个相同的请求,都只会被请求最多一次,避免请求浪费,如:
- 两个
src相同的<*img*> - 两个
href相同的<*link*>
Disk Cache
Disk Cache(简称:DC)是存储在硬盘中的缓存,读取速度慢点,但什么都能存储到磁盘中,比 MC胜在容量和对存储时效性上。
在所有浏览器缓存中,DC覆盖面是最大的,它会根据HTTP Header中的字段(如:Cache-Control、ETag、Last-Modified )判断哪些资源需要被缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。
通常,在跨站点时,只要相同地址的资源一旦被硬盘缓存下来,就不会再次去请求,因此绝大部分时候数据都来源于DC。但所有的持久化存储都会面临容量增长的问题,DC也不例外,在浏览器自动清理时,会有特殊的算法去把“最老的”或“最可能过时的”资源删除,因此是一个一个删除的,不过每个浏览器是缓存清除算法不尽相同,这点也可以看作是各个浏览器差异性的体现。
Push Cache
Push Cache(简称:PC)译作“推送缓存”,是属于HTTP/2中新增的内容,当前面三种缓存都没有命中时,它才会被使用。它只会Session中存在,一旦Session结束就会被释放,并且缓存时间也是短暂的。通常在Chrome浏览器中只有5分钟的有效期,同时它也并非严格执行HTTP/2头的缓存指令。
如果想进进一步深入了解PC, 推荐阅读文章:HTTP/2 push is tougher than I thought
总体缓存流程的总结
如果一个请求在上述所有阶段都没有找到缓存,那么浏览器会正式发送网络请求去获取资源,之后为了提升请求的缓存命中率,会把请求的资源添加到缓存中去,具体来说:
- 根据SW中的handler决定是否存入Cache Storage(额外的缓存位置)。SW是由开发者编写的额外的脚本,且缓存位置独立,出现也比较晚,使用还不算广泛。
- MC保存一份资源的引用,以备下次使用。MC是浏览器为了加快读取缓存速度而进行的自身优化行为,不受开发者控制,也不受HTTP协议头的约束,算是一个黑盒。
- 根据HTTP头部的相关字段(
Cache-Control、Pragma等)决定是否存入DS。DS也是我们平时最熟悉的一种缓存机制,也叫HTTP Cache(因为不像MC,它遵守HTTP协议头中的字段)。值得一提的是,我们常说的强制缓存,协商缓存,以及Cache-Control等都归于此类。
按照缓存类型分类
按照缓存类型进行分类,可以分为强制缓存和协商缓存。但需要注意的是,这两种缓存类型都是属于Disk Cache或者叫做HTTP Cache里的一种。
强制缓存
强制缓存是指当客户端请求时,会先访问本地缓存看缓存是否存在且未过期,若缓存中资源有效直接返回,否则请求后端服务器获取最新资源,并在响应后存储到本地缓存中。通过减少不必要的网络请求,强制缓存能够显著提升网页的加载速度及用户体验,是性能优化中最有效的缓存策略之一。因此,考虑到性能优化时,强化缓存通常是首先得优化手段。
实现强制缓存的主要依靠HTTP响应头中的相关字段,具体包括:
- Expires:指定了一个具体的日期时间作为缓存的过期时间,它是一个绝对的时间 (通常设置为当前时间+缓存时长)。尽管它是一个早期引入的头字段,但在与Cache-Control共存的情况下,后者优先级更高。
- Cache-Control: 用于指定资源缓存的最大有效时间,在该时间范围内,客户端可以直接使用缓存而无需向服务器发送请求。
Expires字段存在两个主要缺点:
- 由于它使用绝对时间,用户可能会将客户端本地的时间进行修改,而导致浏览器判断缓存失效,从而重新请求该资源。此外,即使不考虑人为修改,时差或者误差等因素也可能造成客户端与服务端的时间不一致,进而影响缓存准确性。
- 写法太复杂了。表示时间的字符串多个空格,少个字母,都会导致变为非法属性从而设置失效。
为了解决Expires的不足,在HTTP/1.1中引入了 Cache-Control字段,该字段常用值如下(完整的列表可以查看 MDN):
- max-age:设置缓存的最大有效时间,单位为秒,是一个相对时间。
- must-revalidate:如果超过了 max-age 的时间,浏览器必须向服务器发送请求,验证资源是否还有效。
- no-cache:虽然字面意思是“不要缓存”,但实际上还是要求客户端缓存内容的,只是是否使用这个内容由后续的协商缓存来决定。
- no-store:真正意义上的“不要缓存”。所有内容都不走缓存,包括强制缓存和协商缓存。
- public:所有的内容都可以被缓存(包括客户端和代理服务器, 如 CDN )
- private:所有的内容只有客户端才可以缓存,代理服务器不能缓存。默认值。
这些值可以混合使用,例如Cache-control:public, max-age=2592000.在混合使用时,它们的优先级如下图所示:
协商缓存
当强制缓存失效时,就需要使用协商缓存,由服务器决定缓存内容是否失效。具体流程为:浏览器先请求本地缓存数据库获取缓存标识,,随后浏览器拿着这个标识和服务器通讯,若资源未发生变化,则服务器返回HTTP状态码304,通知浏览器继续使用缓存,否则返回200状态码及最新资源。
协商缓存在请求数上和未使用缓存时相同,但如果是304状态码,返回的仅仅是一个状态码而已,并没有实际的文件内容,从而显著减小了网络传输体积。它的优化点主要体现在“响应”上面通过减少响应体体积,来缩短网络传输时间。因此和强制缓存相比提升幅度较小,但总比没有缓存好。
协商缓存是可以和强制缓存一起用的,作为强制缓存失效后的一种后备方案,在实际项目中也确实一同出现。对比缓存有 2 组字段(不是两个):
- Last-Modified & If-Modified-Since
- Etag & If-None-Match
Last-Modified & If-Modified-Since
- 服务器通过
Last-Modified字段告知客户端,资源最后一次被修改的时间。 - 浏览器将这个值和资源一起记录在缓存数据库中。
- 再次请求相同资源时时,浏览器从自己的缓存中找出“不确定是否过期的”缓存。因此在请求头中将上次的
Last-Modified的值写入到请求头的If-Modified-Since字段 - 服务器会将
If-Modified-Since的值与Last-Modified字段进行对比。如果相等,则表示未修改,响应 304;反之,则表示修改了,响应 200 状态码,并返回数据。
然而,该机制存在一定局限性:
- 如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒。
- 如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,所以起不到缓存的作用。
因此在 HTTP/1.1 出现了 ETag和 If-None-Match
Etag & If-None-Match
为了解决上述问题,出现了一组新的字段 Etag 和 If-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 告诉客户端直接使用本地缓存即可。
以下是对协商缓存中两组字段的简要对比:
- 精确度
Etag 更精确:Last-Modified 的时间单位为秒,若文件在1秒内多次更新,Last-Modified 无法准确反映变化;而 Etag 基于文件内容生成哈希值,任何修改都会改变该值,能准确识别内容变化。
- 性能
Last-Modified 更高效:Last-Modified 仅需记录时间戳,服务端开销较小;而 Etag 需通过算法计算哈希值,对服务器性能有一定消耗。
- 优先级
Etag 优先级更高:当两者同时存在时,服务器会优先校验 Etag,仅在 Etag 不一致或缺失时,才使用 Last-Modified 进行判断。
浏览器的缓存读取规则
当浏览器要请求资源时:
- 从 Service Worker 中获取内容( 如果设置了 Service Worker )
- 查看 Memory Cache
- 查看 Disk Cache。这里又细分:
如果有强制缓存且未失效,则使用强制缓存,不请求服务器。这时的状态码全部是 200
如果有强制缓存但已失效,使用协商缓存,比较后确定 304 还是 200
- 发送网络请求,等待网络响应
- 把响应内容存入 Disk Cache (如果 HTTP 响应头信息有相应配置的话)
- 把响应内容的引用存入 Memory Cache_(无视 HTTP 头信息的配置)
- 把响应内容存入 Service Worker的 Cache Storage( 如果设置了 Service Worker)
其中针对第 3 步,具体的流程图如下:
用户行为对浏览器缓存影响
在理解了缓存的基本原理后,还需要关注用户的不同操作会出发浏览器采用不同的缓存策略。主要可分为以下三种情况:
- 正常打开网页(地址栏输入URL并回车)
- 浏览器会优先查找Disk Cache。如果存在有效缓存,则直接使用;否则向服务器发送网络请求。
- 普通刷新(F5 或者工具栏刷新按钮)
- 由于页面标签页并未关闭,Memory Cache此时是可用的,因此浏览器会优先使用它(如果命中),其次才会检查Disk Cache。
- 强制刷新(
Ctrl + F5或Ctrl + Shift + R)- 浏览器不使用缓存。因此发送请求头部均带有
Cache-Control: no-cache和Pragma: no-cache(为了兼容 HTTP/1.0),服务器收到后则会返回 200 状态码及最新的内容。
- 浏览器不使用缓存。因此发送请求头部均带有
默认请求与F5与Ctrl + F5刷新
| 操作 | 行为 | 缓存影响 |
|---|---|---|
| 普通加载(直接输入 URL / 点击链接 / 从书签打开) | 正常加载流程 | 优先使用强缓存(Memory → Disk) |
| 刷新(F5) | “软刷新”,保留部分缓存 | 跳过 Memory Cache,但会使用 Disk Cache;仍可能触发协商缓存(304) |
| 强制刷新(Ctrl + F5 / Cmd + Shift + R) | “硬刷新”,重新拉取 | 跳过所有缓存(Memory + Disk),直接向服务器请求新资源(200 from network) |
HTTP缓存协议
来自服务器的缓存指令
当客户端发出一个get请求到服务器,服务器可能有以下的内心活动:「你请求的这个资源,我很少会改动它,干脆你把它缓存起来吧,以后就不要来烦我了」
为了表达这个美好的愿望,服务器在响应头中加入了以下内容:
1 | Cache-Control: max-age=3600 |
这个响应头表达了下面的信息:
Cache-Control: max-age=3600,我希望你把这个资源缓存起来,缓存时间是3600秒(1小时)ETag: W/"121-171ca289ebf",这个资源的编号是W/"121-171ca289ebf"Date: Thu, 30 Apr 2020 12:39:56 GMT,我给你响应这个资源的服务器时间是格林威治时间2020-04-30 12:39:56Last-Modified: Thu, 30 Apr 2020 08:16:31 GMT,这个资源的上一次修改时间是格林威治时间2020-04-30 08:16:31
这个美好的缓存愿望,就这样通过响应头传递给客户端了,如果客户端是其他应用程序,可能并不会理会服务器的愿望,也就是说,可能根本不会缓存任何东西。但是凑巧客户端是一个浏览器,它和服务器一直以来都是相亲相爱的小伙伴,当它看到服务器的这个响应头表达的美好愿望后,立即忙起来:
- 浏览器把这次请求得到的响应体缓存到本地文件中
- 浏览器标记这次请求的请求方法和请求路径
- 浏览器标记这次缓存的时间是3600秒
- 浏览器记录服务器的响应时间是格林威治时间
2020-04-30 12:39:56 - 浏览器记录服务器给予的资源编号
W/"121-171ca289ebf" - 浏览器记录资源的上一次修改时间是格林威治时间
2020-04-30 08:16:31
来自客户端的缓存指令
当客户端收拾好行李,准备再次请求GET /index.js时,它突然想起了一件事:我需要的东西在不在缓存里呢?此时,客户端会到缓存中去寻找是否有缓存的资源,寻找的过程如下:
- 缓存中是否有匹配的请求方法和路径?
- 如果有,该缓存资源是否还有效呢?
以上两个验证会导致浏览器产生不同的行为,要验证是否有匹配的缓存非常简单,只需要验证当前的请求方法GET和当前的请求路径/index.js是否有对应的缓存存在即可,如果没有,就直接请求服务器,就和第一次请求服务器时一样,无需赘述。
关键在于如何验证缓存是否有效?
非常简单,就是把max-age + Date,得到一个过期时间,看看这个过期时间是否大于当前时间,如果是,则表示缓存还没有过期,仍然有效,如果不是,则表示缓存失效。
完整流程图
