使用缓存技术对已获取的资源进行重用,是一种提升网站性能与用户体验的有效策略。
一、缓存的原理
在首次请求后,保存一份请求请求资源的响应副本,当用户再次发起相同请求后,如果判断缓存命中,则拦截请求,将之前存储的相应副本返回给用户,从而避免重新向服务器发起资源请求。
缓存的技术种类
缓存的技术种类有很多,例如:
- 代理缓存
- 浏览器缓存
- 网关缓存
- 负载均衡
- 内容分发网络
它们大致可以分为两类:共享缓存 和 私有缓存两类
- 共享缓存:指的是缓存内容可被多个用户使用,如公司内部架设的web代理
- 私有缓存:私有缓存指的是只能单独被用户使用的缓存,如浏览器缓存。
我们主要讨论和前端密切相关的浏览器HTTP
缓存机制,它可以分为强制缓存和协商缓存。二者最大的区别在于是否向服务器发出请求。强缓存命中的话不会发请求到服务器,协商缓存一定会发请求到服务器,通过资源请求头字段验证资源是否命中协商缓存,如果协商缓存命中,服务器会将请求返回,但不会返回资源,此时code
为304
。
二、强缓存
对于强制缓存而言,如果浏览器判断所请求的目标资源有效命中,则可直接从强制缓存中返回请求响应,无需与服务器进行任何通信。
控制强缓存的字段常用的有:Expires
和Cache-Control
Expires
1. expires
缓存机制
expires
是在HTTP1.0
协议中声明的用来控制缓存失效日期时间戳的字段,它由服务器端制定后通过响应头告知浏览器,浏览器在接收到带有该字段的响应体后进行缓存。- 之后浏览器再次发起相同的资源请求,便会把
expires
与本地当前的时间戳进行对比,如果本地请求的时间戳未超过expires
的时间戳(本地时间戳
<expires
),则说明浏览器缓存未过期,无需向服务器发起请求,可以直接使用本地资源。
2. Expires
的局限性
依赖本地时间戳,若客户端时间与服务端时间不同步,则缓存过期时间的判断就不准确。
Cache-Control
为了解决expires
的局限性,从HTTP1.1
协议开始新增了Cache-Control
字段。
1. Cache-Control: max-age=<seconds>
指令
max-age=<seconds>
通过设置一个秒为单位的时间长度,表示该资源在被请求后到seconds
秒内有效,如此便可以避免服务器时间与本地时间不同步的问题。
2. Cache-Control
的其它指令
除了max-age
之外,cache-control
还可以配置其他指令
Cache-control: must-revalidate
Cache-control: no-cache
Cache-control: no-store
Cache-control: no-transform
Cache-control: public
Cache-control: private
Cache-control: proxy-revalidate
Cache-Control: max-age=<seconds>
Cache-control: s-maxage=<seconds>
3. no-cache
和no-store
no-cache
和no-store
是两个互斥的属性值,不能同时设置。
no-cache
:设置no-cache
并未像字面上的意思不使用缓存,其表示为强制进行协商缓存,即对于每次发起的请求都不会再去判断强制缓存是否过期,而是直接与服务器协商来验证缓存的有效性,若缓存未过期,则会使用本地缓存。no-store
:设置no-store
则表示禁止使用任何缓存策略,客户端的每次请求都需要服务器端给予全新的响应。
4. private
和public
private
和public
也是cache-control
的一组互斥属性值,它们用以明确响应资源是否可被代理服务器进行缓存。
cache-control
设置了public
属性值,则表示响应资源既可以被浏览器缓存,又可以被代理服务缓存。cache-control
设置了private
属性值,则限制了响应资源只能被浏览器缓存,若未显示指定则默认值为private
5. max-age
和s-maxage
在一般项目的使用场景中多为
max-age
,但对于大型架构的项目通常会涉及使用各种代理服务器的情况,此时便需要考虑缓存在代理服务器上的有效性问题,便会用到s-maxage
。
max-age
它表示服务器端告知客户端浏览器响应资源的过期时长。s-maxage
它表示缓存在代理服务器中的过期时长,且仅当设置了public
属性值时才有效。
Pragma
Pragma
是一个在 HTTP/1.0
中规定的通用首部,这个首部的效果依赖于不同的实现,所以在“请求 – 响应”链中可能会有不同的效果。它用来向后兼容只支持 HTTP/1.0
协议的缓存服务器,因为那时候 HTTP/1.1
协议中的 Cache-Control
还没有出来。
代码演示
-
新建一个
app.js
文件,使用node.js
中的http
模块,实现服务端的HTTP
服务。// app.js const http = require('http') http.createServer((req,res)=>{ res.end('hello') }).listen(3000,()=>{ console.log("Server running... http://localhost:3000"); })
-
在终端中启动服务
node ./app.js
(建议使用nodemon
启动服务,每次修改代码可以自动重启) -
打开浏览器 输入
localhost:3000
,如下所示说明我们的服务已经启动成功 -
接下来我们写一个
html
文件,让服务端发送回来新建
强制缓存.html
文件,并在里面引入两张图片(网上随便下载的两张景色图)<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>强制缓存</title> </head> <body> <h1>强制缓存</h1> <img src="./img/01.png" alt="01.png" width="200px"> <img src="./img/02.png" alt="02.png" width="200px"> </body> </html>
-
修改
app.js
文件,返回强制缓存.html
- 使用
fs
模块读取文件 - 使用
url
模块解析url
const http = require('http') const fs = require('fs') const url = require('url') http.createServer((req,res)=>{ const { pathname } = url.parse(req.url) if(pathname === '/'){ const data = fs.readFileSync('./强制缓存.html') res.end(data) }else if(pathname === '/img/01.png'){ // 请求01.png时,读取01.png并返回 const data = fs.readFileSync('./img/01.png') res.end(data) } else if(pathname === '/img/02.png'){ // 同理 const data = fs.readFileSync('./img/02.png') res.end(data) }else { // 对于其他请求 返回404 res.statusCode = 404 res.end() } }).listen(3000,()=>{ console.log("Server running... http://localhost:3000"); })
- 使用
-
重启服务,打开浏览器,可以正常请求到我们的
html
文件和图片 -
默认情况下,是没有任何缓存的,在
network
里我们可以多次刷新查看请求 -
给这两张图以不同的方式加上强制缓存
- 第①张图使用
expires
- 第②张图使用
cache-control:max-age
... http.createServer((req,res)=>{ ... }else if(pathname === '/img/01.png'){ const data = fs.readFileSync('./img/01.png') // 写响应头 , 200 - 响应码 , Expires是一个绝对时间,UTC时区的日期字符串 res.writeHead(200,{ Expires: new Date('2023-7-20 20:09:30').toUTCString() // 我现在的时间是2023-07-20 20:09:00 ,我设置20:09:30缓存失效,方便观察 }) res.end(data) } else if(pathname === '/img/02.png'){ const data = fs.readFileSync('./img/02.png') // max-age的时间单位是 秒 ,这里我设置10s res.writeHead(200,{ 'Cache-Control':'max-age=10', }) res.end(data) })
- 第①张图使用
-
分享每次请求缓存情况
-
第一次打开浏览器页面
2023-07-20 20:09:10
,都没有命中缓存,这是第一次请求,所以不会有缓存可以用 -
第二次刷新页面
2023-07-20 20:09:15
,图1、图2都命中了缓存- 图1缓存是
Expires
:当前时间2023-07-20 20:09:15 < 过期时间是2023-07-20 20:09:30
,因此命中缓存。 - 图2缓存是
max-age
:从第一次请求20:09:10
到20:09:15
过去了5s
,小于设置的10s
有效期,因此缓存也有效。
- 图1缓存是
-
第三次刷新页面
2023-07-20 20:09:27
,图1命中缓存、图2没有命中- 图1:
当前时间2023-07-20 20:09:27 < 过期时间是2023-07-20 20:09:30
,缓存有效 - 图2:
20:09:27 - 20:09:10 = 17s
,距离最近一次请求已过去17s
,缓存失效
- 图1:
-
第四次刷新页面时间
2023-07-20 20:09:34
,图1未命中缓存、图2命中- 图1:
当前时间2023-07-20 20:09:34 > 过期时间是2023-07-20 20:09:30
,缓存失效,后面永远不会再有效(客户端与服务端时间一致的前提下) - 图2: 这时图2的对比时间不再是一开始的
2023-07-20 20:09:10
,因为它是距离最近一次请求的10s
内有效,最近一次请求是第三次刷新页面时发出的20:09:27
,距离现在20:09:34
才过去7s
,因此缓存有效。
- 图1:
-
三、协商缓存
协商缓存在使用本地缓存之前,需要向服务器端发起一次
GET
请求,与之协商当前浏览器保存的本地缓存是否已经过期。
协商缓存常用的控制字段:Last-Modified/If-Modified-Since
和 ETag/If-None-Match
Last-Modified/If-Modified-Since
1. 实现原理
客户端向服务端请求一个01.png
的图片资源时,为了让该资源被再次请求时能够通过协商缓存的机制使用本地缓存,那么首次返回该图片资源时,响应头中会包含一个名为last-modified
的字段,该字段的属性值为该文件最近一次修改的时间戳。
当我们刷新网页再次请求该资源时,由于使用的是协商缓存,客户端无法直接判断本地缓存是否有效,所以会向服务器发送一次GET
请求,进行缓存有效性的协商,此次请求的请求头中会包含一个ifmodified-since
字段,该字段的属性值为上次响应头中last-modified
的字段值。
当服务器收到该请求后,便会用当前请求的资源文件的修改时间戳 与 if-modified-since
字段的值进行对比,若二者相同则说明缓存未过期,可以继续使用本地缓存;否则缓存失效,服务器返回最新的01.png
文件资源。
注:协商缓存有效时,服务端返回的响应状态码是
304
2. Last-Modified
的不足点
- 如果对文件进行编辑,但是内容没有发生任何变化,文件最新修改时间也会更新,从而导致协商缓存有效性的判断不准确,会重新请求新的资源。
- 校验的时间戳精确到秒,因此,如果文件修改速度非常快,在
1s
内完成修改,协商缓存的有效性判断也会不准确,无法请求到最新的资源。
ETag/If-None-Match
基于实体标签(
Entity Tag
)的协商缓存,可以弥补时间戳(Last-Modified
)判断的不
1. 实现原理
当客户端请求一个静态资源02.png
,ETag
会为当前资源进行哈希运算生成一个字符串,并通过响应头的etag
字段返回。
当我们刷新网页再次请求该资源时,由于使用的是协商缓存,客户端无法直接判断本地缓存是否有效,所以会向服务器发送一次GET
请求,进行缓存有效性的协商,此次请求的请求头中会包含一个If-None-Match
字段,该字段的属性值为上次响应头中etag
的字段值。
同样的当服务器收到该请求后,会将二者进行对比,若二者相同则说明缓存未过期,可以继续使用本地缓存;否则缓存失效,服务器返回最新的02.png
文件资源
2. ETag的不足点
ETag
并不能完美的替代last-modified
,它也存在一些不足点:
-
服务器生成文件资源的
ETag
需要额外的计算开销,若资源存在尺寸大、数据多、修改频繁等问题,则会影响服务器性能。 -
ETag
字段值的生成分为强验证和弱验证- 强验证根据资源内容,深入到每个字节,进行生成,能够保证每个字节都相同,但是非常消耗计算量;
- 弱验证根据资源的部分属性值来生成,生成速度快,但无法确保每个字节都相同,准确率不高。
ETag
在服务器集群场景下,准确率不高。因为ETag
默认格式是inode-size-timestamp
,因此即使对象大小、权限、时间戳和路径都相同,inode
和ETag
也会不同。如果群集中有10
台服务器,则ETag
匹配的准确率仅为10%
。
代码演示
- 同样,我们先写一个
app.js
,用来实现服务端代码,实现过程与上面的强制缓存类似,唯处理响应结果时有不同,所以直接放上全量代码了。
const http = require('http')
const fs = require('fs')
const url = require('url')
const etag = require('etag')
http.createServer((req,res)=>{
const { pathname } = url.parse(req.url)
if(pathname === '/'){
const data = fs.readFileSync('./协商缓存.html')
res.end(data)
}else if(pathname === '/img/01.png'){
// 使用last-modified字段控制协商缓存
// 获取文件的最新修改时间
const { mtime } = fs.statSync('./img/01.png')
// 获取请求头中的if-modified-since字段的值
const ifModifiedSince = req.headers['if-modified-since']
// 对比两个时间是否相同
if(ifModifiedSince === mtime.toUTCString()){
// 时间相同,缓存生效,返回响应码304
res.statusCode = 304
res.end()
return
}
// 事件不同,缓存失效,重新返回全新资源
const data = fs.readFileSync('./img/01.png')
// 在请求头中添加last-modified
res.setHeader('last-modified',mtime.toUTCString())
// 在讲强制缓存时说过,使用协商缓存需要将Cache-Control设置为no-cache
res.setHeader('Cache-Control','no-cache')
res.end(data)
} else if(pathname === '/img/02.png'){
// 使用etag字段控制协商缓存
const data = fs.readFileSync('./img/02.png')
// 对文件进行哈希计算获取计算后的值
const etagContent = etag(data)
// 获取请求头中的if-none-match的值
const ifNoneMatch = req.headers['if-none-match']
// 对比两个字符串是否一致
if(ifNoneMatch === etagContent){
// 一致,缓存生效,返回响应码304
res.statusCode = 304
res.end()
return
}
// 缓存失效
// 在请求头中放入当前最新的哈希计算的字符串
res.setHeader('etag',etagContent)
// 同样设置Cache-Control为no-cache
res.setHeader('Cache-Control','no-cache')
res.end(data)
} else {
res.statusCode = 404
res.end()
}
}).listen(4000,()=>{
console.log("Server running... http://localhost:4000");
})
- 新建一个
协商缓存.html
文件,与强制缓存.html
类似。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>协商缓存</title>
</head>
<body>
<h1>协商缓存</h1>
<img src="./img/01.png" alt="01.png" width="200px">
<img src="./img/02.png" alt="02.png" width="200px">
</body>
</html>
- 然后启动服务
modemon ./app2.js
或node ./app2.js
- 打开浏览器
localhost:4000
,调试时多关注一下第一次请求和后面每次请求的不同,特别是请求头和响应头里的相关字段。