Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

浏览器缓存机制 #69

Open
peng-yin opened this issue Jul 9, 2021 · 0 comments
Open

浏览器缓存机制 #69

peng-yin opened this issue Jul 9, 2021 · 0 comments

Comments

@peng-yin
Copy link
Owner

peng-yin commented Jul 9, 2021

浏览器缓存的作用

  • 缓存可以减少冗余的数据传输,节省了网络带宽,从而更快的加载页面以及减少用户的流量消耗

  • 缓存降低了服务器的要求,从而让服务器更快的响应。

概述

浏览器缓存策略分为两种:强缓存和协商缓存。一看到强缓存,就可能联想到"弱缓存",这个"强"实际形容得不太恰当,其实强缓存指的是新鲜的响应,而协商缓存指的是陈旧的响应(已过期),而要复用陈旧的响应就得使用条件请求(If-xxx)进行缓存验证,

基本原理

  • 浏览器在加载资源时,根据请求头的 Expires 和 Cache-Control 判断是否命中强缓存,是则直接从缓存读取资源,不会发请求到服务器。

  • 如果没有命中强缓存,浏览器一定会发送一个请求到服务器,通过 Last-Modified 和 ETag 验证资源是否命中协商缓存,如果命中,则返回 304 读取缓存资源

  • 如果前面两者都没有命中,直接从服务器请求加载资源

// 强缓存
res.setHeader('Cache-Control', 'public, max-age=xxx');
res.setHeader('Cache-Control', 'public, max-age=0');
// 协商缓存
res.setHeader('Last-Modified', xxx);
res.setHeader('ETag', xxx);

缓存的文件到哪里去

一般看到的两种:memory cache(内存缓存) 和 disk cache(硬盘缓存)

  • 200 from memory cache:它是将资源文件缓存到内存中,而是直接从内存中读取数据。但该方式退出进程时数据会被清除,如关闭浏览器;一般会将将脚本、字体、图片会存储到内存缓存中。

  • 200 from disk cache:它是将资源文件缓存到硬盘中。关闭浏览器后,数据依然存在;一般会将非脚本的存放在硬盘中,比如 css。

优先访问 memory cache,其次是 disk cache,最后是请求网络资源;且内存读取速度比硬盘读取速度快,但也不能把所有数据放在内存中,因为内存也是有限的。

缓存的位置按照获取资源请求优先级,缓存位置依次如下:

  • Memory Cache(内存缓存)

  • Service Worker(离线缓存)

  • Disk Cache(磁盘缓存)

  • Push Cache(推送缓存)

Memory Cache

Memory 为内存缓存,是浏览器最先尝试命中的缓存,也是响应最快的缓存。但是存活时间最短的,当进程结束后,tab 标签关闭后,缓存就不存在了。

645

因为内存空间比较小,通常较小的资源放在内存缓存中,比如 base64 图片等资源。

Service Worker

Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,因此无法直接访问 DOM。

可以帮我们实现离线缓存、消息推送和网络代理等功能。

Disk Cache

内存的优先性,导致大文件不能缓存到内存中,那么磁盘缓存则不同。虽然存储效率比内存缓存慢,但是存储容量和存储市场有优势。

Push Cache

它是最后一道缓存命中,属于 HTTP2 的内容.

通过 express 搭建服务模拟演示

const path = require('path')
const fs = require('fs')
const express = require('express')
const app = express()

app.get('/', (req, res) => {
  res.send(`<!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Init Demo</title>
  </head>
  <body>
    Hello Init Demo
    <script src="/test.js"></script>
  </body>
  </html>`)
})

app.get('/test.js', (req, res) => {
  let sourcePath = path.resolve(__dirname, '../public/test.js')
  let result = fs.readFileSync(sourcePath)
  res.end(result)
})

app.listen(3000, () => {
  console.log('listening on 3000')
})

如上代码,当进入http://localhost:3000/,请求过程如下:

  • 浏览器请求静态资源 test.js
  • 服务器读取磁盘文件 test.js,返回给浏览器
  • 浏览器再次请求,服务器又重新读取磁盘文件 test.js,返给浏览器。

多次刷新网页,重新进入,可以看到每次都重新发了test.js的请求,这种方式下没有进行任何缓存。

http1

强缓存

强缓存通过 Expires 和 Cache-Control 两种响应头实现

原理:

浏览器在加载资源的时候,会先根据本地缓存资源的 header 中的信息(Expires 和 Cache-Control)来判断是否需要强制缓存。如果命中的话,则会直接使用缓存中的资源。否则的话,会继续向服务器发送请求。

Expires

Expires 是 HTTP1.0 的规范,表示该资源过期时间,它描述的是一个绝对时间(值为 GMT 时间,即格林尼治时间),由服务器返回。

如果浏览器端当前时间小于过期时间,则直接使用缓存数据。

app.get('/test.js', (req, res) => {
  let sourcePath = path.resolve(__dirname, '../public/test.js')
  let result = fs.readFileSync(sourcePath)
  res.setHeader(
    'Expires',
    moment().utc().add(1, 'm').format('ddd, DD MMM YYYY HH:mm:ss') + ' GMT' // 设置1分钟后过期
  )
  res.end(result)
})

上面代码我们添加了 Expires 响应头,第一次向服务器发起请求后,同时返回过期时间和文件;当再次刷新时,就会看到文件是直接从缓存(memory cache)中读取的;当 1 分钟过后过期了,又会重新请求了。

http2

这种方式受限于本地时间,服务器的时间和客户端的时间不一样的情况下(比如浏览器设置了很后的时间,则一直处于过期状态),可能会造成缓存失效。而且过期后,不管文件有没有发生变化,服务器都会再次读取文件返回给浏览器。

Expires 是 HTTP 1.0 的东西,现在默认浏览器大部分使用 HTTP 1.1,它的作用基本忽略,因为会靠Cache-Control作为主要判断依据。

Cache-Control

知道了 Expires的缺点后,在 HTTP 1.1 版开始,就加入了Cache-Control来替代,它是利用max-age判断缓存时间的,以秒为单位,它的优先级高于 Expires,表示的是相对时间。也就是说,如果max-age和Expires同时存在,则被 Cache-Control 的 max-age 覆盖。

app.get('/test.js', (req, res) => {
  let sourcePath = path.resolve(__dirname, '../public/test.js')
  let result = fs.readFileSync(sourcePath)
  res.setHeader('Cache-Control', 'max-age=60') // 设置相对时间-60秒过期
  res.end(result)
})

同样第一次请求后再次请求,就可以看到是命中协商缓存了。

http3

除了该字段,还有其他字段可设置:

public
表示可以被浏览器和代理服务器缓存,代理服务器一般可用 nginx 来做
private
只让客户端可以缓存该资源;代理服务器不缓存
no-cache
跳过设置强缓存,但是不妨碍设置协商缓存;一般如果你做了强缓存,只有在强缓存失效了才走协商缓存的,设置了 no-cache 就不会走强缓存了,每次请求都回询问服务端。
no-store
禁止使用缓存,每一次都要重新请求数据。
比如我设置禁止缓存,再重复上面操作,就每次都向服务器请求了

res.setHeader('Cache-Control', 'no-store, max-age=60') // 禁止缓存

http4

协商缓存

强缓存的缺点就是每次都根据时间来判断是否过期,但如果到了过期时间后,文件没有改动,再次去获取就有点浪费服务器的资源了,因此有了协商缓存。

当浏览器对某个资源的请求没有命中强缓存,就会发一个请求到服务器,验证协商缓存是否命中,如果协商缓存命中,请求响应返回的 http 状态为 304 告诉浏览器读取换出,并且会显示一个 Not Modified 的字符串;如果未命中,则返回请求的资源。

协商缓存是利用的是Last-Modified/If-Modified-Since和ETag/If-None-Match这两对标识来管理的

原理:

客户端向服务器端发出请求,服务端会检测是否有对应的标识,如果没有对应的标识,服务器端会返回一个对应的标识给客户端,客户端下次再次请求的时候,把该标识带过去,然后服务器端会验证该标识,如果验证通过了,则会响应 304,告诉浏览器读取缓存。如果标识没有通过,则返回请求的资源。

Last-Modified
过程如下:

  • 浏览器请求资源,服务器每次返回文件的同时,返回Last-Modified(最后的修改时间)到 Header 中
  • 当浏览器的缓存文件过期后,浏览器带上请求头If-Modified-Since(值为上一次的Last-Modified)请求服务器
  • 服务器比较请求头的If-Modified-Since和文件上次修改时间一样,则命中缓存返回 304;如果不一致就返回 200 响应和文件的内容还有更新Last-Modified的值,以此往复。
app.get('/test.js', (req, res) => {
  let sourcePath = path.resolve(__dirname, '../public/test.js')
  let result = fs.readFileSync(sourcePath)
  let status = fs.statSync(sourcePath)
  let lastModified = status.mtime.toUTCString()
  if (lastModified === req.headers['if-modified-since']) {
    res.writeHead(304, 'Not Modified')
    res.end()
  } else {
    res.setHeader('Cache-Control', 'max-age=1') // 设置1秒后过期以方便我们马上能用Last-Modified判断
    res.setHeader('Last-Modified', lastModified)
    res.writeHead(200, 'OK')
    res.end(result)
  }
})

第一次请求如下:

http5

1 秒过期后,再次进行多次请求,发现返回的都是 304,命中协商缓存:

http6

ETag

Last-Modified 也有它的缺点,比如修改时间是 GMT 时间,只能精确到秒,如果文件在 1 秒内有多次改动,服务器并不知道文件有改动,浏览器拿不到最新的文件。而且如果文件被修改后又撤销修改了,内容还是保持原样,但是最后修改时间变了,也要重新请求。也有可能存在服务器没有准确获取文件修改时间,或与代理服务器时间不一致的情况。

为了解决文件修改时间不精确带来的问题,服务器和浏览器再次协商,这次不返回时间,返回文件的唯一标识ETag。只有当文件内容改变时,ETag才改变。ETag的优先级高于Last-Modified。

过程如下:

  • 浏览器请求资源,服务器每次返回文件的同时带上文件的唯一标识 ETag
    -当浏览器的缓存文件过期后,浏览器带上请求头If-None-Match(值为上一次的ETag)请求服务器
  • 服务器比较请求头的If-None-Match和文件的 ETag 一样,则命中缓存返回 304;如果不一致就返回 200 响应和文件的内容还有更新ETag的值,以此往复。
const md5 = require('md5')

app.get('/test.js', (req, res) => {
  let sourcePath = path.resolve(__dirname, '../public/test.js')
  let result = fs.readFileSync(sourcePath)
  let etag = md5(result)

  if (req.headers['if-none-match'] === etag) {
    res.writeHead(304, 'Not Modified')
    res.end()
  } else {
    res.setHeader('ETag', etag)
    res.writeHead(200, 'OK')
    res.end(result)
  }
})

http7

再次请求时:

http8

HTTP 中并没有指定如何生成ETag, 实际项目中生成哈希(hash)是比较常见的做法。

不过ETag每次服务端生成都需要进行读写操作,而Last-Modified只需要读取操作,ETag生成计算的消耗更大些。

缓存的优先级

首先明确的是强缓存的优先级高于协商缓存

在 HTTP1.0 的时候还有个Pragma字段,也属于强缓存,当该字段值为 no-cache 的时候,会告诉浏览器不要对该资源缓存,即每次都得向服务器发一次请求才行,它的优先级高于Cache-Control

一般我们现在就不会用它了,如果想见到Pragma的话,在 Chrome 的 devtools 中启用 disable cache 时或者按 Ctrl + F5强制刷新,就会在请求的 Request Header 上看到。

http9

最后优先级为:

Pragma > Cache-Control > Expires > ETag > Last-Modified

如何清除缓存

浏览器默认会缓存图片,css 和 js 等静态资源,有时在开发环境下可能会因为强缓存导致资源没有及时更新而看不到最新的效果,可以用如下几种方式:

  • 直接 Ctrl+F5 强制刷新(直接 F5 的话会跳过强缓存规则,直接走协商缓存)
  • 谷歌浏览器可以在 Network 里面选中Disable cache
  • 给资源文件加一个时间戳
  • 其他方式设置如 webpack

缓存场景

对于大部分的场景都可以使用强缓存配合协商缓存解决,但是在一些特殊的地方可能需要选择特殊的缓存策略

  • 对于某些不需要缓存的资源,可以使用 Cache-control: no-store ,表示该资源不需要缓存
  • 对于频繁变动的资源,可以使用 Cache-Control: no-cache 并配合 ETag 使用,表示该资源已被缓存,但是每次都会发送请求询问资源是否更新
  • 对于代码文件来说,通常使用 Cache-Control: max-age=31536000 并配合策略缓存使用,然后对文件进行指纹处理,一旦文件名变动就会立刻下载新的文件

最佳实践

缓存的意义就在于减少请求,更多地使用本地的资源,给用户更好的体验的同时,也减轻服务器压力。所以,最佳实践,就应该是尽可能命中强缓存,同时,能在更新版本的时候让客户端的缓存失效。
在更新版本之后,如何让用户第一时间使用最新的资源文件呢?机智的前端们想出了一个方法,在更新版本的时候,顺便把静态资源的路径改了,这样,就相当于第一次访问这些资源,就不会存在缓存的问题了。

webpack可以让我们在打包的时候,在文件的命名上带上hash值

我们可以得出一个较为合理的缓存方案:

HTML:使用协商缓存。
CSS&JS&图片:使用强缓存,文件命名带上hash值。

1、index.html 不做缓存,每次请求都获取最新版本
2、使用 webpack 等 build 后的其他所有资源文件(包括 js、css 和图片等),都做强缓存(一个月打底,可以设置一年)

我们知道协商缓存其实也向服务端发起了一个请求,只不过最后经过一系列验证,结果就是不传输具体内容了,但是验证的过程也给后端造成了一些开销,所以我们要尽量减少这种开销。

那么把前端资源都用作强缓存就是最好的处理办法了,但是又面临一个问题『前端发版怎么更新』
这时候我们需要注意 build 后的前端资源,现在各个脚手架默认都会把文件名字生成一段 hash 值
xxx.6ae44c4e.js这种。原来我一直不明白这样的意义在哪,现在明白了。

首先 index.html 不做缓存,所以当我们发版以后浏览器会获取到最新的 index.html 文件,由于每次 build 之后生成的文件名字不同,index.html 引入的前端的资源就不同,旧的所有的资源文件包括 js、css 和图片等)都不会影响新发版的内容。

这样的话就做到了在不发版的情况下,每次刷新基本就一个 index.html 的资源还有一些接口请求,其余的全都是强缓存资源。发版之后可以立马更新,不会造成资源混乱。现在掘金和 segmentfault 都是采取的这种策略。

http

和运维的同事商量了强缓存的优化,将 build 之后的 static 文件夹全部做了 add_header Cache-Control "max-age=30243600",也就是强缓存 30 天的设置。

经过测试发现,由协商缓存到强缓存后,有如下变化:

类型 请求数 实际传输资源大小 传输完毕(Finish) DOMContentLoaded Load
协商缓存 631 168K 2.00s 447 ms 717 ms
强缓存 631 23K 1.43s 424 ms 704 ms

结果分析

首先,请求数量是一样的,完全 Load 的时候差不多,Finish 的时间提升了 25%,传输的资源大小减少了 80% 以上,同时由于没有和服务端去进行协商,相当于对服务端也做了优化。这些都是在协商缓存的基础上做的优化对比,如果后端服务连协商缓存都没上的话,真的应该优化一下了


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant