缓存之 HTTP 缓存

在一个 C/S 结构中,最基本的缓存分为两种:客户端缓存和服务器缓存。本文仅讨论客户端缓存,即将某一次的响应结果保存在客户端(比如浏览器中),而后续的请求只需要从缓存中读取即可,而不用频繁的请求服务器获取资源,从而极大的降低了服务器的处理压力。

1.设置缓存的重要性

在任何一个前端项目中,访问服务器获取数据都是常见的事情,但是如果相同的数据被重复请求了不止一次,那么多余的请求次数必然会浪费网络带宽,甚至延迟浏览器要渲染的内容,从而影响用户的使用体验。如果用户使用的是按流量计费的方式访问网络,那么多余的请求还会隐性地增加用户的网络流量费用。因此考虑使用缓存技术对已获取的资源进行重用,是一种提升网站性能和用户体验的有效策略。

缓存的原理是在首次请求后保存一份请求资源的响应副本(标记请求路径和方法),当用户再次发起相同的请求后,如果判断缓存命中则拦截请求,将之前存储的响应副本返回给用户,从而避免重新向服务器发起资源请求。

缓存的技术有很多种,比如代理缓存、浏览器缓存、网关缓存、负载均衡器及内容分发网络(CDN)等,它们大致可以分为两类:共享缓存私有缓存。共享缓存指的是缓存内容可以被多个用户使用,如公司内部架设的 Web 代理;私有缓存指的是只能单独被用户使用的缓存,如浏览器缓存,只有用户自己的浏览器可以使用该缓存。

缓存分类缓存类型
共享缓存代理服务器缓存、CDN
私有缓存浏览器缓存

HTTP 缓存是前端开发中最常接触的缓存机制之一,它又可以细分为强制缓存协商缓存,二者最大的区别在于判断缓存命中时,强制缓存不用去判断这个缓存是已过期,就可以直接使用,而协商缓存在每次获取缓存资源的时候,需要浏览器向服务器端进行询问(请求),该资源有没有更新,有更新则使用服务器端响应的最新的资源,如果没有更新就使用缓存中的内容,简而言之协商缓存需要通过客户端向服务器端请求协商缓存的相关信息,进而判断是否需要就内容进行重新请求。下面就来具体看看 HTTP 缓存的具体机制及缓存的决策策略。

缓存简单原理

2.强制缓存及其配置

对于强制缓存而言,如果浏览器判断所请求的目标资源有效命中缓存,则可以直接从强制缓存(一般为内存或本地磁盘)中返回响应副本,无需与服务器进行任何通信。

在介绍强制缓存命中判断之前,我们首先来看一段响应头的部分信息:

Access-Control-Allow-Orgin: *
Age: 798765
Content-Length: 80030
Content-Type: image/png
Cache-Control: max-age= 31536000
Expires: Fri, 26 Apr 2024 02:08:00 GMT

其中与强制缓存相关的两个字段是 Expires 和 Cache-Control,Expires 是在 HTTP1.0 协议中声明的用来控制缓存失效日期时间戳的字段,这是一个绝对时间,表示具体的几点几分几秒后失效,它由服务器(服务器端的时间为格林威治时间 GMT)指定后通过响应头告知浏览器,浏览器在接收到该字段的响应头后进行缓存。

这之后浏览器再次发起相同的资源请求,会对比 Expires 与本地当前的时间戳,如果当前请求的本地时间戳小于 Expires 的值,则说明浏览器缓存的响应还未过期,可以直接使用而无须向服务器端再次发起请求。只有当本地时间戳大于 Expires 值时,发现缓存过期,才允许重新向服务器端发起请求。

为了让大家对强制缓存有更好的理解,下面通过代码的方式给大家演示一下 ,注意一般缓存主要针对页面中不经常变动的静态资源进行缓存,动态资源要保证数据的实时性,所以很少对动态资源进行缓存。

①.package.json 文件, 通过本地安装 nodemon 实时监听 app.js 文件变化

$ npm i -D nodemon
{
  "name": "http-cache",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon app.js"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "nodemon": "^3.1.0"
  }
}

②.html 文件

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>HTTP Cache</title>
  </head>

  <body>
    <h1>HTTP缓存</h1>
    <img width="150px" src="./img/1.jpg" />
    <h2>强制缓存</h2>
    <hr />
    <h2>协商缓存</h2>
  </body>
</html>

③.app.js 文件

const http = require('http')
const fs = require('fs')
const url = require('url')
http
  .createServer((req, res) => {
    console.log(req.method, req.url)
    const { pathname } = url.parse(req.url)
    if (pathname === '/') {
      // 读取 index.html 响应给客户端
      fs.readFile('./index.html', 'utf-8', (err, data) => {
        res.end(data)
      })
    } else if (pathname === '/img/1.jpg') {
      // 异步读取 img/1.jpg 响应给客户端
      fs.readFile('./img/1.jpg', (err, data) => {
        // Expires 设置强制缓存过期时间,时间需要通过 toUTCString() 转换为 GMT 时间
        // Expires 不用区分大小写 会被 Node 在 res.headers 中自动转为 expires
        res.writeHead(200, {
          // new Date(本地时间)
          Expires: new Date('2024-4-26 12:30:50').toUTCString(),
        })
        // 不设置响应头信息,默认情况下,没有任何缓存
        res.end(data)
      })
    } else {
      res.statusCode = 404
      res.end()
    }
  })
  .listen(3000, () => {
    console.log('http://localhost:3000')
  })
$ npm run dev

以上代码中可以通过修改响应头信息中的 Expires 绝对时间来测试强制缓存是否生效,具体效果如下:

从上述强制缓存是否过期的判断机制中不难看出,这个方式存在一个很大的漏洞,即对本地时间戳过分依赖,如果客户本地的时间与服务器端的时间不同步,或者客户主动修改了客户端的时间,那么对于缓存过期的判断就可能无法达到预期的结果,从而无法达到预期的效果。

为了解决 Expires 判断的局限性,从 HTTP1.1 协议开始新增了 Cache-Control 字段来对 Expires 的功能进行扩展和完善。从上述响应头信息中可以看出,Cache-Control 设置了 max-age=31536000 的属性值来控制响应资源的有效期,它是一个以秒为单位的时间长度( 3153600 是 1 年的秒数),表示该资源被请求到后的 3153600 秒内有效,如此就避免了服务器端和客户端时间戳不同步而造成的问题。

Cache-Control 既可以做强制缓存,也可以做协商缓存。除此之外,Cache-Control 还可以配置一些其他属性值来更准确地控制缓存,下面来具体介绍。

①.html 文件

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>HTTP Cache</title>
  </head>

  <body>
    <h1>HTTP缓存</h1>

    <h2>强制缓存</h2>
    <img width="150px" src="./img/1.jpg" />
    <img width="150px" src="./img/2.jpg" />
    <hr />
    <h2>协商缓存</h2>
  </body>
</html>

②.app.js 文件

const http = require('http')
const fs = require('fs')
const url = require('url')
http
  .createServer((req, res) => {
    console.log(req.method, req.url)
    const { pathname } = url.parse(req.url)
    if (pathname === '/') {
      // 读取 index.html 响应给客户端
      fs.readFile('./index.html', 'utf-8', (err, data) => {
        res.end(data)
      })
    } else if (pathname === '/img/1.jpg') {
      // 异步读取 img/1.jpg 响应给客户端
      fs.readFile('./img/1.jpg', (err, data) => {
        // Expires 设置强制缓存过期时间,时间需要通过 toUTCString() 转换为 GMT 时间
        // Expires 不用区分大小写 会被 Node 在 res.headers 中自动转为 expires
        res.writeHead(200, {
          // new Date(本地时间)
          Expires: new Date('2024-4-26 12:30:50').toUTCString(),
        })
        // 不设置响应头信息,默认情况下,没有任何缓存
        res.end(data)
      })
    } else if (pathname === '/img/2.jpg') {
      // 异步读取 img/2.jpg 响应给客户端
      // 通过设置 Cache-Control 配置强制缓存的过期时间
      fs.readFile('./img/2.jpg', (err, data) => {
        res.writeHead(200, {
          'Cache-Control': 'max-age=60', // max-age 是一个滑动时间,单位是秒
        })
        // 不设置响应头信息,默认情况下,没有任何缓存
        res.end(data)
      })
    } else {
      res.statusCode = 404
      res.end()
    }
  })
  .listen(3000, () => {
    console.log('http://localhost:3000')
  })

no-cache 和 no-store

设置 no-cache 并非表示不设置缓存,而是表示强制进行协商缓存(后面会说到),即对于每次发起的请求都不会再去判断强制缓存是否过期,而是直接与服务器协商来验证缓存的有效性,若缓存过期,则会使用本地缓存。

设置 no-store 则表示禁止使用任何缓存策略,客户端的每次请求都需要服务器给予全新的响应。no-cache 和 no-store 是两个互斥的属性,不能同时设置。

发送如下响应头可以关闭缓存

Cache-Control: no-cache

指定 no-cache 或 max-age=0 表示客户端可以缓存资源,每次使用缓存资源前都必须重新验证其有效性。这意味着每次都会发起 HTTP 请求,但当缓存内容仍有效时可以跳过 HTTP 响应体的下载,本质是协商缓存。

Cache-Control: max-age=0

private 和 public

private 和 public 也是 Cache-Control 的一组互斥的属性值,它们可以明确响应资源是否可以被代理服务器进行缓存。

若资源响应头中的 Cache-Control 设置了 public 属性值,则表示响应资源既可以被浏览器缓存,又可以被代理服务器进行公共缓存。

private 则限制了响应资源,只能被浏览器缓存,也就是私有缓存,不能被代理服务器缓存,若未显示指定,默认值为 private。

对于应用程序中不会经常改变的文件,通常可以在发送响应头前添加积极缓存(浏览器和代理服务器均可以缓存)。主要包括应用程序提供的静态文件,例如图像、CSS 文件和 JS 文件。

Cache-Control: public, max-age=3153600

max-age 和 s-maxage

max-age 属性值比 s-maxage 更常用,它表示服务器端告知客户端浏览器响应资源的过期时长。在一般项目的使用场景中基本够用,对于大型架构的项目通常会涉及使用各种代理服务器的情况,这就需要考虑缓存在代理服务器上的有效性问题。这便是 s-maxage 存在的意义,它表示缓存在代理服务器中的过期时长。

Cache-Control: max-age=3153600, s-maxage=3600

结论:由此可见 Cache-Control 能作为 Expires 的完全替代方案,并且拥有其所不具备的一些控制缓存的特性,在项目实践中使用它就够了,目前 Expires 还存在的唯一理由是考虑可用性方面的向下兼容。

3.协商缓存及其配置

协商缓存就是使用本地缓存之前,需要向服务器端发起一次 GET 请求,与之协商当前浏览器保存的本地缓存是否已经过期。(就是问一下服务器,我请求的资源缓存是否有效,有效就使用本地缓存;否则就响应一个最新的资源内容)

协商缓存主要为了解决在强制缓存中,请求的 url 路径和 method 不变的情况下,资源不更新或未及时更新的问题。

通常是采用所请求资源的最后一次的修改时间戳来判断的,为了便于理解,下面来看一个例子:假设客户端浏览器需要向服务器请求一个 manifest.js 的 JS 文件,为了让改资源被再次请求时能够通过协商缓存的机制使用本地缓存,那么首次返回该资源的响应头中应该包含一个名为 Last-Modified 的字段,该字段的属性值为该 JS 文件最近一次修改的时间戳,简略截取请求头与响应头的关键信息如下:

Request URL: http://localhost:3000/manifest.js
Request Method: GET

Last-Modified: Tue, 23 Apr 2024 05:07:29 GMT
Cache-Control: no-cache

当我们刷新网页时,由于该 JS 文件使用的是协商缓存,客户端浏览器无法确定本地缓存是否过期,所以需要向服务器发送一次 GET 请求,进行缓存有效性的协商,这一次的请求的请求头中需要包含一个 If-Modified-Since 字段的值,如果二者相同说明缓存未过期,可继续使用本地缓存,否则需要服务器重新返回全新的文件资源,简略截取请求头与响应头的关键信息如下:

// 再次请求的请求头
Request URL: http://localhost:3000/manifest.js
Request Method: GET
If-Modified-Since: Sun, 02 Sep 2018 03:07:37 GMT

// 协商缓存有效的响应头,Last-Modified 和 Cache-Control: no-cache 需要同时设置
Last-Modified: Sun, 02 Sep 2018 03:07:37 GMT
Cache-Control: no-cache

// 协商缓存命中状态码为 304,此后不会再下载响应体
Status Code: 304 Not Modified

只设置 Last-Modified 而没有 Cache-Control: no-cache 相当于强制缓存,不会进行协商缓存,不会有协商操作。另外还要注意,协商缓存判断缓存有效的响应状态码是 304,即缓存有效响应重定向到本地缓存上,且后续不会再下载响应体。这个强制缓存有所不同,强制缓存若有效,则再次请求的响应状态码为 200。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>HTTP Cache</title>
  </head>

  <body>
    <h1>HTTP缓存</h1>

    <h2>强制缓存</h2>
    <!-- <img width="150px" src="./img/1.jpg"> -->
    <!-- <img width="150px" src="./img/2.jpg"> -->
    <hr />
    <h2>协商缓存</h2>
    <img width="150px" src="./img/3.jpg" />
  </body>
</html>
// app.js
const http = require('http')
const fs = require('fs')
const url = require('url')
http
  .createServer((req, res) => {
    console.log(req.method, req.url)
    const { pathname } = url.parse(req.url)
    if (pathname === '/') {
      // 读取 index.html 响应给客户端
      fs.readFile('./index.html', 'utf-8', (err, data) => {
        res.end(data)
      })
    } else if (pathname === '/img/1.jpg') {
      // ...
    } else if (pathname === '/img/3.jpg') {
      // 异步读取 img/3.jpg 响应给客户端
      // 通过设置 "Cache-Control": "no-cache" 通过判断文件的修改时间是否发生变化,来设置协商缓存
      fs.readFile('./img/3.jpg', (err, data) => {
        // console.log(req.headers);
        // 获取文件修改时间 ctime
        fs.stat('./img/3.jpg', (err, stat) => {
          const { ctime } = stat
          if (req.headers['if-modified-since'] === ctime.toUTCString()) {
            // 缓存生效,无需再响应 data
            res.statusCode = 304
            res.end()
            return
          }
          // 首次请求或资源发生修改时,重新请求,返回响应头的信息
          res.writeHead(200, {
            'Cache-Control': 'no-cache',
            'Last-Modified': ctime.toUTCString(),
          })
          // writeHead 可以写入多个header,setHeader 每次写入 1 个
          // res.setHeader("Cache-Control","no-cache")
          // res.setHeader( "Last-Modified", ctime.toUTCString())
          // 不设置响应头信息,默认情况下,没有任何缓存
          res.end(data)
        })
      })
    } else {
      res.statusCode = 404
      res.end()
    }
  })
  .listen(3000, () => {
    console.log('http://localhost:3000')
  })

20240426_04

Last-Modified 的不足

通过 Last-Modified 所实现的协商缓存能够满足大部分的使用场景,但也存在两个比较明显的缺陷:

首先,它只是根据资源最后的修改时间戳进行判断的,虽然请求的资源进行了编辑,但内容没有发生任何变化,时间戳也会更新,从而导致协商缓存时关于有效性的判断,验证为无效,需要重新进行完整的资源请求。这无疑会造成网络带宽的浪费,以及验证用户获取目标资源的时间。

其次,标识文件资源修改的时间戳单位是秒,对于毫秒级的修改会无感知,如果文件修改的速度非常快,假设在几百毫秒内完成,那么上述通过时间戳的方式来验证缓存的有效性,是无法识别出该文件资源这一次的更新的。

其实,造成上述两种缺陷的原因相同,就是服务器无法仅根据资源修改的时间戳来识别出真正的更新,从而导致重新发起了请求,该重新请求却使用了缓存或未能使用缓存。

基于 ETag 的协商缓存

为了弥补通过时间戳判断缓存是否失效的不足,从 HTTP 1.1 规范开始新增了一个 ETag 的头信息,即实体标签(Entity Tag)。

其内容主要是服务器为不同资源进行哈希运算所生成的一个字符串,该字符串类似于文件指纹,只要文件内容编码存在差异,对应的 Etag 标签值就会不同,因此可以使用 ETag 对文件资源进行更精准的变化感知。下面我们来看一个使用 Etag 进行协商缓存图片资源的示例,首次请求后的部分响应头关键信息如下:

// 响应头
Content-Type: text/css
Cache-Control: max-age=315360000
Date: Fri, 26 Apr 2024 05:50:40 GMT
Etag: W/"661fa985-1a35"
Expires: Thu, 31 Dec 2037 23:55:55 GMT
Last-Modified: Wed, 17 Apr 2024 10:50:45 GMT

上述响应头中同时包含了 Last-Modified 和 Etag 。

如何生成 ETag 呢, 可以有多种方式,比如 Node.js 内置的 crypto 加密模块提供了加密功能,其中包括了用于 OpenSSL 散列、HMAC、加密、解密、签名、以及验证的函数的一整套封装;也可以使用第三方库 MD5 生成唯一不变的类似文件指纹的 hash 值;本示例中使用基于 HTTP/1.1 中的 ETag 封装的 etag 包进行演示。

npm i etag@1.8.1
{
  "name": "http-cache",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon app.js"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "nodemon": "^3.1.0"
  },
  "dependencies": {
    "etag": "^1.8.1"
  }
}

const http = require('http')
const fs = require('fs')
const url = require('url')
const etag = require('etag')
http
  .createServer((req, res) => {
    console.log(req.method, req.url)
    const { pathname } = url.parse(req.url)
    if (pathname === '/') {
      // 读取 index.html 响应给客户端
      fs.readFile('./index.html', 'utf-8', (err, data) => {
        res.end(data)
      })
    } else if (pathname === '/img/1.jpg') {
      // ...
    } else if (pathname === '/img/2.jpg') {
      // ...
    } else if (pathname === '/img/3.jpg') {
      // ...
    } else if (pathname === '/img/4.jpg') {
      // 异步读取 img/4.jpg 响应给客户端
      // Etag 方式实现协商缓存
      fs.readFile('./img/4.jpg', (err, data) => {
        const etagContent = etag(data)
        if (req.headers['if-none-match'] === etagContent) {
          // 缓存生效,无需再响应 data
          res.statusCode = 304
          res.end()
          return
        }
        // 首次请求或资源发生修改时,重新请求,返回响应头的信息
        res.writeHead(200, {
          'Cache-Control': 'no-cache',
          Etag: etag(data),
        })
        res.end(data)
      })
    } else {
      res.statusCode = 404
      res.end()
    }
  })
  .listen(3000, () => {
    console.log('http://localhost:3000')
  })

ETag 的不足

基于 Etag 设置协商缓存的方式看起来比 Last-Modified 的方式更好一些,但也多多少少也存在着诸多问题。不像强制缓存中 Cache-Control 可以完全替代 Expires 的功能,在协商缓存中,Etag 并非 Last-Modified 的替代方案而是一种补充方案,因为它依旧存在一些弊病。

一方面,服务器对于生成文件资源的 ETag 需要付出额外的计算开销,如果资源占用的空间较大,数量较多且修改比较频繁,那么生成 ETag 的过程就会影响服务器的性能。

另一方面, ETag 字段的生成分为强验证和弱验证,强验证根据资源内容进行生成,能够保证每个字节都相同;弱验证则根据资源的部分属性值(如修改时间)来生成 ETag,生成速度快但无法确保每个字节都相同,并且在服务器集群场景下,也会因为不够准确而降低协商缓存有效性验证的成功率,所以恰当的方式是根据具体的资源使用场景选择合适的缓存校验方式。

4.缓存选择决策策略

前面我们较为详细地介绍了浏览器 HTTP 缓存的配置和验证细节,下面思考一下如何应用 HTTP 缓存技术来提升网站的性能。假设在不考虑客户端缓存容量与服务器算力的理想情况下,我们当然希望客户端浏览器上的缓存触发率尽可能高,留存时间尽可能长,同时还需要通过 Etag 实现当资源更新时进行高效的重新验证。

但实际情况往往是容量与算力都有限,因此就需要制定合适的缓存策略,来利用有限的资源达到最后的性能优化效果。明确能力的边界,力求在边界内做到最好。

缓存决策树

在面对一个具体的缓存需求时,到底如何制定缓存策略呢?我们可以参照如图所示的决策树来逐步确定对一个资源具体的缓存策略。

首先根据资源内容的属性判断是否需要使用缓存,如果不希望对该资源开启缓存(比如涉及用户的一些敏感信息),则可以直接设置 Cache-Control 的属性值为 no-store 来禁止任何缓存策略,这样请求和响应的信息就都不会被存储在对方及中间代理的磁盘系统上了。

如果希望使用缓存,那么接下来就需要确定对缓存有效性的判断是否需要和服务器进行协商,若需要与服务器协商则可以设置 Cache-Control 的属性值为 no-cache,来强制启用协商缓存。

否则接下来考虑是否开启中间代理服务器缓存该资源,可以通过设置 Cache-Control 的属性值为 private 或 public 来进行控制。如果之前未设置 no-cache 启用协商缓存,那么接下来可以设置强制缓存的过期时间,即 Cache-Control: max-age=xxx(事件戳) 的属性值 或 Expires: 绝对时间的时间戳,最后如果启用了协商缓存,则可进一步设置请求资源的 Last-Modified 和 Etag 实体标签等参数。

这里建议你能够根据该决策树的流程去设置缓存策略,这样不但会让指定的策略有很高的可行性,而且对于理解缓存中的各个知识点也非常有帮助。

缓存决策实例

在使用缓存技术优化性能,提升体验的过程中,有一个问题是不可逾越的:我们既希望缓存能够在客户端尽可能久的保存,又希望它在资源发生修改时进行及时的更新。

这是两个互斥的需求,使用强制缓存并定义足够长的时间就能让缓存在客户端长期驻留,但由于强制缓存的优先级高于协商缓存,所以很难进行及时更新;若使用协商缓存,虽然能够保证及时更新,但频繁与服务器进行协商验证,其响应速度肯定不及使用强制缓存快。那么如何兼顾二者的优势呢?

我们可以将一个网站所需要的资源按照不同类型去拆解,为不同类型的资源制定相应的缓存策略,以下面的 HTML 文件资源为例:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>HTTP 缓存策略</title>
    <link rel="stylesheet" href="style.css" />
  </head>

  <body>
    <img src="photo.jpg" />
    <script src="main.js"></script>
  </body>
</html>

该 HTML 文件中包含了一个 JS 文件 main.js 、一个样式表文件 style.css 和 一个图片文件 photo.jpg,若要展示出该 HTML 中的内容,就需要加载出其中包含的所有外部链接文件。因此,我们可以针对它们进行如下配置。

首先,HTML 在这里属于其他文件的主文件,为了保证当其内容发生修改时能够及时更新,应当将其设置为协商缓存,即 Cache-Control 字段添加 no-cache 属性值;其次是图片文件,因为网站对图片文件的修改基本上都是更换修改,同时考虑到图片文件的数量及大小可能对客户端缓存空间造成不小的开销,所以可以采用强制缓存且过期时间不宜过长,故可设置过期时间为 24 小时,即 Cache-Control: max-age=86400。

接下来要考虑样式表文件 style.css ,由于其属于文本文件,可能存在内容的不定期修改,又想使用强制缓存在提供重用效率,故可以考虑在样式表文件的命名中增加文件指纹或版本号(例如 Webpack 打包后添加的 hash 值便可以作为文件指纹,使文件名变为 style.94c9eb0f02d.css), 这样当 CSS 文件内容发生修改后,不同的文件便会有不同的文件指纹,即需要请求的文件 URL 不同了,因此必然会发生对该文件资源的重新请求。同时考虑到网络浏览器与 CDN 等中间代理的缓存,其过期时间可适当延长到一年,即 Cache-Control: max-age=31536000。

最后是 JS 脚本文件,可将其缓存设置与 CSS 样式表文件一样,采取文件指纹和较长的过期时间,如果 JS 文件中包含了用户的私人信息而不想让中间代理缓存,则可为 Cache-Control 添加 private 属性值。

从这个缓存策略的示例中我们可以看出,对不同资源进行组合使用强制缓存、协商缓存及文件指纹或版本号,可以做到一举多得:及时修改文件、较长缓存过期时间以及控制所能进行缓存的位置。

缓存设置注意事项

在前面的内容虽然给出了一种制定缓存策略的思路与示例,但需要明白的一点是:不存在适用于所有场景下的最佳缓存策略。即便有缓存,在第一次下载时也需要去请求资源,耗费大量的资源和时间。凡是恰当的缓存策略都需要根据具体场景下的请求资源类型数据更新要求网络通信模式等多方面因素考量后制定出来,所以下面列举一下缓存决策时的注意事项,来作为决策思路的补充:

①.拆分源码,分包加载

对大型的前端应用迭代开发来说,其代码量通常很大,如果发生修改的地方集中在几个重要的模块中,那么进行全量的代码更新显然会显得比较冗余,因此我们可以考虑在代码构建过程中,按照模块拆分将其打包成多个单独的文件。这样在每次修改后的更新提取时,仅需拉取发生修改的模块代码包,从而大大降低了需要下载的内容大小。

②.预估资源的缓存时效

根据不同资源的不同需求特点,规划相应的缓存更新时效,为强制缓存指定合适的 max-age 取值,为协商缓存提供验证更新的 Etag 实体标签。

③.控制中间代理的缓存

凡是会涉及到用户隐私信息的尽量避免中间代理的缓存,如果对所有用户响应相同的资源,则可以考虑让中间代理也进行缓存。

④.避免网址的冗余

缓存是根据请求资源的 URL 进行的,不同的资源会有不同的 URL,所以尽可能不要将相同的资源设置为不同的 URL。

⑤.规划缓存的层次结构

参考缓存决策中介绍的示例,不仅是请求的资源类型,文件资源的层次结构也会对制定缓存策略有一定的影响,我们应当综合考虑。