浏览器缓存是一种非常有效的前端性能优化手段。很多时候大家会把浏览器缓存当成 “HTTP缓存”,实际上浏览器缓存主要分为四个方面,“HTTP缓存”只是浏览器缓存的一个方面而已。

  • Memory Cache
  • Service Worker Cache
  • HTTP Cache
  • Push Cache

# HTTP缓存

既然大家都熟知 “HTTP缓存” 我们就从 “HTTP缓存” 说起。HTTP缓存分为强缓存和协商缓存,如果没有命中强缓存就会走协商缓存。

下面我们新建个 demo, 来看下相关设置。

// /app.js
const Koa = require('koa')
const app = new Koa()
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')

const mimes = {
  css: 'text/css',
  less: 'text/css',
  gif: 'image/gif',
  html: 'text/html',
  ico: 'image/x-icon',
  jpeg: 'image/jpeg',
  jpg: 'image/jpeg',
  js: 'text/javascript',
  json: 'application/json',
  pdf: 'application/pdf',
  png: 'image/png',
  svg: 'image/svg+xml',
  swf: 'application/x-shockwave-flash',
  tiff: 'image/tiff',
  txt: 'text/plain',
  wav: 'audio/x-wav',
  wma: 'audio/x-ms-wma',
  wmv: 'video/x-ms-wmv',
  xml: 'text/xml',
}

function parseMime(url) {
  let extName = path.extname(url)
  extName = extName ? extName.slice(1) : 'unknown'
  return mimes[extName]
}

const parseStatic = (dir) => {
  return new Promise((resolve) => {
    resolve(fs.readFileSync(dir), 'binary')
  })
}

app.use(async (ctx) => {
  const url = ctx.request.url
  const filePath = path.resolve(__dirname, `.${url}`)
  ctx.set('Content-Type', parseMime(url))
  ctx.body = await parseStatic(filePath)
})

app.listen(3004, () => {
  console.log('starting at port 3000')
})
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <img src="./1.jpg" alt="">
</body>
</html>

使用 node 命令启动上面的 demo,我们1.jpg这个图片资源为例,可以看到每次请求图片都会向服务器发送请求。

# 强缓存

强缓存通过HTTP头中的 Expires 和 Cache-Control 控制,如果这次请求命中了强缓存就直接读取强缓存中的内容,不会和服务端通信,注意:这两个字段是通过服务端设置的,首次发送请求不会有,等这个请求返回后,会在 Response Headers 中设置这两个字段。

# Expires

之前实现强缓存都是通过设置 Expires 字段来实现的。当服务器返回响应时会将过期时间写到 expires 字段中。expires 是一个时间戳,如果本地时间小于 expires 设定的过期时间,发送请求时就从本地缓存中读取这个资源。





 



app.use(async (ctx) => {
  const url = ctx.request.url
  const filePath = path.resolve(__dirname, `.${url}`)
  ctx.set('Content-Type', parseMime(url))
  ctx.set('Expires', new Date(Date.now() + 30000))
  ctx.body = await parseStatic(filePath)
})

在上面代码中我们通过服务器给响应设置了 Expires 字段,当用户再次请求这个资源的时候,可以看到它是从 memory cache 中读取的资源,当然,如果文件大的话也可能是从 dist cache 中读取的。






 



app.use(async (ctx) => {
  const url = ctx.request.url
  const filePath = path.resolve(__dirname, `.${url}`)
  ctx.set('Content-Type', parseMime(url))
  ctx.set('Expires', new Date(Date.now() + 30000))
  ctx.set('Cache-Control', 'max-age=3600')
  ctx.body = await parseStatic(filePath)
})

由于 expires 的时间是由服务器端设定的,而本地时间是从从客户端读取的,有可能服务端和客户端的时间不一致,导致 expires 设置无效。所以后来引入了 Cache-Control 。

# Cache-Control

在 Cache-Control 中,我们通过 max-age 控制资源的有效期。max-age 不是一个时间戳而是一个时间长度,客户端会记录请求到资源的时间点,并以此作为相对时间的起点,当下次客户端发送请求的时候,会拿这两个时间相减,看看得到的时差是否小于 max-age 设定的时长,如果小于,则直接从缓存中读取资源。






 



app.use(async (ctx) => {
  const url = ctx.request.url
  const filePath = path.resolve(__dirname, `.${url}`)
  ctx.set('Content-Type', parseMime(url))
  ctx.set('Expires', new Date(Date.now() + 30000))
  ctx.set('Cache-Control', 'max-age=3600')
  ctx.body = await parseStatic(filePath)
})

Cache-Control 的 max-age 优先级比 expires 优先级高,当两者同时出现时,以 Cache-Control 为准。

Cache-Control 除了我们上面介绍的 max-age 外,还可以配置其他属性,这里选几个常用的简要说明下。

  • s-maxage

  • public 与 private 如果我们设置了public,这个资源既可以被浏览器缓存,也可以被代理服务器缓存。如果设置了private,则该资源只能给浏览器缓存。private是默认值。

  • no-store 与 no-cache

设置了no-store后,则表明不使用任何缓存策略,直接向服务器发送请求。 设置了no-cache后,跳过强制缓存,直接向服务端去确认该资源是否过期,也就是直接走协商缓存。

# 协商缓存

当强制缓存失效后,会走协商缓存,在协商缓存下,浏览器会向服务器询问缓存信息,看看是从服务器拿资源还是从本地拿资源。

如果服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304。

协商缓存通过设置 Last-Modified 和 Etag 字段进行设置。

# Last-Modified

Last-Modified 是一个通过服务端设置的时间戳,会在首次请求时随着 Response Headers 返回。当再次请求该资源时,请求头会带上一个 If-Modified-Since 时间戳,这个时间戳就是 Last-Modified 返回的那个时间戳。服务器会判断这个时间戳和资源最后修改时间是否相等,如果不相等,则说明资源被修改了,重新返回一个新的响应并携带上最新的 Last-Modified ,如果时间相等,就返回 304 ,Response Headers 不会再添加 Last-Modified 字段。

通过例子我们来看下:

function getFileStat(filePath) {
	return new Promise((resolve, reject) => {
		fs.stat(filePath, function(err, stats) {
			if (stats) {
				resolve(stats);
			} else {
				reject(err);
			}
		});
	});
}
app.use(async (ctx) => {
	const url = ctx.request.url;
	const filePath = path.resolve(__dirname, `.${url}`);
	ctx.set('Content-Type', parseMime(url));
  // 使客户端不走强缓存
	ctx.set('Cache-Control', 'no-cache');
  // 判断请求头中是否携带了 if-modified-since
	const ifModifiedSince = ctx.request.header['if-modified-since'];
  // 读取资源的修改时间
  const fileStat = await getFileStat(filePath);
  // 如果 if-modified-since 和服务器资源修改时间相等,说明资源没有修改,走协商缓存,返回304
	if (ifModifiedSince === fileStat.mtime.toGMTString()) {
		ctx.status = 304;
	} else {
    // 如果时间不等,说明资源发生了变化,重新设置 Last-Modified 的值,并返回新的资源
		ctx.set('Last-Modified', fileStat.mtime.toGMTString());
		ctx.body = await parseStatic(filePath);
	}
});

第一次请求:

第二次请求:

从上面例子可以看出

Last-Modified 的格式形如:

Last-Modified: Sat, 26 Jun 2021 04:38:59 GMT

If-Modified-Since 的格式形如:

If-Modified-Since: Sat, 26 Jun 2021 04:38:59 GMT

虽然我们使用 Last-Modified 可以设置协商缓存,但是这有一个前提,就是服务器可以正确感应到资源的变化。如果资源被编辑了,但是内容没有发生变化,或者是文件被修改的时间太快,导致服务器无法正确感应资源的变化,这个时候协商缓存就会失效。我们可以通过设置 etag 来避免上面的问题。

# Etag

etag 是服务器根据文件内容进行编码为每个资源设置的唯一标识符,只要文件内容没有发生变化,这个标识符就不会变。

当我们首次发送请求时,会在响应头设置 etag ,当下次发送请求的时候,请求头会携带一个值相同名为if-None-Match 字符串,让服务端进行对比,如果相同则走协商缓存,如果不同则返回新的资源。

下面我们看个demo:






 
 
 
 
 
 
 
 
 
 
 


app.use(async (ctx) => {
	const url = ctx.request.url;
	const filePath = path.resolve(__dirname, `.${url}`);
	ctx.set('Content-Type', parseMime(url));
	ctx.set('Cache-Control', 'no-cache');
	const fileBuffer = await parseStatic(filePath);
	const ifNoneMatch = ctx.request.headers['if-none-match'];
	const hash = crypto.createHash('md5');
	hash.update(fileBuffer);
	const etag = `"${hash.digest('hex')}"`;
	if (ifNoneMatch === etag) {
		ctx.status = 304;
	} else {
		ctx.set('etag', etag);
		ctx.body = fileBuffer;
	}
});

第一次请求:

第二次请求:

注意:etag 的生成比较耗费服务端性能,所以要看情况确认是否使用。所以 etag 只是作为 Last-Modified 的补充而不是替代。

# HTTP缓存决策

上面我们简要说了下HTTP的缓存,那什么时候应该使用缓存,什么时候不应该使用,应该使用哪种呢?

当资源不应该被复用的时候,需要将 Cache-Control 设置为 no-store,拒绝缓存。否则考虑每次是否都需要向服务器确认缓存是否有效,如果需要则将 Cache-Control 设置为 no-cache。如果不需要每次都向服务器确认,则考虑是否要让代理服务器缓存,根据结果设置private和public。然后考虑资源的过期时间,设置 max-age, s-maxage的值,然后设置协商缓存的 etag、Last-modified等。

# MemoryCache

MemoryCache 是指内存中的缓存,它是响应速度最快的一种缓存,但也是存活时间最短的一种,当渲染进程结束,它里面的数据也就不存在了。

# Service Worker Cache

Service Worker 独立于主线程之外的 JS 线程,脱离于浏览器窗体,无法直接访问 DOM 。它可以帮我们实现离线缓存、消息推送和网络代理等功能。

Service Worker的生命周期分为 install、active、working 三个阶段。一旦被 install 只会在 active 和 working 间切换,除非我们终止它。

Server Worker必须在https协议下才能使用。

Service Worker API (opens new window)

Service Workers Nightly (opens new window)

# Push Cache

Push Cache 是指 HTTP2 在 server push 阶段存在的缓存。目前应用还不多,可以阅读下这篇文章 HTTP/2 push is tougher than I thought (opens new window)

  • Push Cache 是缓存的最后一道防线。浏览器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情况下才会去询问 Push Cache。
  • Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。
  • 不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache。

参考:

前端性能优化原理与实践 (opens new window)

通过 Node.js 实践彻底搞懂强缓存和协商缓存 (opens new window)

Http Cache-Control,http头部已经设置了 (opens new window)