引言
大家是否有在面试的过程中,被问到为何第二次打开百度的官网时,比第一次打开时的速度会快,这其实就是http缓存的问题,本期就带大家了解强缓存和协商缓存,并且他们是如何实现的。
首先,先给大家介绍一下请求头和响应头,这能帮大家更好地去理解本文。
js
const http = require('http')
const url = require('url') // url模块 做url路径的解析
const server = http.createServer((req, res) => {
const { pathname } = url.parse(`http://${req.headers.host}${req.url}`)
if (pathname === '/') {
res.end('<h1>Hello world</h1>')
}
})
server.listen(3000, () => {
console.log('listening on port 3000');
})
这里我们使用node
中自带的两个模块http
和url
,然后我们通过pathname
去获得前端请求数据的url
,然后判断,如果我们后端访问的是根路径/
,就返回<h1>hellow world</h1>
给前端。
我们访问http://localhost:3000/
:
如图,我们拿到了数据Hello World
,并且以html的形式出现在浏览器上。
为什么我们后端返回的数据为<h1>Hello world</h1>
,怎么被浏览器解析出来了呢?因为我们在后端并没有设置响应头的格式,而浏览器会默认将它以html
的格式解析出来。
如果我们想要将后端返回的数据以json
形式去加载的话,我们可以在响应头上设置一个字段:'Content-Type': 'application/json'
,那么浏览器就会以这个格式进行加载:
上述,这样我们就实现了一个简单的http服务。添加响应头和请求头的特性是在HTTP/1.0
版本被发展出来的。 因为Http/1.0
支持多种类型文件的传输,那么就需要告诉浏览器需要以哪种方式去加载这些文件,通过引入请求头和响应头来让客户端和服务端更加深入的交流,key-value形式。
关于http
的发展史,大家可以看看我写的文章:说一说http的发展史
内容协商
内容协商是什么呢?很简单,就是前后端沟通好以何种方式去解析数据。
前端是可以告诉后端它所想要拿到的数据的格式的,通过在请求头中设置accept
来期望后端返回的是一个什么样的数据类型,如果不写就是默认的,我们可以在请求头中看到:
而在后端中我们也可以拿到前端期望的格式:
js
const accept = req.headers.accept
console.log(accept)
因此,在后端获取到这个accept
时,可以知道前端想要拿到哪些格式,如果前端想要json
格式的数据,那就返还json
格式过去。
js
const { log } = require('console');
const http = require('http');
const url = require('url');
const responseData = {
ID: 'zhangsan',
Name: '张三',
RegisterDate: '2024年3月28日'
}
function toHTML(data) {
return `
<ul>
<li><span>账号:</span> <span>${data.ID}</span></li>
<li><span>昵称:</span> <span>${data.Name}</span></li>
<li><span>注册时间:</span> <span>${data.RegisterDate}</span></li>
</ul>
`
}
const server = http.createServer((req, res) => {
const { pathname } = url.parse(`http://${req.headers.host}${req.url}`)
console.log(pathname);
if (pathname === '/') {
const accept = req.headers.accept
console.log(accept);
if (accept.indexOf('application/json') !== -1) {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(responseData))
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(toHTML(responseData))
}
} else {
res.writeHead(404, { 'Content-Type': 'text/html' })
res.end('<h1>Not Found</h1>')
}
});
server.listen(3000, () => {
console.log('Server is running on port 3000');
});
如上述代码,后端通过从获得的accept
中去查找前端是否期望传json
的格式过去,如果有,就传json
过去,如果没有,我们就传html
格式过去。
所以页面最终输出:
后端返回静态资源给前端
现在我们所讲跟强缓存和协商缓存并没有什么必然联系,但是写的详细一些希望能帮大家更好地去理解
node
及两个缓存
首先,我们写一份html
文件,文件里面有一个文本和一张图片,我们现在的目的是当前端访问http://localhost:3000/index.html
时,可以看到这个页面。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>来颗奇趣蛋</h1>
<img src="assets/logo.jpg" alt="">
</body>
</html>
这是我们的目录结构:图片存在assets
当中。
js
const http = require('http');
const url = require('url');
// 绝对路径相对路径
const path = require('path');
// node 自带的文件系统
const fs = require('fs');
// 前后端不分离,把一个静态资源返回给前端
const server = http.createServer((req, res) => {
// __dirname当前js文件的绝对路径
// 将前端请求的地址转换成url格式,再拼接www这个路径,最后读取整个文件的绝对路径
let filePath = path.resolve(__dirname, path.join('www', url.fileURLToPath(`file:/${req.url}`)))
// console.log(filePath);
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath)//读取文件的详细参数
const isDir = stats.isDirectory()// 用来判断读到的是文件还是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
if (!isDir || fs.existsSync(filePath)) {
// 读取资源文件向前端返回
const content = fs.readFileSync(filePath) // 读取文件
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
return res.end(content)
}
}
res.writeHead(404, { 'Content-Type': 'text/html' })
return res.end('<h1>Not Found</h1>')
})
server.listen(3000, () => {
console.log('server is running at port 3000')
});
这里我们先需要拿到前端的url
,再通过path
模块去拿到文件的一个绝对路径,我们这里的filePath
就会打印:D:\codespace\面试题\http缓存-2\www\index.html
。然后我们通过引入node
自带的文件模块fs
,去判断filePath
这个文件资源是否存在,如果存在,会去读取文件的详细参数,而这个详细参数就比如文件的大小,创造时间等。然后再去读取文件,在node
环境中,一个文件是会被默认读取 buffer
16进制数据流的,然后将这个数据流向前端输出。并且我们要在响应头告诉前端如何解析这个buffer
数据流,要不然前端不会知道如何去解析这个16进制数据流。
所以,当我们访问http://localhost:3000/index.html
,就可以看到页面:
但是我们又发现一个问题:
我们来看图片的响应:
我们发现乱码了,这是因为图片是在html文件里面再次请求的,我们并没有给它设置读取的格式,所以会乱码。而图片能加载出来是因为谷歌浏览器太强大了。
解决图片乱码
在上面我们看到,图片乱码了,这是因为我们并没有给浏览器设置读取的格式,我们来解决一下:
js
const http = require('http');
const url = require('url');
// 绝对路径相对路径
const path = require('path');
// node 自带的文件系统
const fs = require('fs');
const mime = require('mime-types');
// 前后端不分离,把一个静态资源返回给前端
const server = http.createServer((req, res) => {
// __dirname当前js文件的绝对路径
// 将前端请求的地址转换成url格式,再拼接www这个路径,最后读取整个文件的绝对路径
let filePath = path.resolve(__dirname, path.join('www', url.fileURLToPath(`file:/${req.url}`)))
// console.log(filePath);
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath)//读取文件的详细参数
const isDir = stats.isDirectory()// 用来判断读到的是文件还是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
if (!isDir || fs.existsSync(filePath)) {
// 读取资源文件向前端返回
const content = fs.readFileSync(filePath) // 读取文件
const { ext } = path.parse(filePath)
console.log(ext);
if (ext === '.jpg') {
res.writeHead(200, { 'Content-Type': 'image/jpg' })
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
}
return res.end(content)
}
}
res.writeHead(404, { 'Content-Type': 'text/html' })
return res.end('<h1>Not Found</h1>')
})
server.listen(3000, () => {
console.log('server is running at port 3000')
});
我们的图片为jpg
格式,const { ext } = path.parse(filePath)
,我们通过解构拿到ext
,我们打印一下ext
来看一下:
这样后端就拿到了请求了文件的格式,然后我们通过if
语句去判断,告诉前端该以何种格式去加载。
这样图片就没出现乱码了。
但是这样还会出现一个问题,如果我的图片格式是png
,或者是jpeg
等等格式呢?那么难道我要写很多if
语句去进行判断吗?
mime-types
其实官方提供了一个工具给我们去匹配格式,我们需要去安装一个依赖: npm i mime-types
。后端就可以去返回对应的格式告诉浏览器。
js
const http = require('http');
const url = require('url');
// 绝对路径相对路径
const path = require('path');
// node 自带的文件系统
const fs = require('fs');
const mime = require('mime-types');
// 前后端不分离,把一个静态资源返回给前端
const server = http.createServer((req, res) => {
// __dirname当前js文件的绝对路径
// 将前端请求的地址转换成url格式,再拼接www这个路径,最后读取整个文件的绝对路径
let filePath = path.resolve(__dirname, path.join('www', url.fileURLToPath(`file:/${req.url}`)))
// console.log(filePath);
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath)//读取文件的详细参数
const isDir = stats.isDirectory()// 用来判断读到的是文件还是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
if (!isDir || fs.existsSync(filePath)) {
// 读取资源文件向前端返回
const content = fs.readFileSync(filePath) // 读取文件
const { ext } = path.parse(filePath)
console.log(ext);
res.writeHead(200, { 'Content-Type': mime.lookup(ext) })
return res.end(content)
}
}
res.writeHead(404, { 'Content-Type': 'text/html' })
return res.end('<h1>Not Found</h1>')
})
server.listen(3000, () => {
console.log('server is running at port 3000')
});
好的,现在我们就正式开始来讲解:
首先我们先来看看百度:
请求百度的图片是0ms,没有耗费任何时间,我们知道只要网络请求一定是需要花费时间的,那么就说明这些图片压根就没有发送请求,就没有向百度的服务器去要这些图片,那么这些图片就一定被缓存起来了,这就是http
缓存。
强缓存
我们看上图,发现响应头里有一个字段Cache-Control
,发现它的max-age=315360000
,单位为s
,这其实就是缓存的时间,我们换算一下,计算得出为10
年,也就是说,当我们第一次访问百度页面,在10年内拿图片就不用再去发接口请求了。这就是强缓存
。
实现强缓存
如何去实现强缓存是十分简单的,我们只需要在后端的响应头当中添加一个字段'Cache-control': 'max-age=86400'
,缓存为一天时间:
js
const http = require('http');
const url = require('url');
const path = require('path');
const fs = require('fs');
const mime = require('mime-types');
// 前后端不分离,把一个静态资源返回给前端
const server = http.createServer((req, res) => {
let filePath = path.resolve(__dirname, path.join('www', url.fileURLToPath(`file:/${req.url}`)))
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath)//读取文件的详细参数
const isDir = stats.isDirectory()// 用来判断读到的是文件还是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
// --------------------------------------------------
if (!isDir || fs.existsSync(filePath)) {
// 读取资源文件向前端返回
const content = fs.readFileSync(filePath) // 读取文件
const { ext } = path.parse(filePath)
console.log(ext);
res.writeHead(200, {
'Content-Type': mime.lookup(ext),
'Cache-control': 'max-age=86400' // 一天
})
return res.end(content)
}
}
res.writeHead(404, { 'Content-Type': 'text/html' })
return res.end('<h1>Not Found</h1>')
})
server.listen(3000, () => {
console.log('server is running at port 3000')
});
那当我们第二次访问时,请求也就变成0ms
啦:
强缓存的缺点
强缓存在某些情况下是十分好用的,比如我们刚刚展示的那张百度的logo
,百度的图片一般是不会进行更换的,只会在某些节假日的时候才会更换,而它们也是通过特殊手段进行更换。如果我们将某种静态资源进行强缓存了,那么当我们改变了该静态资源,页面是不会进行更新的。
比如,我们此时强缓存了一张图片,那么我们换一张图片进行展示,命名与之前一致。小伙伴们可以试一下,刷新浏览器,图片是不会进行变更的。这是因为旧图片已经被缓存到本地了,如果后端的资源发生变更,强缓存并不会知道,所以不会进行刷新。
但是我们可以进行强制刷新: shift + F5
还有一个重点,在我们强缓存图片的同时,大家有没有发现我们只缓存住了图片资源,并没有缓存住上方的index.html
:
我可以明确的告诉大家,这并不是文件资源格式的问题,我们在后端设置了响应头,我们来查看一下index.html
:
我们发现,在响应头中仍然是有max-age=86400
的字段的,说明我们后端写的响应头是生效的,那么我们再来看看请求头:
在请求头中发现了max-age=0
,这是因为浏览器默认给请求头设置了一个这个字段,它让请求头中的max-age
无法生效。
为什么浏览器会给请求头默认设置max-age=0
呢?
这是因为通过浏览器Url地址栏发送的get请求,无法被强缓存
所以浏览器会给请求头默认加上一个max-age=0
。
首先大家要明白,在浏览器中url地址栏输入一段url发送请求,那么这个请求的类型只能是
get
请求。那什么是从url地址栏发送请求呢?如下图
所以,我们拿到的这份html
文件是不能进行强缓存的。
在请求图片时,是浏览器获取到了
html
代码,加载页面时,碰到了需要进行请求的资源,比如我们这里的图片,所以此时图片是可以进行强缓存的
总结强缓存
-
设置响应头: 'Cache-Control': 'max-age=xxxxxx'
-
若不改变资源文件命名,在变更同样格式的资源时,浏览器不能去加载新的资源,会从本地缓存中读取旧文件。
-
通过浏览器Url地址栏发送的get请求,无法被强缓存
协商缓存
协商缓存出现的意义就是它可以去缓存通过浏览器Url地址栏发送的get请求
,也就是说可以缓存我们这里的index.html
文件。
并且协商缓存可以解决: 一旦后端资源变更了,前端能立马拿到更新的资源。
实现协商缓存
很简单,我们只需要在响应头中再添加一个字段:'Last-Modified'
,那么它的值是什么呢?
它的值应该是一个时间戳
,它记录着资源文件上一次被修改的时间,那么我们如何获得这个文件呢?
大家是否记得,我们可以通过const stats = fs.statSync(filePath)
去读取文件的详细参数,我给大家打印一下这个stats
来看:
这里的mtimeMs
就记录着上一次文件修改的时间。
所以,我们响应头需要这么写:
js
res.writeHead(status, {
'Content-Type': mime.lookup(ext),
'Cache-Control': 'max-age=86400', // 一天
'Last-Modified': stats.mtimeMs // 时间戳 资源修改的时间
})
当我们加上字段后,去浏览器,点击刷新:
响应头当中就多了一个Last-Modified
的字段,它的值为文件资源最后一次修改的时间。我们再来看看请求头:
发现请求头当中出现了一个if-Modified-Since
字段,而它的值为响应头的的返回值,也就是资源文件上一次修改的时间。
服务器首先返回一个时间戳给前端,它记录着资源文件的修改时间。然后浏览器自动将这份时间保存在请求头当中。
那么,写在了请求头当中的字段,它会自动的去传给后端。
我们现在来对html文件进行一些修改:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>来颗奇趣蛋</h1>
<h2>奇趣蛋</h2>
<img src="assets/logo.jpg" alt="">
</body>
</html>
因为我的后端资源文件被改变了,那么操作系统帮我们记录的文件上一次修改的时间也会变更:
我们可以发现,这个字段的值改变了,因为我们修改了文件,那么此时,响应头中的'Last-Modified'
和请求头中的if-Modified-Since
就不一样了。
然后我们就可以通过比对响应头和请求头的时间戳,去判断后端的资源文件有没有改变:
js
const http = require('http')
const url = require('url')// url模块 做字符串url路径的解析
const path = require('path')// path 解析路径 解析绝对相对
const fs = require('fs') // 文件模块
const mime = require('mime-types')
const server = http.createServer((req, res) => {
let filePath = path.resolve(__dirname, path.join('www', url.fileURLToPath(`file:/${req.url}`)))
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath)
console.log(stats);
const isDir = stats.isDirectory()
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
if (!isDir || fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath)
const { ext } = path.parse(filePath)
const timeStamp = req.headers['if-modified-since']
let status = 200
if (timeStamp && Number(timeStamp) === stats.mtimeMs) { // 该资源没有被修改
status = 304 // 资源未修改
}
res.writeHead(status, {
'Content-Type': mime.lookup(ext),
'Cache-Control': 'max-age=86400', // 一天
'Last-Modified': stats.mtimeMs // 时间戳 资源修改的时间
})
return res.end(content)
}
}
res.writeHead(404, { 'Content-Type': 'text/html' })
res.end('<h1>Not Found</h1>')
})
server.listen(3000, () => {
console.log('listening on port 3000');
})
如果timeStamp && Number(timeStamp) === stats.mtimeMs
,也就是后端文件没有被修改,那么我们就返回304
状态码,304
状态码的意思就是后端资源未修改,那么直接从缓存中去读取这个html
文件。
如果判断为不相等,那么则返回最新的资源给前端。
首先我们来看看原本html
文件的大小:
可以看到原本的文件大小为536B
。
我们再次刷新浏览器,因为后端文件没有被修改,判断时间戳相等,返回状态304
,所以直接从缓存中读取:
这样文件的大小只有203B
了,我们就实现了一个协商缓存!
为什么这个
index.html
文件不能跟图片一样是0B
,这是因为html
这个类型是不可能为0的
协商缓存的缺点:
-
但是协商缓存仍然无法解决我们在强缓存所说的那个问题,如果后端资源的内容变了但是文件的命名没有去改变,那么浏览器还是不能去更新。
-
如上述换了一张图片但是命名不变
-
如何解决?
-
我们需要通过在文件名后面加一个
hash
值,只要文件的内容被更改了,那么hash
值一定会更改,相应的文件名也会改变,这样浏览器就会重新加载。 -
例如:
-
-
- 如果我们去给
html
文件添加一段代码,但是后面我们觉得这段代码没什么用,又把它删了,那么时间戳
依然会改变,因为我们修改了文件,因此前端还是会重新发送请求。
问题的本质在于我们是通过判断文件修改的时间去决定是否进行缓存,但是只要我们进行编辑了就会刷新时间,尽管内容不变。
这里我们就需要依赖另一个字段了: etag
。
etag
etag
就是一个文件签名,它是通过文件的内容来生成的,也就是说如果文件的内容不变,那么etag
也是不变的。
而etag
跟Last-Modified
差不多,浏览器也会在请求头中自动生成一个字段:if-none-match
,且值为etag
的值。
js
const checksum = require('checksum')
checksum.file(filePath, (err, sum) => {
const resStream = fs.createReadStream(filePath)
sum = `"${sum}"`
if (req.headers['if-none-match'] === sum) {
res.writeHead(status, {
'Content-Type': mime.lookup(ext),
'Cache-Control': 'max-age=86400',
'etag': sum // 签名(文件资源)也可以做协商缓存
})
} else {
res.writeHead(200, {
'Content-Type': mime.lookup(ext),
'etag': sum
})
return resStream.pipe(res)
}
})
这里我们需要安装一个依赖checksum
, etag的值也就是文件签名,它可以完整的去代表这个文件,它是由文件的内容去生成的。
这个etag
我们就不细讲了,感兴趣的小伙伴可以自行查阅一下。
总结:
-
设置响应头: 'Last-Modified': stats.mtimeMs' // 时间戳
浏览器就会自动在请求头中携带if-modified-since,且值为响应头的返回值,当后端判断前端携带的时间戳和后端文件的修改 时间一致时,返回304状态码,让浏览器从缓存中读取数据
回顾
强缓存和协商缓存在某一些方面是强大的,而他们在许多大公司的官网也是经常被用到的。因为过多的网络请求是十分耗费时间和性能的。许多的官网上放着很多的图片,就例如百度,那他们绝对不会允许当用户访问的时候需要发送如此多的网络请求,所以使用了缓存机制。