前言

为什么要写这篇文章?
故事背景发生在 2019 年的某一天,国内某大型开源 CDN 网站服务器奔溃之后。公司的 H5 产品,引用了他们的 CDN 资源,当天无法访问,后来我们紧急发布了热修复,用阿里云 CDN 资源替换了线上的链接地址。

发布热补丁之后,我们期望的结果当然是用户重新打开微信公众号访问 H5 就能正常加载最新的资源,正常访问。

但是,最后的问题是,iOS 系统的微信公众号能够正常访问,安卓却不行,安卓加载的还是缓存中的静态资源文件。

这里面当然是因为微信安卓的浏览器内核对缓存做了特殊的处理(坑),但是怎么从根本上解决缓存问题?

关于 HTTP 缓存的文章网上很多,所以我这里主要做简单总结和问题探讨。

问题:

  1. HTTP 缓存策略是什么样的原理?
  2. 缓存响应的状态码 200 和 304 状态码有什么区别?
  3. 如何高效的利用缓存?
  4. 假如想利用 HTTP 请求统计用户访问量,有缓存不就没用了?

先给结论:

  1. 缓存策略分为强制缓存和协商缓存
  2. 缓存位置分为 CacheStorage、内存缓存、硬盘缓存
  3. 200 代表从本地缓存中获取,304 代表和服务器进行过一次通信后从浏览器缓存中获取
  4. 尽可能将静态资源的缓存周期变长,index.html 不应该被缓存,利用 GET 请求进行缓存
  5. 利用 1*1 px 透明图片埋点和 referer 来统计用户访问量,该图片路径不缓存

一、HTTP 缓存

缓存是一种保存资源副本并在下次请求时直接使用该副本的技术

浏览器缓存一般只有 GET 请求才有效。

缓存的种类有很多,其大致可归为两类:私有与共享缓存。共享缓存存储的响应能够被多个用户使用。私有缓存只能用于单独用户。

“public” 指令表示该响应可以被任何中间人(译者注:比如中间代理、CDN 等)缓存,隐含的意思是,其他用户可能也能分享这个资源的缓存

private 是指资源应该被缓存,但是只能被客户端的浏览器缓存.

下面是和 HTTP 缓存有关的 HTTP 消息头:

HTTP Header 出现在 应用在
Cache-Control 请求、响应 强制和协商缓存
Expires 响应 强制缓存
Last-Modified 响应 协商缓存
If-Modified-Since 请求 协商缓存
ETag 响应 协商缓存
If-None-Match 请求 协商缓存

二、缓存位置

文件的缓存位置,目前普遍使用的有三种,其中 cacheStorage 是在 ServiceWorker 中应用中产生的。

缓存位置 资源
from ServiceWorker(cacheStorage) 通过 ServiceWorker 注册安装
from memory cache 脚本、字体、图片
from disk cache 非脚本:css、svg

缓存放在内存还是硬盘中,也会和文件大小有关系。

三、强制缓存

强制缓存是通过 Expires 和 Cache Control 的 max-age 来判断缓存是否过期的策略,此时不会向服务器发起请求。

如果缓存没有过期,将会直接从浏览器缓存中获取资源。

Expires 的值通常是一个绝对时间,存在的问题当是客户端和服务器时间不一致或者被修改的情况下就会失效。而 max-age 的值是相对时间,根据浏览器缓存根据服务器返回的 Date 和 Max-Age 判断缓存是否过期。

打个比方:max-age 相当于说“保质期六个月”,而 Expires 是说“在此日期之前”饮用。

max-age 和 Expires 设置的缓存过期时间最多为一年(365 天),如果多于这个值则浏览器有可能会忽略

当 Expires 和 Max-Age 同时存在时,会忽略 Expires 指令。

作为请求首部时,cache-directive 的可选值

字段名称 说明
no-cache 告知(代理)服务器不直接使用缓存,要求向原服务器发起请求
no-store 所有内容都不会被保存到缓存或 Internet 临时文件中,直接下载文件
max-age=delta-seconds 告知服务器客户端希望接收一个存在时间(Age)不大于 delta-seconds 秒的资源, 为 0 表示直接进行协商缓存
max-stale[=delta-seconds] 告知(代理)服务器端愿意接收一个超过缓存时间的资源,若定义 delta-seconds 则为 delta-seconds 秒,若没有则为任意超出时间
min-fresh=delta-seconds 所有内容都不会被保存到缓存或 Internet 临时文件中
no-transform 告知(代理)服务器客户端希望获取实体数据没有被转换(比如压缩)过的资源
only-if-cached 告知(代理)服务器客户端希望获取缓存的内容(若有), 而不用向原服务器发去请求
cache-extension 自定义扩展值,若服务器不识别该值将被忽略掉

Cache-Control 在响应中的字段:
字段名称 | 说明
–|–
public| 指示响应可以被任何缓存所缓存,即使通常它只是非可缓存或可缓存到一个非共享缓存内
private| 指示响应信息的全部或者部分用于单个用户,而不能用一个共享缓存来缓存
no-cache| 可以缓存,但是只有在跟 WEB 服务器验证了其有效后,才能返回给客户端(直接进行协商缓存)
no-store| 所有内容不会被保存到缓存或 Internet 临时文件中,指令的目的是防止无心发布或是保留了敏感信息(例如,备份)
no-transform| 告知客户端缓存文件时不得对实体数据做任何改变。它对转换某个实体体的媒体类型很有用,例如,一个非透明的代理把图像转换格式,以节省缓存空间,或是减少慢速链接中的通信量
only-if-cached| 在某些情况下,如网络连接非常差时,客户端可能需要一个缓存,只返回目前已存储的那些响应,而不是重新加载,或与源服务器重新验证。要做到这一点,客户端可以在一个请求中包含该指令。
must-revalidate| 当前资源一定是向原服务器发去验证请求的,若请求失败会返回504(而非代理服务器上的缓存)
proxy-revalidate| 与 must-revalidate 类似,但是仅能应用于共享缓存(如代理)
max-age=delta-seconds| 告知客户端该资源在 delta-seconds 秒内是新鲜的,除非还包含 max-stale 指令,否则客户端不期望接收一个陈旧的响应。
s-maxage=delta-seconds| 同 max-age,但仅应用于共享缓存(如代理)
cache-extension| 自定义扩展值,若服务器不识别该值将被忽略

四、协商缓存

Last-Modified 和 If-Modified-Since 是用最后修改日期时间判断一个缓存的资源是否有效。

  1. 服务器第一次请求会发送 Last-Modified 响应头
  2. 浏览器下次协商请求时,会将 If-Modified-Since 头加上 缓存响应中的 Last-Modified 值合在一起发送给服务器,
  3. 服务器判断没过期,就会返回 304 状态码,如果过期会直接返回资源
  4. 如果状态码是 304, 浏览器就从缓存中获取资源

Last-Modified 存在的问题可能是,将打包的前端资源文件夹进行覆盖式部署时,部分文件内容并没有发生变化,但是修改时间却被更新了,此时浏览器会下载资源,造成了不必要的时延和带宽消耗。

Etag 和 If-None-Match 则是用内容摘要作为判定的依据。内容摘要是指根据一个资源的内容产生一串比较短的数字,当内容变化时,产生的数字串也会改变。内容摘要的算法有很多种,较常见的是 SHA-1 哈希算法、CRC32 等。

Etag 的运行流程和 Last-Modified 一样:

  1. 第一次请求时,服务器返回文件的 etag
  2. 后面的请求,浏览器将 If-None-Match 头带上缓存响应中的 etag 值合在一起,发送给服务器
  3. 服务器判断没过期,就会返回 304 状态码,如果过期就会直接返回资源。
  4. 如果状态码是 304, 浏览器就从缓存中获取资源

Etag 的优先级会比 Last-Modified 更高, Etag 就是为了解决 Last-Modified 文件更新时间变化,但文件内容没变的问题,另外 Etag 的缺点在于会占用比 Last-Modified 更高的服务器 CPU 消耗。

五、200 和 304

由以上的知识,我们了解到:

强制缓存返回的 HTTP 响应状态码是 200,而协商缓存返回的响应状态码是 304。

大多数情况是这样没错,但是在部分浏览器中(比如谷歌),协商缓存也是会返回 200 的,这是浏览器的算法使然,浏览器判断,越长时间没有更新的文件,会直接从浏览器缓存中获取资源,此时状态码是 200.

下面是网上常见强制缓存和协商缓存的流程图,我也画了一个:

六、缓存更新

这个问题主要在产品部署发布迭代版本的时候会遇到。

如果服务器没有配置 Last-Modified 和 etag, 当强制缓存还没有失效的时候,如何更新文件版本呢?

目前普遍的做法有两种,不缓存 index.html,在 js,css,字体,图片的链接地址后拼接版本参数:比如

http://cdn.xienanbo.com/logo.png?v=2.2.0

或者将资源文件的内容 HASH 短码添加在文件名中:

http://cdn.xienanbo.com/dist.a87e5ae.js

七、谈谈微信浏览器的坑

微信安卓版内置浏览器符合一般浏览器的缓存策略。但是 iOS 内置浏览器会将 index.html 文件强制缓存,尽管你在 index.html 文件的 head 标签上添加了如下代码:

1
2
3
<meta http-equiv="Cache-Control" content="no-cache, no-store" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="expires" content="0" />

上面 Pragma 是 HTTP1.0 的消息头,基本没用,仅有 IE 才能识别这段 meta 标签含义。
解决方案就是从服务器层面对 index.html 文件进行缓存控制,比如 Nginx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
location / {
root /var/www/;
index index.html index.htm;
try_files $uri $uri/ /index.html;

#### kill cache
add_header Last-Modified $date_gmt;
add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
if_modified_since off;
expires off;
etag off;
}

location ~* ^.+\.(jpg|jpeg|gif|png|ico|css|js)$ {
root /mnt/dat1/test/tes-app;
access_log off;
expires 30d;
}

大公司一般采用 index.html 和 js、css、字体、图片资源利用 CDN 分开部署,不在同一台服务器上,所以只需要对 index.html 设置不缓存即可。

顺便提一下,如果是字体文件部署在 CDN 上,会存在跨域问题(CSS、JS、图片不会受到同源政策的限制),开启 CORS 即可:

1
access-control-allow-origin: *

总结

浏览器缓存是前端性能优化中关键的一步,利用 GET 请求缓存文件。

浏览器缓存分为强制缓存和协商缓存,强制缓存中 Cache-Control 比 Expires 的优先级更高。协商缓存中,If-None-Match 比 If-Modified-Since 的优先级更高。

强制缓存返回状态码 200,协商缓存状态 304。

前端打包应该利用打包工具对文件名添加 hash 短码,如需校验身份信息应该利用协商缓存和私有缓存。

参考链接:

@Starbucks 2019/04/13