「代码实操」 从根儿上理解浏览器缓存

2,259 阅读12分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第30天,点击查看活动详情

前言

缓存是优化手段中不可或缺的一部分,面试时也经常问到,它可以加快页面的响应速度、减轻服务器的压力。很多人对这方面的了解都是看过相关文章,但是没有实际操作过,今天我带大家手把手走一遍这个流程,让浏览器缓存知识变成真正属于自己的知识。

搭建 express 服务端

首先我们先用 express 框架搭建一个简单的服务端。

三部曲运行服务器

三部曲第一步:创建一个空文件夹,进入终端生成一个 package.json 文件。

npm init -y 

三部曲第二步:安装 express 依赖。

npm install express --save

三部曲第三步:创建一个 index.js 文件。

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

浏览器打开 localhost:3000 看看刚起的服务:

微信截图_20221015180323.png

不出意外我们看到的是这个画面。紧接着就可以进行下一步了。

打印请求资源路径

接下来我们要做一件事 —— 将每次向服务端请求资源的路径都打印出来 。这样方便我们后续对缓存未命中,观察哪些资源是需要重新向服务端请求的。

app.all("*", function (req, res, next) {
    console.log(req.originalUrl);
    next();
});
app.use('/public', express.static(__dirname + '/public', {
    ...
}))

这里要注意顺序之分,先 app.allapp.use

静态文件映射

如果你想把所有的静态文件路径都前缀"/static", 你可以使用“挂载”功能。 如果req.url 不包含这个前缀, 挂载过的中间件不会执行。 当function被执行的时候,这个参数不会被传递。 这个只会影响这个函数,后面的中间件里得到的 req.url里将会包含"/static"

// GET /static/javascripts/jquery.js
// GET /static/style.css
// GET /static/favicon.ico
app.use(express.static('/public'));
declare namespace serveStatic {
    var mime: typeof m;
    interface ServeStaticOptions<R extends http.ServerResponse = http.ServerResponse> {
        cacheControl?: boolean | undefined;
        etag?: boolean | undefined;
        maxAge?: number | string | undefined;
        lastModified?: boolean | undefined;
    }
    ...
}

首先我们 禁用所有缓存 。具体的,根据 ts 文件我们可以知道,etagcacheControl 以及 lastModified 都可以接受一个 Boolean 值,那么我们可以给它们三个设置 false,表示禁用,而 maxAge 我们可以设置一个 0,表示禁用。

app.use(express.static('/static', {
    etag: false,
    cacheControl: false,
    lastModified: false,
    maxAge: 0
}))

功能测试

接下来访问 localhost:3000/public/index.html 进行测试:

微信截图_20221018190511.png

终端资源请求路径:

微信截图_20221018210912.png

ps:小伙伴们测试缓存的时候重点关注 status 和 size 这两栏就好了,后面不再赘述。

不管刷新多少次,状态码都是是 200,Size 栏那边也没有任何表示 test.js 是从缓存中读取的信息,并且每次刷新时,终端中都会打印这两个文件 —— 这说明每次都是从服务端直接获取的。

强缓存

首先我们测试强缓存 —— 在设定的过期时间内,浏览器都不会再向服务器请求资源,而是直接从浏览器缓存中加载资源。

Expires

Expires 是 HTTP 1.0 时期提出的一个 表示资源过期的绝对时间 的头部。

测试

接下来我们尝试给响应的资源加上 Expires 头部:

app.use('/public', express.static(__dirname + '/public', {
    ...
    setHeaders: function (res, path, stat) {
        res.append('Expires', new Date(new Date().getTime() + 15 * 1000));
    }
}))

这里我们将 资源过期时间 设置为 15 秒,即 15 秒后,强缓存失效,需要向服务端重新请求资源。

重启服务,然后再次访问 localhost:3000/public/index.html,我们来看看浏览器中请求的结果以及终端里当前请求的资源路径:

  1. 首次访问页面时加载了 index.html 文件以及 test.js 文件: 微信截图_20221018193453.png 微信截图_20221018210912.png

  2. 15 秒内,刷新页面重新加载 test.js 文件,此时强缓存仍有效,不用向服务端发起资源请求,而是直接从浏览器缓存中获取资源: 微信截图_20221018193501.png 微信截图_20221018210923.png

  3. 超出 15 秒后,重新加载 test.js 文件,发现强缓存失效,需要重新向服务端发起资源请求: 微信截图_20221018193509.png 微信截图_20221018210912.png

详细过程:

  1. 浏览器首次加载资源时向服务端发出资源请求,随后服务端响应资源,同时设置了 Expires 响应头,表示资源过期的绝对时间 ,浏览器接收资源的同时会将这个 Response Header 缓存下来。
  2. 15 秒内再次加载该资源时,通过比对此前缓存的 Response HeaderExpires 的时间,判定命中了强缓存,会直接从浏览器缓存中加载资源,状态码 200,同时 Size 栏显示 momory cache
  3. 15 秒后加载该资源时,同样通过比对,发现超出过期时间,则判定强缓存失效,会重新向服务端发送资源请求,浏览器接收响应资源的同时缓存该 Response Header

缺点

我们从上面可以知道,Expires 是服务端往响应头中设置的 绝对时间,如果说服务器和浏览器的时间不一致的话,是很不稳定的 —— 客户端修改本地时间时或者时区不一致时,都可能产生问题。

Cache-Control

HTTP 1.1 时期增加 Cache-Control 响应头来改善这个问题。

测试

Cache-Control 的默认值为 public, max-age=0 。因此我们要修改 maxAge 属性。

cacheControl?: boolean | undefined;
maxAge?: number | string | undefined;

通过 ts 文件我们可以看出 cacheControl 接收的是 Boolean 值,我们要将它设置为 true 。同时,将 maxAge 属性设置为 15 秒(注意这里的 maxAge 属性单位是毫秒,响应头的 max-age 单位是秒,因此要注意单位换算)。

app.use('/public', express.static(__dirname + '/public', {
    etag: false,
    cacheControl: true,
    lastModified: false,
    maxAge: 15 * 1000, // 注意这里的 maxAge 单位是毫秒,响应头的 max-age 单位是秒
    // setHeaders: function (res, path, stat) {
    //     res.append('Expires', new Date(new Date().getTime() + 15 * 1000));
    // }
}))

重启服务,然后访问 localhost:3000/public/index.html:

  1. 首次访问页面时加载了 index.html 文件以及 test.js 文件: 微信截图_20221018194627.png 微信截图_20221018210912.png

  2. 15 秒内,刷新页面重新加载 test.js 文件,此时强缓存仍有效,不用向服务端发起资源请求,而是直接从浏览器缓存中获取资源: 微信截图_20221018194632.png 微信截图_20221018210923.png

  3. 超出 15 秒后,重新加载 test.js 文件,发现强缓存失效,需要重新向服务端发起资源请求: 微信截图_20221018194652.png 微信截图_20221018210912.png

喜欢看 动图 的小伙伴看这里:

20221018_201203.gif

详细过程:

  1. 15 秒后,同样的操作,但是发现超出 当前时间,判定强缓存过期,于是重新向服务端发送资源请求,浏览器接收资源的同时将缓存该响应头。

  2. 浏览器首次加载资源时向服务端发出资源请求,随后服务端响应资源,同时设置了 Cache-Control 响应头,表示资源过期的相对时间 ,浏览器接收资源的同时会将这个 Response Header 缓存下来。

  3. 15 秒内再次加载该资源时,通过比对此前缓存的 Response Header 中的资源过期时间 Cache-Control + 资源请求时间 Date,发现 未超出当前时间 ,则判定命中了强缓存,会直接从浏览器缓存中加载资源,状态码 200,同时 Size 栏显示 momory cache

  4. 15 秒后加载该资源时,同样通过比对,发现 超出当前时间 ,则判定强缓存失效,会重新向服务端发送资源请求,浏览器接收响应资源的同时缓存该 Response Header

Cache-Control 和 Expires 的优先级

如果 Cache-ControlExpires 同时存在会以谁的时间为准呢?我们来测试一下:

app.use('/public', express.static(__dirname + '/public', {
    etag: false,
    cacheControl: true,
    lastModified: false,
    maxAge: 10 * 1000,
    setHeaders: function (res, path, stat) {
        res.append('Expires', new Date(new Date().getTime() + 15 * 1000));
    }
}))

重启服务,然后访问 localhost:3000/public/index.html。

20221018_201524.gif

可以发现强缓存在 10 秒后就失效了。

因此当 Cache-ControlExpires 同时存在时,会以 Cache-Control 的时间为准。

小结

通过上面的了解,我们应当知道:

  1. 强缓存 Expires ,表示的是一个 服务端为准的绝对时间
  2. 强缓存 Cache-Control ,表示的是一个过期秒数,过期时间是以 客户端为准的相对时间
  3. 两者均存在则以 Cache-Control 的时间为准。
  4. 加载资源时,如果命中强缓存(在有效期内),则响应状态码 200,且有 memory cache 字样,表示该资源是从浏览器缓存中加载的。若缓存失效后会重新向服务端发起资源请求,服务器响应资源,浏览器接收响应的同时将 Response Header 缓存下来。
  5. 强缓存是由服务器设置的,但是是由浏览器来判断缓存是否还有效。

协商缓存

如果没有命中强缓存,就会判断是否命中协商缓存,此时浏览器会向服务端发送请求,确认资源是否被修改(是否有效),如果资源未修改则告知浏览器仍可用缓存的资源,否则服务端响应时会将资源一并响应。

Last-Modified 和 If-Modified-Since

协商缓存的实现都是成双成对的,我们先介绍 Last-ModifiedIf-Modified-SinceLast-Modified 表示资源的最后修改时间,类似这样:

微信截图_20221018214607.png

测试

lastModified?: boolean | undefined;

从 ts 文件中我们可以知道 lastModified 属性接收一个 boolean 值,true 表示开启,我们来尝试一下:

app.use('/public', express.static(__dirname + '/public', {
    etag: false,
    cacheControl: true,
    lastModified: true,
    maxAge: 10 * 1000,
}))

我们看看效果:

20221018_220428.gif

详细过程:

  1. 浏览器首次加载资源时向服务端发出资源请求,随后服务端响应资源,同时设置了 ExpiresLast-Modified 响应头,浏览器接收资源的同时会将这个 Response Header 缓存下来。
  2. 10 秒内再次加载该资源时,通过比对此前缓存的 Response HeaderExpires 的时间,判定命中了强缓存,会直接从浏览器缓存中加载资源,状态码 200,同时 Size 栏显示 momory cache
  3. 10 秒后加载该资源时,同样通过比对,发现超出过期时间,则判定强缓存失效,会重新向服务端发送资源请求,验证是否命中协商缓存。此次请求会将缓存的 Response Header 中的 Last-Modified 值作为这次请求头 If-Modified-Since 的值,服务端接收请求后,将请求头中 If-Modified-Since 与所请求资源的最后修改时间比对,根据是否修改来判定是否过期 ,如果资源未修改则返回状态码 304 Not Modified,表示命中协商缓存;否则重新返回资源和 新的最后修改时间

微信截图_20221018232817.png

缺点

乍一看好像这样挺好的,没啥毛病,真的是这样吗?

为了方便测试协商缓存的缺点,我们直接关闭 cacheControl 强缓存,使用 Expires ,然后将强缓存设置为当前时间之前,如此一来强缓存会一直过期:

app.use('/public', express.static(__dirname + '/public', {
    etag: false,
    cacheControl: false,
    lastModified: true,
    maxAge: 0,
    setHeaders: function (res, path, stat) {
        res.append('Expires', new Date(new Date().getTime() - 15 * 1000));
    }
}))

我们刚刚提到,lastModified 表示的是资源的最后修改时间,那么这个最后修改时间一定准确更新吗?

我们先右键属性看看 test.js 文件的最后修改时间:

微信截图_20221018222138.png

时间是 22:21:06。再看看响应头中 Last-Modified 的值:

微信截图_20221018222148.png

这里的时间是 GMT 时间 ,我们是在东八区,因此需要加上 8 小时,即 14:21:06 + 8:00 也就是 22:21:06。

这么看来最后修改时间是没问题的,接下来我们来测试。

这是此时 test.js 文件中的内容:

// test.js
console.log(123);

接下来我们进行修改,注意不要保存:

// test.js
console.log(1234);

然后再修改回去,保存。

接下来我们访问 localhost:3000/public/index.html:

微信截图_20221018222801.png

我们惊讶的发现 test.js文件内容没有修改 ,但是 Last-Modified 的值变了,并且状态码返回 200,我们再次刷新后状态码又变成 304,也就是命中了协商缓存。

有的小伙伴说:那你确实就是改了文件啊,“最后修改时间”变了不应该吗?

没错,逻辑没毛病,但是它已经不符合我们的需求了 —— 缓存资源 。我们想想为什么要缓存资源?因为它 内容没变动 ,所以我们不应该让服务器返回我们请求的资源的。test.js 的文件内容并没有变动,从需求上来看应该命中协商缓存的,然而没有 。这就是 Last-Modified 这个模式的弊端了 —— 不够准确。

除此之外,如果在极短时间内修改文件同时进行请求,那么“最后修改时间”也有可能没变。

ETag 和 If-None-Match

结论为先,ETag 会根据 文件的内容 + 文件修改时间 生成一个 hash 值。

这个和 Last-Modified 的判定逻辑差不多,不赘述了:

微信截图_20221018235346.png

需要注意的是,由于 ETag 是根据 文件的内容 + 文件修改时间 生成的 hash 值,因此 Last-Modified 的缺点它也有 —— 文件内容不变但 ETag 的值变了,缓存失效。

但是起码它能解决 Last-Modified 的另一个缺点。

小结

  1. 强缓存未命中时,会进行协商缓存判定,如果协商缓存命中,则状态码为 304 Not Modified。协商缓存未命中,则返回资源,同时更新对应的响应头。
  2. Last-Modified 是根据请求头中 If-Modified-Since 的值与资源的最后修改时间来判定是否命中协商缓存的,容易误判。
  3. ETag 是根据请求头中 If-None-Match 的值与资源生成的 hash 值来判定是否命中协商缓存的。

流程图

微信截图_20221026163135.png

结束语

本文就到此结束了,相信这么一番操作后,大家对浏览器缓存的理解更加深刻了。

如果小伙伴们有别的想法,欢迎留言,让我们共同学习进步💪💪。

如果文中有不对的地方,或是大家有不同的见解,欢迎指出🙏🙏。

如果大家觉得所有收获,欢迎一键三连💕💕。