浏览器页面缓存 - Cache【性能篇】

缓存是我们日常开发中经常会接触到的一个重要概念,也是我们优化性能的一个利器。常用的场景比如在页面的某个不经常更新的图片,或者是页面用到的各种静态资源我们都可以将其进行缓存。这样,浏览器在进行资源请求的时候就不必在走网络请求,而是可以直接从本地的缓存中获取相应的资源,无疑要快上很多。今天就来介绍一下缓存背后的机制。

服务器缓存控制

在这里插入图片描述

我们可以通过上面的【服务器缓存控制】的时序图得知

  1. 浏览器在每次发起请求的时候都会在浏览器缓存中查找资源
  2. 服务器可以设置自己想要的缓存策略(通过设置HTTP响应头)来设置资源在浏览器中的缓存效果
  3. 如果命中缓存资源,则根据第二步缓存策略选择直接使用还是和服务器协商资源新鲜度
  4. 如果缓存中没有命中(比如上述的第一次HTTP请求,那本地缓存肯定不会命中,也有可能是缓存过期等原因),就会再发起HTTP请求服务器
  5. 后续请求反复 1-4步骤
    以上是服务器对缓存的整体控制过程。

客户端缓存控制

我们在上面的服务器缓存控制中知道,服务器可以通过对http响应头的一些关键字段来设置缓存的效果。
同样在客户端也可以通过对http请求头的设置来控制缓存策略。比如最常用的Cache-Control字段。如果客户端设置了Cache-Control: max-age=0的话,即便服务端设置了缓存,那浏览器也不会使用缓存资源,每次也会向服务器发出HTTP请求来获取需要的资源。
在这里插入图片描述

缓存类型

我们可以按照是否向服务器重新发起HTTP请求将缓存分为强缓存,和协商缓存。

特别注意

在下面Demo演示时,切记将浏览器中的 【网络 -> 停用缓存】选项不要勾选。否则浏览器不会从本地缓存取数据。
在这里插入图片描述
如果【停用缓存】被勾选了,浏览器发送的请求中就会自带 Cache-Control:no-cache和Pragma:no-cache两个请求头。会强制要求缓存把请求提交给原始服务器进行验证 (协商缓存验证)。
假如 Cache-Control 在请求头中不存在的话,Pragma:no-cache的行为与 Cache-Control: no-cache 一致。
在这里插入图片描述
在这里插入图片描述

强缓存

控制强缓存策略的HTTP报文字段为Cache-Control和Expires。其中Cache-Control的优先级要比Expires要高。

Cache-Control

常用值

Cache-Control 通用消息头字段,被用于在 http 请求和响应中,通过指定指令来实现缓存机制。
该字段常用值如下:

  1. public:表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存,即使是通常不可缓存的内容。(例如:1.该响应没有max-age指令或Expires消息头;2. 该响应对应的请求方法是 POST 。)通常我们在应用程序中将一些不会改变的文件,比如CSS、JS、图片等一些静态文件使用该策略进行缓存,比如设置将这些资源设置为:Cache-Control:public, max-age=31536000
  2. private:表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它)。私有缓存可以缓存响应内容,比如:对应用户的本地浏览器。
  3. max-age: 比如Cache-Control: max-age=5表示该资源缓存的有效时间为5s,但需要注意的是这里的max-age是“生存时间”(又被称之为“新鲜度”,类似TTL time to live),计算的时间起点是响应报文在服务端被创建的时刻(即响应头中的Date字段)而不是客户端接收到的时间,比如说此事的网络链路比较长而且网络状况不太好,数据在传输的过程中就耗费了3s钟的时间,最后到浏览器客户端的缓存有效期也就剩下2s了
  4. no-store:不允许缓存,比如秒杀,或者其他一些实时性要求很高的场景
  5. no-cache:表可以缓存,但在使用之前必须去服务器验证是否过期,是否有新的版本,即需要走协商缓存
  6. must-revalidate:如果缓存不过期就可以继续使用。一旦资源过期(比如已经超过max-age),在成功向原始服务器验证之前,缓存不能用该资源响应后续请求。
代码示例

使用koa来创建一个服务端,然后使用koa-static 中间件在服务器中创建一个静态资源目录的服务,里面包含前端index.html代码。

// router.ts
import Router from 'koa-router';
import CacheController from './controller/cache';
const router = new Router();
router.get('/has/cache', CacheController.hasCache);
export default router;
// controller
class CacheController {
  hasCache = async ctx => {
    const cacheControl = ctx.headers['cache-control'] || 'max-age=5';
    console.log(`服务端接受到请求: ${ctx.headers['cache-control']}`);
    ctx.set("Cache-Control", cacheControl);
    ctx.body = {
      txt: '被缓存的数据, 服务端生成时间戳为:',
      timestamps: +new Date()
    };
  };
}
export default new CacheController();
// 前端逻辑
<!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>
  <style>
    .title {
      font-size: 20px;
      width: 390px;
      text-align: center;
      margin-bottom: 20px;
    }
    .cache .txt,
    .no-cache .txt {
      display: inline-block;
      width: 269px;
    }
    .request-wrap .item {
      padding: 20px 0;
      border-bottom: 1px dashed #eee;
      height: 100px;
    }
    .item .timestamps {
      font-size: 26px;
      font-weight: bold;
    }
    .request-wrap .item div{
      margin: 8px auto;
    }
  </style>
</head>
<body>
  <div class="title">接口缓存DEMO</div>
  <div class="dec">页面加载时请求的数据返回:</div>
  <div class="cache item">
    <span class="txt"></span>
    <span class="timestamps"></span>
  </div>
  <div class="request-wrap">
    <div class="item item1">
      <div class="des">请求头Cache-Control字段设置为max-age:0</div>
      <button id="btn1">点我发送接口请求</button>
      <div>
        <span class="txt"></span>
        <span class="timestamps"></span>
      </div>
    </div>
    <div class="item item2">
      <div class="des">请求头Cache-Control字段设置为max-age:5</div>
      <button id="btn2">点我发送接口请求</button>
      <div>
        <span class="txt"></span>
        <span class="timestamps"></span>
      </div>
    </div>
    <div class="item item3">
      <div class="des">请求头Cache-Control字段设置为no-store</div>
      <button id="btn3">点我发送接口请求</button>
      <span class="txt"></span>
      <div>
        <span class="txt"></span>
        <span class="timestamps"></span>
      </div>
    </div>
    <div class="item item5">
      <div class="des">请求头Cache-Control字段设置为must-revalidate</div>
      <button id="btn5">点我发送接口请求</button>
      <div>
        <span class="txt"></span>
        <span class="timestamps"></span>
      </div>
    </div>
  </div>
</body>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.4/jquery.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.3.5/axios.js"></script>
<script type="module">
const getCacheConfig = (headers) => ({
  method: 'get',
  url: 'http://localhost:3009/has/cache',
  headers,
})

const sendCacheRequest = async (hasCacheConfig, ele) => {
  await axios(hasCacheConfig)
  .then(function (response) {
    $(`${ele} .txt`).text(response.data.txt);
    $(`${ele} .timestamps`).text(response.data.timestamps);
  })
  .catch(function (error) {
    console.log(error);
  });
}

sendCacheRequest(getCacheConfig(), '.cache');

$('#btn1').on('click', () => {
  const header = {
    'Cache-Control': 'max-age=0',
  }
  sendCacheRequest(getCacheConfig(header), '.item1');
})

$('#btn2').on('click', () => {
  const header = {
    'Cache-Control': 'max-age=5',
  }
  sendCacheRequest(getCacheConfig(header), '.item2');
})

$('#btn3').on('click', () => {
  const header = {
    'Cache-Control': 'no-store',
  }
  sendCacheRequest(getCacheConfig(header), '.item3');
})

$('#btn5').on('click', () => {
  const header = {
    'Cache-Control': 'max-age=3, must-revalidate',
  }
  sendCacheRequest(getCacheConfig(header), '.item5');
})

</script>
</html>

在示例代码中,前端页面在首次加载时会请求一次http://localhost:3009/has/cache接口获取数据,服务端会返回一个包含时间戳的字符串给前端展示。如果请求资源来自缓存的话,那么时间戳是保持不变,反之如果资源来自服务器,则对应的时间戳也就发生了变化。
对应效果如下:
请添加图片描述
可以看到:
Cache-Control值设置为max-age:0时,每次都会发出HTTP接口请求。
Cache-Control值设置为max-age:5时,缓存有效期内会走本地缓存,超过5s之后就会发出HTTP请求服务端的新资源。
Cache-Control值设置为no-store时,不会缓存,每次都会发出新的请求
Cache-Control值设置为max-age=3, must-revalidate时,资源在有效期内(3s)会取缓存,超过三秒就会发出HTTP请求,向服务端协商认证资源
整个流程图如下所示:
在这里插入图片描述

Expires

Expires是HTTP/1.0控制网页缓存的字段,其值为服务器返回该请求结果缓存的到期时间,即再次发起该请求时,如果客户端的时间小于Expires的值时,直接使用缓存结果。

现在如果还在使用HTTP 1.X协议的话,也是http1.1版本的协议了。Expire已经被Cache-Control替代,原因在于Expires控制缓存的原理是使用客户端的时间与服务端返回的时间做对比,那么如果客户端与服务端的时间因为某些原因例如时区不同、或者客户端和服务端计算时间的口径不一致发生误差,那么强缓存则会直接失效,这样的话强缓存就达不到预期的效果了。
如果在两个字段同时存在,Cache-Control的优先级是更高的

语法
Expires: <http-date>

需要注意的是设置的时间为格林时间。

代码示例
// router.ts
router.get('/has/expires/cache', CacheController.cacheWithExpires);
//controller
// 强缓存方式二:设置Expires
  cacheWithExpires = async ctx => {
    ctx.set("Expires", new Date(Date.now() + 3 * 1000).toUTCString()); // 过期时间设为当前时间 + 3s
    ctx.body = {
      txt: '被缓存的数据, 服务端生成时间戳为:',
      timestamps: +new Date()
    };
  };

前端页面点击按钮,会请求/has/expires/cache接口。该接口会在当前的格林时间之上设置有效的缓存时间为3s。那么在3s内浏览器再次请求时,会直接从缓存中获取数据,超过3s后才会发送请求到服务端获取数据
效果如下:
请添加图片描述

协商缓存

要实现既能最大化程度的使用本地缓存,但也因为缓存会失效。而强缓存的策略只能做到刷新数据,此时就需要一个机制来主动去服务端验证当前的缓存资源版本是否是最新的。这其实就是协商缓存

在文章最开始的时序图我们知道,协商缓存会在强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程,主要有以下两种情况,我们来看下背后的具体机制是如何的。
在这里插入图片描述
HTTP 协议定义了一系列“If”开头的“条件请求”字段,专门用来检查验证资源是否过期,验证的责任交给服务器,浏览器只需等待服务器判断的结果即可。
我们最常用的是“if-Modified-Since”和“If-None-Match”这两个。但需要第一次的响应报文预先提供“Last-modified”和“ETag”,然后第二次请求时就可以带上缓存里的原值,验证资源是否是最新的。即我们可以分为两组字段Etag / If-None-Match和Last-Modified / If-Modified-Since。
而其中Etag / If-None-Match的优先级比Last-Modified / If-Modified-Since高。
如果资源没有变,服务器就回应一个“304 Not Modified”,表示缓存依然有效,浏览器就可以更新一下有效期,然后放心大胆地使用缓存了。如果资源有更新,就会返回200的状态码给服务器。

Last-Modified / If-Modified-Since

二者的值都是 GMT 格式的时间字符串, Last-Modified 标记最后文件修改时间, 下一次请求时,请求头中会带上 If-Modified-Since 字段,其值就是 之前服务器返回给浏览器Last-Modified的值, 告诉服务器我本地缓存的文件最后修改的时间,在服务器上根据文件的最后修改时间判断资源是否有变化, 如果文件没有变更则返回 304 Not Modified ,请求不会返回资源内容,浏览器直接使用本地缓存。当服务器返回 304 Not Modified 的响应时,response header 中不会再添加的 Last-Modified 去试图更新本地缓存的 Last-Modified, 因为既然资源没有变化,那么 Last-Modified 也就不会改变;如果资源有变化,就正常返回返回资源内容,新的 Last-Modified 会在 response header 返回,并在下次请求之前更新本地缓存的 Last-Modified,下次请求时,If-Modified-Since会启用更新后的 Last-Modified。

Etag / If-None-Match

etag值是由服务器为每一个资源生成的唯一标识串,只要资源有变化就这个值就会改变。可以看出是资源的指纹,服务器根据文件本身算出一个哈希值并通过 ETag字段返回给浏览器,而服务器接收到 If-None-Match 字段以后,服务器通过比较两者是否一致来判定文件内容是否被改变。与 Last-Modified 不一样的是,当服务器返回 304 Not Modified 的响应时,由于在服务器上ETag 重新计算过,response header中还会把这个 ETag 返回,即使这个 ETag 跟之前的没有变化。
HTTP 中并没有指定如何生成 ETag,可以由开发者自行生成,哈希是比较理想的选择。注意一点的是,etag在相同文件进行比较才有意思,不同文件的tag可能相同也可能不一样,没有什么意义。
在node js中,如果使用koa的话,可以使用koa-etag这个npm包来生成etag。对应的代码如下:

// index.ts
import Koa from 'koa';
import path from 'path';
import koaStatic from "koa-static";
import { koaBody } from 'koa-body';
import parameter from 'koa-parameter';
import etag from 'koa-etag';
import globalExceptionHandler from './middleware/exceptions';
import router from './router';

console.log(`当前环境: ${process.env.NODE_ENV}`);
// koa应用
const app = new Koa();
// 注册中间件
// 全局捕获错误
app.use(globalExceptionHandler);
// 使用etag中间件 
app.use(etag()); 
// 静态文件服务 public 文件夹下文件可对外访问
app.use(koaStatic(path.join(__dirname, 'public')));
// 解析body 参数
app.use(koaBody());
// 验证入参
app.use(parameter(app));
// 注册路由
app.use(router.routes());
// 如果请求了不被允许的方法 比如说只实现了 get 这时候请求了post 就会告诉客户端一些信息
app.use(router.allowedMethods());
app.listen(3009, () => console.log('程序已经在3009端口启动'));

然后再public中新建一个index.js,里面随便输入一点内容。本地启动服务之后,我们通过http://localhost:3009/index.js 这个地址来访问创建的js

// public/index.js
console.log('data come from index.js');

在这里插入图片描述
可以看到,服务端返回了这个js的静态文件的同时,也通过响应头返回了ETag。

ETag 和 Last-Modified的区别

  1. 精确度上,Etag 要优于 Last-Modified。Last-Modified 的时间单位是秒,如果某个文件在 1 秒内被改变多次,那么它们的 Last-Modified 并没有体现出来修改,但是 Etag 每次都会改变
  2. 如果服务端是负载均衡的服务器,各个服务器生成的 Last-Modified 也有可能不一致。
  3. 性能上,Etag 要逊于 Last-Modified,毕竟 Last-Modified 只需要记录时间,而 ETag 需要服务器通过消息摘要算法来计算出一个hash 值。
  4. 优先级上,在资源新鲜度校验时,服务器会优先考虑 Etag。即如果条件请求的请求头同时携带 If-Modified-Since 和 If-None-Match 字段,则会优先判断资源的 ETag 值是否发生变化。

参考文章

MDN-Cache-Control
MDN-Pragma
MDN-Expires

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

问白

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值