[核心概念] 一文说透前端浏览器缓存

803 阅读12分钟

前端浏览器缓存

系列开篇

为进入前端的你建立清晰、准确、必要概念和这些概念的之间清晰、准确、必要关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。

面试题

  • 请聊聊浏览器的缓存策略
  • 如何选择合适的缓存策略
  • HTTP 状态码 304 等技术细节

这是干什么的?

使用缓存本质上就是为了节省网络传输资源的一种手段。重用已获取的资源,减少延迟与网络阻塞。

我们先讨论浏览器的缓存机制。有个比较混淆的概念是: 前端数据存储(缓存)方案【关联概念】(Storage、IndexDB、Cookie等)我们分下次讨论。

浏览器对于缓存的处理是根据第一次请求资源时返回的响应头来确定的。

按照缓存位置分类

它们的优先级是:(由上到下寻找,找到即返回;找不到则继续)

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache
  • 网络请求

memory cache

顾名思义,内存中的缓存,js和图片等文件解析执行后直接存入内存缓存中,刷新页面时只需直接从内存缓存中读取,以下是他的特点

  • 空间有限
  • 短期存储 (关闭Tab失效)
  • 读取速度快

disk cache

顾名思义,硬盘上的缓存 (http cache),disk cache 会严格根据 HTTP 头信息中的各类字段来判定哪些资源可以缓存,哪些资源不可以缓存;哪些资源是仍然可用的,哪些资源是过时需要重新请求的。

当命中缓存之后,浏览器会从硬盘中读取资源,虽然比起从内存中读取慢了一些,但比起网络请求还是快了不少的。绝大部分的缓存都来自 disk cache。以下是他的特点

  • 持久存储
  • 读取速度较慢(但比网络请求快)

Service Worker

Service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的

如果我们没有在 Service Worker 命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示我们是从 Service Worker 中获取的内容。

Push Cache

Push Cache 是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂,只在会话(Session)中存在,一旦会话结束就被释放。Push Cache 中的缓存只能被使用一次。

请求网络

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

  • 根据 Service Worker 中的 handler 决定是否存入 Cache Storage(额外的缓存位置)。
  • 根据 HTTP 头部的相关字段(Cache-control, Pragma 等)决定是否存入 disk cache
  • memory cache 保存一份资源的引用,以备下次使用。

按失效策略分类

为强缓存协商缓存,这都是 disk cache 范畴,存于硬盘中。

强制缓存 (强缓存)

强制缓存直接减少请求数,是提升最大的缓存策略。如果考虑使用缓存来优化网页性能的话,强制缓存应该是首先被考虑的。在请求 ,处理,响应3阶段都有提升。

简单介绍下设置参数: ExpiresCache-control

Expires

这是 HTTP 1.0 的字段,表示缓存到期时间,是一个绝对的时间 (当前时间+缓存时间), 你可以看成到期时间。主要缺点:

  • 由于是绝对时间,用户可能会将客户端本地的时间进行修改,而导致浏览器判断缓存失效,重新请求该资源。此外,即使不考虑自信修改,时差或者误差等因素也可能造成客户端与服务端的时间不一致,致使缓存失效。
  • 写法太复杂了。表示时间的字符串多个空格,少个字母,都会导致非法属性从而设置失效。

Cache-control

已知Expires的缺点之后,在HTTP/1.1中,增加了一个字段Cache-control,该字段表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求

**这两者的区别就是前者是绝对时间,而后者是相对时间**如下:

Cache-control: max-age=2592000
  • max-age:即最大有效时间
  • must-revalidate:如果超过了 max-age 的时间,浏览器必须向服务器发送请求,验证资源是否还有效。
  • no-cache虽然字面意思是“不要缓存”,但实际上还是要求客户端缓存内容的,只是是否使用这个内容由后续的对比来决定。
  • no-store: 真正意义上的“不要缓存”。所有内容都不走缓存,包括强制和对比。
  • public:所有的内容都可以被缓存 (包括客户端和代理服务器, 如 CDN)
  • private:所有的内容只有客户端才可以缓存代理服务器不能缓存。默认值。

需要注意的是,no-cache这个名字有一点误导。设置了no-cache之后,并不是说浏览器就不再缓存数据,只是浏览器在使用缓存数据时,需要先确认一下数据是否还跟服务器保持一致,也就是协商缓存。而no-store才表示不会被缓存,即不使用强制缓存,也不使用协商缓存。

这些值可以混合使用(设置冲突按优先级覆盖),例如

Cache-control: public, max-age=2592000

混用优先级: no-store > no-cache > public/private > max-age

对比缓存 (协商缓存)

当强制缓存失效(超过规定时间)时,就需要使用对比缓存,由服务器决定缓存内容是否失效。流程如下

  1. 浏览器先请求缓存数据库,返回一个缓存标识。
  2. 之后浏览器拿这个标识和服务器通讯。
  • 如果缓存未失效,则返回 HTTP 状态码 304 表示继续使用,于是客户端继续使用缓存;
  • 如果失效,则返回新的数据和缓存规则,浏览器响应数据后,再把规则写入到缓存数据库。

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

它的优化覆盖了文章开头提到过的请求数据的三个步骤中的最后一个:“响应”。通过减少响应体体积,来缩短网络传输时间。所以和强制缓存相比提升幅度较小,但总比没有缓存好。

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

介绍下协商缓存的使用

Last-Modified & If-Modified-Since

  • 服务器通过 Last-Modified 字段告知客户端,资源最后一次被修改的时间,例如 Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT
  • 浏览器将这个值和内容一起记录在缓存数据库中。
  • 下一次请求相同资源时时,浏览器从自己的缓存中找出“不确定是否过期的”缓存。因此在请求头中将上次的 Last-Modified 的值写入到请求头的 If-Modified-Since 字段
  • 服务器会将 If-Modified-Since 的值与 Last-Modified 字段进行对比。如果相等,则表示未修改,响应 304;反之,则表示修改了,响应 200 状态码,并返回数据。

缺陷

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

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

Etag & If-None-Match

  • Etag 存储的是文件的特殊标识(一般都是 hash生成的),服务器存储着文件的 Etag 字段。

  • 之后的流程和 Last-Modified 一致,只是 Last-Modified 字段和它所表示的更新时间改变成了 Etag 字段和它所表示的文件 hash,把 If-Modified-Since 变成了 If-None-Match。服务器同样进行比较,命中返回 304, 不命中返回新资源和 200。

  • Etag 的优先级高于 Last-Modified

二者对比

  • 精确度上:Etag要优于Last-Modified。
  • 优先级上:服务器校验优先考虑Etag。
  • 性能上:Etag要逊于Last-Modified

缓存的应用模式

1. 对于不常变化的资源

Cache-Control: max-age=31536000;  // 一年

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

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

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

如果配置中还增加 public 的话,CDN [关联概念] 也可以缓存起来,效果拔群。

2. 对于经常变化的资源

Cache-Control: no-cache

这里的资源不单单指静态资源,也可能是网页资源,例如博客文章。这类资源的特点是:URL 不能变化,但内容可以(且经常)变化

我们可以设置 Cache-Control: no-cache迫使浏览器每次请求都必须找服务器验证资源是否有效

既然提到了验证,就必须 ETag 或者 Last-Modified 出场。这些字段都会由专门处理静态资源的常用类库(例如 koa-static)自动添加,无需开发者过多关心。

也正如上文中提到协商缓存那样,这种模式下,节省的并不是请求数,而是请求体的大小。所以它的优化效果不如模式 1 来的显著。

3. 非常危险的模式 1 和 2 的结合 (反例,最好别这么做)

Cache-Control: max-age=600, must-revalidate

不知道是否有开发者从模式 1 和 2 获得一些启发:模式 2 中,设置了 no-cache,相当于 max-age=0, must-revalidate。我的应用时效性没有那么强,但又不想做过于长久的强制缓存,我能不能配置例如 max-age=600, must-revalidate 这样折中的设置呢?

表面上看这很美好:资源可以缓存 10 分钟,10 分钟内读取缓存,10 分钟后和服务器进行一次验证,集两种模式之大成,但实际线上暗存风险。因为上面提过,浏览器的缓存有自动清理机制,开发者并不能控制。

举个例子:当我们有 3 种资源: index.html, index.js, index.css。我们对这 3 者进行上述配置之后,假设在某次访问时,index.js 已经被缓存清理而不存在,但 index.html, index.css 仍然存在于缓存中。这时候浏览器会向服务器请求新的 index.js,然后配上老的 index.html, index.css 展现给用户。这其中的风险显而易见:不同版本的资源组合在一起,报错是极有可能的结局。

除了自动清理引发问题,不同资源的请求时间不同也能导致问题。例如 A 页面请求的是 A.js 和 all.css,而 B 页面是 B.js 和 all.css。如果我们以 A -> B 的顺序访问页面,势必导致 all.css 的缓存时间早于 B.js。那么以后访问 B 页面就同样存在资源版本失配的隐患。

其他

用户行为对浏览器缓存的影响

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

  • 普通刷新 (cmd + R):因为 TAB 并没有关闭,因此 memory cache 是可用的,会被优先使用(如果匹配的话)。其次才是 disk cache。

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

启发式缓存

如果请求头中确定缓存过期时间的字段一个都没有。

如果Expires, Cache-Control: max-age, 或 Cache-Control:s-maxage 都没有在响应头中出现, 并且也没有其它缓存的设置, 那么浏览器默认会采用一个启发式的算法, 通常会取响应头的Date_value - Last-Modified_value值的10%作为缓存时间。

参考