大家在日常的开发工作过程中,有没有遇到过下面几种情况:

  • 部署/发布前端工程后,增加的功能或修改的bug没有生效
  • 测试同学测试功能时经常暴力地清除所有浏览器缓存
  • 前端开发同学经常说:你“强刷”一下就好了

遇到上面这些情况,大部分同学就知道了,这是前端有缓存的原因,那具体什么是前端缓存呢,前端缓存仅仅和前端有关系吗?

前端缓存 / 浏览器缓存

前端缓存,是浏览器为了提升网站的加载性能,缩短用户等待时间而采取的措施,浏览器总是想尽量少地向服务器发送请求,能够从自己保存的副本中得到的,就不去麻烦服务器了,毕竟自己动手丰衣足食嘛,所以更准确的叫法应该为浏览器缓存,下文中如果出现缓存等字眼,指的就是前端缓存浏览器缓存

由上所述,缓存对于用户来说是友好的,而且对大多数用户透明的,普通用户可能最多只是感觉再次进入一个网站时速度变快了而已,再进一步可能某些用户发现一些静态页面断网后还能被访问。但是对于开发同学就需要对缓存有所了解,并在发布新版时特别注意。

缓存机制

那缓存是具体是什么呢?我们可以将其理解为我们下载到硬盘的文件,比如说老师为我们制作了一份课件,我们将其下载放在了硬盘中,那之后我们什么时候想看,只需要到这份课件保存的文件夹内,将其打开就好了,而不必再次下载了。但是有一天老师发现了课件里面有一些问题,那老师为了不误导我们肯定想要修正这些问题,修正过后呢,老师在上课的时候就告诉我们“同学们,你们上次下载的课件有些问题,我已经改了,你们再重新下载一下,之前那份就不要看了”。那我们回去之后就重新下载老师修改过后的课件,之前的课件就再也不需要看了。

明白了上面的例子,其实也就明白了缓存的机制,对应于缓存,工作流程应该是这样的:

用户小U使用浏览器小B访问了一个页面P,浏览器将页面P和其中包含的资源文件(一般包含css、js、图片等文件)保存下来了,一段时间内呢小B反复访问页面P,小B都会从自己的存储区取出相应文件为小U渲染出同样的页面P。然后有一天页面P更新了,比如说主题变了或者里面的按钮功能变化了,当小B再次访问页面P时,它知道自己之前保存的页面P的资源文件已经过时了,然后就再次访问一下页面P,再将页面P和其中包含的资源文件保存下来,之前保存的页面P和其中包含的资源文件就没用了。

那上面两个流程是不是都有个重新下载的过程,那重要的就是如果感知到资源的变化。第一个例子中是老师通知同学们资源变化了,第二个例子就没有上面说的那么简单了,没人主动告诉小B它缓存的资源已经过时了,那小B是通过什么方式才能知道自己缓存的资源已经过时了呢:

缓存的状态

状态 备注
无缓存 初次见面,第一次进入某个页面
缓存过时/非法 重逢不识君,进入过某个页面,并有对应缓存,但再次进入时可能已经过时了
缓存合法 归来仍是少年,进入过某个页面,并有对应缓存,再次进入时仍然是有效的

而缓存状态的变化,需要浏览器和服务器之间达成某些协议,这些协议由HTTP头部确定及执行,这里我们介绍最常用的:

缓存头部

  • Cache-Control
  • Etag及If-None-Match夫妇
  • Last-Modified及If-Modified-Since夫妇

Cache-Control

Cache-Control的语法及使用姿势众多,刚兴趣的可直接到MDN查看,这里我们只介绍它在响应头中用于告知浏览器如何进行缓存行为:

Response Headers
  Cache-Control: public, max-age=31536000

上面的响应头中Cache-Control: public, max-age=31536000告知浏览器从当前请求的时间点开始,再次请求此资源如果还未超过31536000秒(1年),你就就不必问我了,放心地使用你本地保存的就好了。但是这就有个问题了,如果一年内的某天,此资源变化了,浏览器该如何知道呢?很遗憾,这种情况下浏览器在1年内是不会再知道了。

所以如果web server想要对资源设置诸如Cache-Control: public, max-age=31536000响应头,一般需要前端搭配文件名hash来使用,这在各种构建工具或脚手架中一般都有相应的配置,如webpack配置:

// webpack
module.exports = {
  // ...
  output: {
    filename: '[name].[contenthash].js',
    // filename: '[name].[hash].js',
    // ...
  },
  optimization: {
    moduleIds: 'hashed',
    // ...
  },
  // ...
}

适用资源:基本所有的资源型文件(如js、css、图片、字体文件等)。

设置为1年没有什么其他含义,只是一个较大的时间区间而已。


当我们按照上面方式配置完成后,满心欢喜的去发布新版了,可是部署完成后再次访问,尴尬了,没有变化?这就要注意了,缓存对于html文件也是生效的,我知道这很显而易见,但是很多人容易忽略。

特别是对于单页面应用来说,我们一般只有一个index.html,在index.html中引入其他js、css等资源文件,上面的步骤只是对于index.html中引入的资源文件名中添加了hash,保证发布新版后这些引入的资源文件在浏览器缓存中不存在,但是如果浏览器取得index.html是通过本地缓存得到的呢?

<!-- 缓存内index.html -->
<link rel="stylesheet" href="index.v1.css" />
<script src="index.v1.js"></script>
<!-- 新版的index.html -->
<link rel="stylesheet" href="index.v2.css" />
<script src="index.v2.js"></script>

这里为了方便,我们使用v1、v2等代指hash

很显然,如果浏览器从缓存中获取index.html,然后肯定会尝试获取index.v1.cssindex.v1.js,而这两个文件再缓存中也是存在的且是合法的(假设还在1年内),那自然用户看到的页面及功能都是老的了。那我们应该如何为html文件设置缓存策略呢?

我们可以在web server的配置中针对html文件设置Cache-Control: no-cache,当我们请求html文件时,响应头会包含:

Response Headers
  Cache-Control: no-cache

要特别注意no-cache(允许缓存,但是使用前要向服务器确定缓存是否合法,确定方案下面会讲到)和no-store(不允许缓存)的区别。

这样的话,当我们发布新版后,浏览器请求index.html发现有缓存,但并不会无脑的使用,而是会向服务器确认一下当前的缓存是否合法,如果合法则直接使用缓存内的版本,否则会向服务器请求最新的index.html,之后我们的index.v2.cssindex.v2.js就能够被正确获取,用户就能够看到新的页面了。

ETag及If-None-Match

ETag被称为实体标签或版本标识符,ETag变化代表资源的变化。

上面说道,浏览器有时需要向服务器确定缓存是否合法,那通过ETag响应头部和If-None-Match请求头部就能够确定,大致过程如下:

  1. 首次请求index.html,服务器响应头部中包含ETag: W/"v1"首部
  2. 浏览器缓存index.html,再次请求index.html时,请求头部包含If-None-Match: W/"v1"
  3. 服务器确定index.html是否有变化,如果没有变化,则状态码返回304,并且没有响应体,浏览器将使用本地缓存;如果有变化,则状态码返回200,将新的index.html传给浏览器,并返回新的ETag: W/"v2"首部
  4. 重复2、3

v1、v2只是为了标识出ETag的变化,实际上生成ETag的算法也不唯一,甚至简单地使用版本号也可以。关于ETag详细信息可参阅MDN

Last-Modified及If-Modified-Since

除了ETagIf-None-Match,使用Last-ModifiedIf-Modified-Since组合也能起到类似的效果,大致过程和前组合类似:

  1. 首次请求index.html,服务器响应头部中包含Last-Modified: Wed, 21 Oct 2019 07:28:00 GMT首部
  2. 浏览器缓存index.html,再次请求index.html时,请求头部包含If-Modified-Since: Wed, 21 Oct 2019 07:28:00 GMT
  3. 服务器确定index.html是否有变化(通过资源的最近修改时间和Wed, 21 Oct 2019 07:28:00 GMT对比),如果没有变化,则状态码返回304,并且没有响应体,浏览器将使用本地缓存;如果有变化,则状态码返回200,将新的index.html传给浏览器,并返回新的Last-Modified: Wed, 22 Oct 2019 09:32:00 GMT首部
  4. 重复2、3

Last-ModifiedIf-Modified-Since组合准确度不如ETagIf-None-Match组合,所以不太推荐使用:

  • 有些服务器无法正确地判断资源的最近修改日期
  • 如果资源的变化周期在秒级以下,只能精确到秒的修改日期就不那么精确了

确定缓存是否合法,也会发请求和服务器端通信,但如果缓存有效,服务器发回的响应中是不包含响应体的,这样流量消耗是很小的,只有头部的消耗;即使缓存无效,也只是相当于发了一个首次请求而已。

总结

综上所述,我们可以在部署前端工程时使用如下方案,保证用户能够享受缓存带来的便利,也能保证不会因为缓存造成更新不生效的问题:

  • 针对大部分资源文件,使用Cache-Control: public, max-age=31536000文件名hash的方案
  • 针对html文件,使用Cache-Control: no-cacheETag方案

为不同类型资源配置响应头部是web server的工作,请求头部是浏览器的自发工作,文件hash是前端的工作。