现代浏览器对于性能优化十分的看重,通常都会主动的去缓存网页上的数据与文件甚至是 AJAX。缓存存在的意非凡义,缓存文件可以用于离线使用,同时也加快了网页的加载速度。浏览器除了底层的优化,对于缓存的合理利用也是非常重要的。不过缓存的存在,又是非常让人恼火。

以一个 style.css 的 CSS 文件为例,当我们再次修改了这个 style.css 文件时,浏览器有可能不会直接去服务器上获取最新版本的文件,而是选择直接从浏览器的缓存区获取这个文件。这就造成了预期效果与实际效果不一致的问题。而一般来说,产品、测试、客户是不会主动去清除缓存的。这样很容易就会被认为这个功能没有修改或者这个功能出现 BUG 的情况。合理的使用缓存就显得很有必要:

  • 一、HTTP 缓存机制

我们能够在浏览器上浏览网页下载文件其实都是 HTTP 请求的响应,那么缓存就是这条响应的副本。通过 HTTP 的响应头能够告诉客户端这条响应的缓存策略,实际上客户端在实现缓存的时候,也是会把缓存对应的 HTTP 响应头保存下来。 通过 HTTP Response Headers 的方式来实现对文件(数据)进行缓存策略的方式主要有以下几种。

使用 HTTP 的方式进行缓存策略

Expires -- 过期时间

Expires 标明了当前响应的缓存(副本)在什么时间之前是「新鲜」的。如果当前时间超过了此 HTTP Expires 的时间,浏览器就会重新向服务器发出请求,检查服务器上的响应是否被修改。他应该呈现为下面这种格式:

HTTP Expires声明过期时间

w3.org 关于 HTTP 响应的解释到,Expires 应该是一个绝对的时间单位,并且应该使用 RFC1123 来格式化。为了让浏览器永久缓存此文件,服务器返回的响应应当将此 HTTP 的 Expires 时间设置为距离当前时间一年左右。这个值不应该被设置为超过了一年的期限。

同时如果返回的 HTTP 的消息头当中缺少了 Expires 字段或者 Expires 字段的值不是未来时间的值,那么 HTTP/1.1 的浏览器就会默认当前响应是不能被缓存的,除非设置了类似 Cache-Control 的字段。RFC 7234 - HTTP:Caching 特别提到,如果 Expires 为 0 或者为 -1,就表示当前的时间为过去的时间,这条响应立即过期。

在响应当中如果同时设置了 Expires 和 Cache-Control(max-age),max-age 的优先级要比 Expires 高,max-age 会覆盖 Expires 的字段。因为 Expires 是 HTTP/1.0 的产物,HTTP/1.1 使用了 Cache-control 来取代 Expires。

Cache-control -- 缓存控制

Cache-control 一眼就能知道这个字段的作用,就是缓存控制的意思。相对于 Expires 只有日期的配置更加的灵活,对缓存的管理更加的细化,在两个同时存在的情况下,Cache-control 的优先级要高于 Expires。Cache-control 的指令有很多:

HTTP Cache-control进行缓存控制

① must-revalidate

  • 一旦该响应对应的副本过期了并且没有成功的与原始服务器进行响应是否修改的校验,再次请求该响应,是不能直接从缓存区拿到该响应的副本的。在所有情况下,缓存区都应该遵守这条指令。

② no-cache

  • 标明每次请求都需要从服务器获取,不需要判断缓存区的副本是否过期。

③ no-store

  • 强制缓存在任何情况下都不要保留任何副本。

④ public

  • 标明任何缓存区都可以缓存此响应,即使这条响应是不能被缓存或者已经被私有缓存区缓存过。这是由于缓存涉及到用户隐私的问题。

⑤ private

  • 标明此缓存是针对单个用户的并且一定不能被共享缓存区存储。私有缓存区有可能缓存此响应并且在之后的请求当中使用到该响应的副本,即使该响应通常不能被缓存。

⑥ proxy-revalidate

  • 和 must-revalidate 指令的作用相同,只不过不作用于私有缓存。

⑦ max-age

  • max-age 说的是副本的年龄,实际上也就副本的生命周期,max-age=1000 也就是此副本在缓存区中生存了 1000 秒之后就会被认为是不「新鲜」的了。

⑧ s-maxage

  • 类似于 max-age 属性,除了他应用于共享(如:代理服务器)缓存。

Last-Modified -- 最后修改时间

Last-Modified 的指令同样是一个 HTTP-date 时间,标明了服务器认为此响应最后修改的时间。

HTTP Last-Modified标明响应最后修改时间

Last-Modified 的实际意义取决于当前这个响应类型的性质。如果请求的是实体文件,那么这个 Last-Modified 有可能标明就是文件系统记录的当前这个文件最后的修改时间。如果是虚拟的对象,那么 Last-Modified 记录的就是最后修改对象内部数据的时间。如果文件修改的时间要比服务器消息时间要完,服务器应该使用响应的 Date 值来替换掉 Last-Modified 值。服务器生成 Last-Modified 的时间应该尽可能的与 Date 时间靠近,这能让浏览器更精准的判断文件最后修改时间、

Etag -- 响应标识

HTTP Etag响应标示

Etag 是服务端对响应内容的一个标示,用于响应内容的唯一标识。让我们假设在首次获取资源 120 秒之后,浏览器又对该资源发起了新请求。首先,浏览器会检查本地缓存并找到之前的响应,不幸的是,这个响应现在已经’过期’,无法再使用。此时,浏览器也可以直接发出新请求,获取新的完整响应,但是这样做效率较低,因为如果资源未被更改过,我们就没有理由再去下载与缓存中已有的完全相同的字节。

这就是 ETag 头中指定的验证令牌所要解决的问题:服务器会生成并返回一个随机令牌,通常是文件内容的哈希值或者某个其他指纹码。客户端不必了解指纹码是如何生成的,只需要在下一个请求中将其发送给服务器:如果指纹码仍然一致,说明资源未被修改,我们就可以跳过下载。

对于网页而言进行缓存控制可以有 HTML 标签控制与 HTTP 响应控制两种。使用 HTML 的 meta 标签能够告知浏览器此页面是否应该被缓存或者缓存的时间。

/* 生存时间为 0 */
<meta http-equiv="cache-control" content="max-age=0" />  
/* 不允许缓存 */
<meta http-equiv="cache-control" content="no-cache" />  
/* 立即过期 */
<meta http-equiv="expires" content="0" />  
/* 立即过期 */
<meta http-equiv="expires" content="Tue, 01 Jan 1980 1:00:00 GMT" />  
/* 不允许缓存 */
<meta http-equiv="pragma" content="no-cache" />  

但是同等情况下 meta 标签的优先级要低于 HTTP 的响应头信息。并且只能作用于 HTML 文件本身,所以在这里我就没有考虑 meta 缓存控制的方式了。

  • 二、强缓存与协商缓存

浏览器缓存区分为强缓存与协商缓存这两种方式。在这里有关于 强缓存与协商缓存 的资料。

强缓存与协商缓存

图中 from cache 是强缓存,当客户端(浏览器)发出一条请求时,浏览器会判断当前这条请求的响应是否已经被缓存过,如果有缓存,则会判断缓存是否过期。如果恰好这个缓存也没有过期,那么浏览器实际上就没有发出这条请求,而是直接从它的缓存区读取了文件。

浏览器如何调用缓存

所以也就是如果你的缓存过期了,浏览器会去服务器上验证文件是否修改过。如果发现文件修改了,重新从服务器上获取最新的文件,这时候返回 HTTP 状态 304。如果缓存没有过期,那么浏览器在这个时候出于性能优化,会选择直接从他的缓存区获取文件,也就是刚刚看到的 from cache,返回 HTTP 200。如果出于性能优化的考虑,就应该将文件的过期时间放长。

在这个时候,坑爹的情况就出现了。有时候浏览器不能正确的从服务器上获取文件时候修改而选择了从缓存区读取。如果文件的过期时间放的太长,浏览器也会直接使用缓存的文件。在开发频繁的背景下,线上已经更改过的文件并不能直接观看到更改过的效果。而绝大部分的用户、产品、测试是不会主动去清除缓存的。

缓存很好,挺高了网页的加载速度,但是也存在了缓存不能及时更新的的问题。

缓存不能被及时跟新

如何让缓存在我想让他缓存的时候就缓存,在我需要的时候就自动更新那?实际上目前还没有很好地主动清除方案,但是通过前端的自动化构建工具,我们能够做到合理的利用缓存与进行缓存的 “更新” 。

使用自动化工具 Gulp 更新缓存

  • 三、使用 Gulp 进行静态资源管理

前端自动化构建工具其实有很多,如 grunt、gulp、fis、webpack 等。这里我选择了 Gulp 这款基于流的自动化构建工具。相对于 Grunt 他的配置更加的简单。利用 Node.js 流的威力,可以快速构建项目并减少频繁的 IO 操作。同时如果有关于 Gulp 的问题可以直接在 stackoverflow上寻求帮助。

对于前端而言,一般我们只需要管理 html、css、js、img 等静态资源。通过使用 Gulp 前端自动化构建工具实现简单的文件压缩、缓存控制等任务。我的项目后端是 Java,而且项目的目录结构非常的复杂。

| --- webpp/
| ---- 项目A的 css、js、img、fonts 静态文件
| ---- 项目B的 css、js、img、fonts 静态文件
| ---- ... ...
| ---- 项目Z的 css、js、img、fonts 静态文件
| ---- WEB-INF 包含了项目 A-Z 的所有 JSP 页面

这样的情况难以对某个项目进行单独的构建,在发布新版本或者 BUG 修复的时候我只好构建整个的 webapp 前端代码。文件的缓存控制我使用了 gulp-rev 这个包,他能对文件进行 md5 重命名。

// 执行 gulp-rev,文件重命名
"css/unicorn.css": "css/unicorn-d41d8cd98f.css"

// 如果不修改文件,执行 gulp-rev
"css/unicorn.css": "css/unicorn-d41d8cd98f.css"

// 当文件修改过后,再次执行 gulp-rev
"css/unicorn.css": "css/unicorn-273c2cin3f.css"

当文件内容改变,文件名称也会随之改变,浏览器就会从服务器上获取最新的文件。为了能够最大化的利用浏览器缓存,我们应该将构建后的静态文件设置为永不过期never expires)。文件被修改浏览器就会从服务器上获取最新文件,如果没有被修改,那么浏览器就会选择从他的缓存区读取对应的文件。基于我的项目情况,我写了一个简单构建脚本:

  • CSS 文件的压缩与添加版本戳
gulp.task('css', function() {  
  return gulp.src('webapp/**/*.css')
    .pipe(plumber())
    .pipe(cleanCss())
    .pipe(rev())
    .pipe(gulp.dest('webapp-dist/'))
    .pipe(rev.manifest('cssmanifest.json'))
    .pipe(gulp.dest('manifest/'));
});
  • JS 文件的压缩与添加版本戳
gulp.task('js', function() {  
  return gulp.src('webapp/**/*.js')
    .pipe(plumber())
    .pipe(uglify())
    .pipe(rev())
    .pipe(gulp.dest('webapp-dist/'))
    .pipe(rev.manifest('jsmanifest.json'))
    .pipe(gulp.dest('manifest/'));
});
  • JSP 替换静态文件的引入路径
gulp.task('jsp', function() {  
  return gulp.src(['manifest/cssmanifest.json', 'manifest/jsmanifest.json', 'webapp/**/*.jsp', 'webapp/**/*.html'])
    .pipe(revcollector({replaceReved: true}))
    .pipe(gulp.dest('webapp-dist/'));
});

在 webapp 的目录上执行 gulp 命令后,gulp 会将 webapp 下的所有非前端类型(*.css、*.js、*.html、*.jsp)的文件全部复制到 webapp-dist 文件目录下。

| --- webapp      // 前端代码文件夹
| --- webapp-dist // webapp 文件夹的备份(不包含前端类型文件)

同时对 *.css、*.js 文件进行压缩与 MD5 重命名,并将重命名前后的文件路径保存到 manifest 目录下的 *.json 文件。之后 Gulp 会遍历 manifest 下的所有 json 文件与 webapp 目录下的所有 .html、.jsp 文件,替换这些页面所引入的静态文件路径。这些页面之后会被输出到 web-dist 下,完成整个缓存管理与文件压缩的构建。

| --- webapp      // 前端代码文件夹
| --- webapp-dist // 构建后的前端代码文件夹
| --- manifest    // 静态文件版本戳关系

Gulp 依赖的模块与完整的 gulpfile 文件在 tiny-gulp 上。

  • 三、总结

其实不仅仅是前端,缓存对于其他语言的价值也是非常的巨大。只不过缓存在实现的时候多少有些问题,这些问题造成了我们在使用上的一些困扰。前端迅猛发展到了今天,借助很多的工具能够实现对缓存的最大范围的利用。从一个缓存的优化起,到一个项目的优化是我目前所需要掌握的技能。

很晚了,早点睡觉了。

完。