浏览器原理探秘 — HTTP缓存篇

使用缓存技术对已获取的资源进行重用,是一种提升网站性能与用户体验的有效策略。

一、缓存的原理

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

image.png

缓存的技术种类

缓存的技术种类有很多,例如:

  • 代理缓存
  • 浏览器缓存
  • 网关缓存
  • 负载均衡
  • 内容分发网络

它们大致可以分为两类:共享缓存 和 私有缓存两类

  • 共享缓存:指的是缓存内容可被多个用户使用,如公司内部架设的web代理
  • 私有缓存:私有缓存指的是只能单独被用户使用的缓存,如浏览器缓存。

我们主要讨论和前端密切相关的浏览器HTTP缓存机制,它可以分为强制缓存和协商缓存。二者最大的区别在于是否向服务器发出请求。强缓存命中的话不会发请求到服务器,协商缓存一定会发请求到服务器,通过资源请求头字段验证资源是否命中协商缓存,如果协商缓存命中,服务器会将请求返回,但不会返回资源,此时code304

二、强缓存

对于强制缓存而言,如果浏览器判断所请求的目标资源有效命中,则可直接从强制缓存中返回请求响应,无需与服务器进行任何通信。

控制强缓存的字段常用的有:ExpiresCache-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-cacheno-store

no-cacheno-store是两个互斥的属性值,不能同时设置。

  • no-cache:设置no-cache并未像字面上的意思不使用缓存,其表示为强制进行协商缓存,即对于每次发起的请求都不会再去判断强制缓存是否过期,而是直接与服务器协商来验证缓存的有效性,若缓存未过期,则会使用本地缓存。
  • no-store:设置no-store则表示禁止使用任何缓存策略,客户端的每次请求都需要服务器端给予全新的响应。

4. privatepublic

privatepublic也是cache-control的一组互斥属性值,它们用以明确响应资源是否可被代理服务器进行缓存。

  • cache-control设置了public属性值,则表示响应资源既可以被浏览器缓存,又可以被代理服务缓存。
  • cache-control设置了private属性值,则限制了响应资源只能被浏览器缓存,若未显示指定则默认值为private

5. max-ages-maxage

在一般项目的使用场景中多为max-age,但对于大型架构的项目通常会涉及使用各种代理服务器的情况,此时便需要考虑缓存在代理服务器上的有效性问题,便会用到s-maxage

  • max-age 它表示服务器端告知客户端浏览器响应资源的过期时长。
  • s-maxage 它表示缓存在代理服务器中的过期时长,且仅当设置了public属性值时才有效。

Pragma

Pragma 是一个在 HTTP/1.0 中规定的通用首部,这个首部的效果依赖于不同的实现,所以在“请求 – 响应”链中可能会有不同的效果。它用来向后兼容只支持 HTTP/1.0 协议的缓存服务器,因为那时候 HTTP/1.1 协议中的 Cache-Control 还没有出来。

代码演示

  1. 新建一个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");
    })
    
  2. 在终端中启动服务 node ./app.js (建议使用nodemon启动服务,每次修改代码可以自动重启)

  3. 打开浏览器 输入 localhost:3000,如下所示说明我们的服务已经启动成功

    image.png

  4. 接下来我们写一个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>
    
  5. 修改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");
        })
    
  6. 重启服务,打开浏览器,可以正常请求到我们的html文件和图片

    image.png

  7. 默认情况下,是没有任何缓存的,在network里我们可以多次刷新查看请求

    image.png

  8. 给这两张图以不同的方式加上强制缓存

    • 第①张图使用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)
        })
    
  9. 分享每次请求缓存情况

    • 第一次打开浏览器页面2023-07-20 20:09:10 ,都没有命中缓存,这是第一次请求,所以不会有缓存可以用

      image.png

    • 第二次刷新页面 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:1020:09:15 过去了5s ,小于设置的10s有效期,因此缓存也有效。

      image.png

    • 第三次刷新页面 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,缓存失效

      image.png

    • 第四次刷新页面时间 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,因此缓存有效。

三、协商缓存

协商缓存在使用本地缓存之前,需要向服务器端发起一次GET请求,与之协商当前浏览器保存的本地缓存是否已经过期。

协商缓存常用的控制字段:Last-Modified/If-Modified-SinceETag/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.pngETag会为当前资源进行哈希运算生成一个字符串,并通过响应头的etag字段返回。

image.png
当我们刷新网页再次请求该资源时,由于使用的是协商缓存,客户端无法直接判断本地缓存是否有效,所以会向服务器发送一次GET请求,进行缓存有效性的协商,此次请求的请求头中会包含一个If-None-Match字段,该字段的属性值为上次响应头中etag的字段值。

image.png

同样的当服务器收到该请求后,会将二者进行对比,若二者相同则说明缓存未过期,可以继续使用本地缓存;否则缓存失效,服务器返回最新的02.png文件资源

2. ETag的不足点

ETag并不能完美的替代last-modified,它也存在一些不足点:

  • 服务器生成文件资源的ETag需要额外的计算开销,若资源存在尺寸大、数据多、修改频繁等问题,则会影响服务器性能。

  • ETag字段值的生成分为强验证和弱验证

    • 强验证根据资源内容,深入到每个字节,进行生成,能够保证每个字节都相同,但是非常消耗计算量;
    • 弱验证根据资源的部分属性值来生成,生成速度快,但无法确保每个字节都相同,准确率不高。
  • ETag在服务器集群场景下,准确率不高。因为ETag默认格式是inode-size-timestamp,因此即使对象大小、权限、时间戳和路径都相同,inodeETag也会不同。如果群集中有10台服务器,则ETag匹配的准确率仅为10%

代码演示

  1. 同样,我们先写一个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");
    })
  1. 新建一个协商缓存.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>
  1. 然后启动服务modemon ./app2.jsnode ./app2.js
  2. 打开浏览器 localhost:4000,调试时多关注一下第一次请求和后面每次请求的不同,特别是请求头和响应头里的相关字段。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYwY0jHp' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片