HR:请你聊一聊浏览器缓存

2,493 阅读9分钟

当你无视风险访问某网站时,大多数情况,你会感觉第二次及后续访问这个网站的速度通常会比第一次访问更快,这就是因为有浏览器缓存的存在,优化了浏览器的性能。

引言

  1. 后端返回给前端的内容是需要在响应头设置 编码类型的(这样浏览器就知道以什么方式加载)
res.writeHead(200, {'content-type': 'text/html; charset=utf-8'})

2. HTTP 分为 请求头和请求体 两部分,主要提供一种内容协商机制。

  1. 静态资源的传输:将网站的非动态生成的内容(如HTML文件、CSS样式表、JavaScript脚本、图片、视频等)从服务器传输到客户端浏览器的过程。

例如文件夹www中写一个文件index.html(如下),然后再向前端返回这个html文件。

// www/index.html
<body>
  <h1>hello world</h1>
  <img src="assets/img.jpg" alt="">
</body>

将这个文件返回前端:先获取文件路径,然后流式返回文件,再输出到前端,具体如下

// index.js
const http = require('http')
const path = require('path')
const fs = require('fs')
const mime = require('mime')  // npm i mine@3 安装

const server = http.createServer((req, res) => {
  let filePath = path.resolve(__dirname, path.join('www', req.url))  // 将文件路径拼接

  if (fs.existsSync(filePath)) {      // 判断文件是否存在
    const stat = fs.statSync(filePath)  // 获取文件信息
    const isDir = stat.isDirectory()   // 判断是否为文件夹

    if (isDir) {
      filePath = path.join(filePath, 'index.html')  // 如果是文件夹,默认返回index.html
    }

    if (!isDir || fs.existsSync(filePath)) {    // 向前端返回文件
      const { ext } = path.parse(filePath)   // 获取文件后缀
      res.writeHead(200, { 'Content-Type': mime.getType(ext) }) 
      
      // 边加载边输出给前端
      const readStream = fs.createReadStream(filePath)  // 创建可读流
      readStream.pipes(res)   // 将可读流的数据,通过管道输出到前端
    }
  }

})

server.listen(3000)

mime.getType(ext) 作用是根据文件后缀生成相应的编码类型返回给前端

image.png

HTTP 缓存

当浏览器接收到该html文件时就会加载该html文件,加载html文件过程中又会去找图片资源,即会再发一次http请求。第一次打开发送http请求加载图片,但是如果该图片长时间不更新的话,有没有一种方法能让用户下次访问时不用发送http请求直接获取呢?就可以用到浏览器缓存了。

HTTP 缓存:将页面上长时间不更新的资源缓存到浏览器上,下次访问页面时该部分资源直接从缓存中获取,从而减少了网络请求,提高页面的加载速度。

实现缓存

强缓存

在响应头中设置 Cache-Control 字段,该字段的值为 max-age=xxx,表示缓存的有效期,单位为秒。当第一次请求过后,资源会被浏览器缓存,再次请求该资源时就不会朝后端发请求,直接去缓存中获取,直到缓存的有效期过完。

res.writeHead(200, {
        'Content-Type': mime.getType(ext),
        'cache-control': 'max-age=86400', // 强缓存一天
      })

第一次请求: image.png

后面再请求: image.png

但是可以发现html文件没被缓存,这是因为通过浏览器 url 地址栏请求的资源,请求头中就会自动携带 Cache-Control:max-age=0 字段,也就意味着这种资源无法被强缓存。

image.png

不是网页无法被缓存,而是资源被请求的方式导致这种资源无法被缓存,比如通过iframe标签加载的网页可以被缓存。被缓存的资源在 cache Storage 中,本质上还是在硬盘上。强制刷新浏览器时(shift+刷新),会清空浏览器的 cache Storage。

协商缓存

为了解决强缓存对浏览器地址栏访问的资源无效的问题(会被缓存,但是不会被强缓存,地址栏再次资源请求时还是会朝后端发请求),浏览器提供了协商缓存的机制。也就是后端在响应头中返回一个标识符,而前端发送请求时将该标识符放到请求头中,后端解析出该标识符是否跟上次相等然后判断是否返回资源。

1. last-modified + if-modified-since

在资源响应头中设置一个'last-modified': stat.mtimeMs字段,stat.mtimeMs值为文件最后一次修改的时间,浏览器再次请求该资源时请求头中会自带'if-modified-since':xxx字段(http协议自动完成的工作),然后后端判断是否等于上次传过去的值。

image.png

浏览器在第一次请求资源时,响应头中携带 last-modified 字段,值为该资源的最后修改时间,当浏览器接收到响应头时,会在该资源再次被请求时,在请求头中自动携带 if-modified-since 字段,值为 last-modified 的值,后端检验请求头中的 if-modified-since 的值和 last-modified 是否一致,如果一致,就返回 304 状态码,浏览器就会从缓存中获取该资源,如果不一致,就返回 200 状态码,浏览器就会重新请求该资源。

代码实现如下:

const { ext } = path.parse(filePath)
const timeStamp = req.headers['if-modified-since']

let status = 200
if (timeStamp && Number(timeStamp) === stat.mtimeMs) {
  status = 304        // 文件修改时间没变
}

res.writeHead(status, {
  'Content-Type': mime.getType(ext),
  'cache-control': 'max-age=86400', // 强缓存一天
  'last-modified': stat.mtimeMs   // 最后修改的时间
})

if (status === 200) {
  const stream = fs.createReadStream(filePath)  // 创建可读流
  stream.pipe(res)  // 将可读流的数据,通过管道,输出到前端
} else {
  return res.end()    // 文件最后一次修改时间没变,返回空
}

结果如下;加载通过url地址栏请求index.html时就无需将文件再传给前端,响应体里面返回空,浏览器接收到一个304状态码时就会去缓存中获取该资源。

image.png

image.png

当时当文件内容没被修改时,比如加了一个标签再删除这个标签保存,文件内容没有变更,但是最后一次修改的时间更新了,从而导致该资源重新被请求。于是可以取文件内容将它生成一个标识来判断是否命中缓存。

2. etag + if-none-match

原理同上一个。资源响应头携带etag字段值为通过md5算法将文件内容生成的值作为标识符,也叫文件指纹,然后浏览器再次请求时携带if-none-match字段值为响应体中的标识符。

可以使用checksum来生成文件内容的标识符npm i checksum安装。

const checksum = require('checksum')   // 计算文件的MD5值

const { ext } = path.parse(filePath)
const ifNoneMatch = req.headers['if-none-match']

let status = 200
if (ifNoneMatch === sum) {   // 如果 ETag 头部和 sum 相同,返回 304
  status = 304
}
checksum.file(filePath, (err, sum) => {   // 识别文件资源,资源变更sum变更
  // console.log(sum);  // 6adb05c9d92089507689a7c2beecfc2d814b67eb

  sum = `"${sum}"`
  res.writeHead(200, {
    'Content-Type': mime.getType(ext),
    'cache-control': 'max-age=86400', // 强缓存一天
    'etag': sum   // eTag 头部,如果资源没有变,返回304
  })

  if (status === 200) {
    const reaStream = fs.createReadStream(filePath)
    reaStream.pipe(res)  // 向前端返回文件
  } else {
    res.end()
  }
})

但是这种方法因为要将整个文件内容读取到且加密处理,很耗费性能,所以适用于文件不是特别大,且文件是特别容易修改完返回原样的。

更新资源时,也就是将图片换成其它图片但是不改变文件名,前端请求时还是加载html,并加载该图片,但是由于文件名不变,之前请求的图片及文件名已经被浏览器缓存,所以命中缓存,就不会再重新请求该资源,加载的还是之前的那张图片。因为缓存的有效期还没到,除非(shift+刷新)强制刷新清空缓存。

解决方法:一般会在文件名后面加上文件指纹(用内容生成 hash 值),这样文件指纹(etag)就会改变,从而保证文件资源的更新。

总结

  1. HTTP 缓存:将页面上长时间不更新的资源缓存到浏览器上,下次访问页面时该部分资源直接从缓存中获取,从而减少了网络请求,提高页面的加载速度
  2. 强缓存:在响应头中设置 Cache-Control 字段,该字段的值为 max-age=xxx,表示缓存的有效期,单位为秒。
  3. 协商缓存:为了解决强缓存对浏览器地址栏访问的资源无效。实现方式:last-modified + if-modified-sinceetag + if-none-match
  4. 只用强缓存可以把除url地址栏访问的资源缓存起来,但是资源更新了就无法第一时间让前端获取到,所以还需要协商缓存;只有协商缓存理论上可以,但是会导致万年不变的图片每次请求时来回传标识符。
  5. 只要命中了强缓存,就不会走协商缓存,只有强缓存到期,才会走协商缓存
  6. 为了保证文件资源更新前端能够及时获取到,一般会在文件名后面加上文件指纹(用内容生成 hash 值),这样文件指纹(etag)就会改变,从而保证文件资源的更新。