什么是浏览器缓存
-
在正式开始讲解浏览器缓存之前,先来回顾一下整个 Web 应用的流程;
-
上图展示了一个 Web 应用最最简单的结构;客户端向服务器端发送 HTTP 请求,服务器端从数据库获取数据,然后进行计算处理,之后向客户端返回 HTTP 响应;那么上面整个流程中,哪些地方比较耗费时间呢?总结起来有如下两个方面:
- 发送请求的时候;
- 涉及到大量计算的时候;
-
一般来讲,上面两个阶段比较耗费时间;
- 首先是发送请求的时候;这里所说的请求,不仅仅是 HTTP 请求,也包括服务器向数据库发起查询数据的请求;
- 其次是大量计算的时候;一般涉及到大量计算,主要是在服务器端和数据库端,服务器端要进行计算这个很好理解,数据库要根据服务器发送过来的查询命令查询到对应的数据,这也是比较耗时的一项工作;
-
因此,单论缓存的话,其实在很多地方都可以做缓存,例如:
- 数据库缓存
- CDN 缓存
- 代理服务器缓存
- 浏览器缓存
- 应用层缓存
-
针对各个地方做出适当的缓存,都能够很大程度的优化整个 Web 应用的性能;但是要逐一讨论的话,是一个非常大的工程量,所以本文主要来看一下浏览器缓存,整个浏览器的缓存过程如下:
-
从上图可以看到,整个浏览器端的缓存其实没有想象的那么复杂,其最基本的原理就是:
- 浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识;
- 浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中;
-
以上两点结论就是浏览器缓存机制的关键,它确保了每个请求的缓存存入与读取,只要再理解浏览器缓存的使用规则,那么所有的问题就迎刃而解了;
按照缓存位置分类
-
从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络;
-
这四种依次为:
- Service Worker
- Memory Cache
- Disk Cache
- Push Cache
Service Worker
-
Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能;
- 使用 Service Worker 的话,传输协议必须为 HTTPS;因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全;
- Service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的;
-
Service Worker 实现缓存功能一般分为三个步骤:
- 首先需要先注册 Service Worker;
- 然后监听到 install 事件以后就可以缓存需要的文件;
- 那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据;
-
当 Service Worker 没有命中缓存的时候,需要去调用 fetch 函数获取数据;也就是说,如果没有在 Service Worker 命中缓存的话,会根据缓存查找优先级去查找数据;
-
但是不管是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示我们是从 Service Worker 中获取的内容;
Memory Cache
-
Memory Cache 也就是内存中的缓存,主要包含的是当前中页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等;
-
读取内存中的数据肯定比磁盘快,内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放;一旦关闭 Tab 页面,内存中的缓存也就被释放了;
-
那么既然内存缓存这么高效,是不是能让数据都存放在内存中呢?这是不可能的;计算机中的内存一定比硬盘容量小得多,操作系统需要精打细算内存的使用,所以能让我们使用的内存必然不多;
-
当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存。
-
Memory Cache 机制保证了一个页面中如果有两个相同的请求,都实际只会被请求最多一次,避免浪费,例如:
- 两个 src 相同的 img;
- 两个 href 相同的 link;
Disk Cache
-
Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上;
-
在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的;它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求;并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据;绝大部分的缓存都来自 Disk Cache;
-
凡是持久性存储都会面临容量增长的问题,Disk Cache 也不例外;
- 在浏览器自动清理时,会有特殊的算法去把 最老的 或者 最可能过时的 资源删除,因此是一个一个删除的;
- 不过每个浏览器识别 最老的 和 最可能过时的 资源的算法不尽相同,这也可以看作是各个浏览器差异性的体现;
Push Cache
-
Push Cache 翻译成中文叫做 推送缓存 ,是属于 HTTP/2 中新增的内容;当以上三种缓存都没有命中时,它才会被使用;它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在 Chrome 浏览器中只有 5 分钟左右,同时它也并非严格执行 HTTP/2 头中的缓存指令;
-
Push Cache 在国内能够查到的资料很少,也是因为 HTTP2 在国内还不够普及;这里推荐阅读 Jake Archibald 的 HTTP/2 push is tougher than I thought 这篇文章;文章中的几个结论:
- 所有的资源都能被推送,并且能够被缓存,但是 Edge 和 Safari 浏览器支持相对比较差;
- 可以推送 no-cache 和 no-store 的资源;
- 一旦连接被关闭,Push Cache 就被释放;
- 多个页面可以使用同一个 HTTP/2 的连接,也就可以使用同一个 Push Cache;这主要还是依赖浏览器的实现而定,出于对性能的考虑,有的浏览器会对相同域名但不同的 tab 标签使用同一个 HTTP 连接;
- Push Cache 中的缓存只能被使用一次;
- 浏览器可以拒绝接受已经存在的资源推送;
- 可以给其他域名推送资源;
-
如果一个请求在上述几个位置都没有找到缓存,那么浏览器会正式发送网络请求去获取内容;之后为了提升之后请求的缓存命中率,自然要把这个资源添加到缓存中去;具体来说:
- 根据 Service Worker 中的 handler 决定是否存入 Cache Storage (额外的缓存位置);Service Worker 是由开发者编写的额外的脚本,且缓存位置独立,出现也较晚,使用还不算太广泛;
- Memory Cache 保存一份资源的引用,以备下次使用;Memory Cache 是浏览器为了加快读取缓存速度而进行的自身的优化行为,不受开发者控制,也不受 HTTP 协议头的约束,算是一个黑盒;
- 根据 HTTP 头部的相关字段( Cache-control、Pragma 等 )决定是否存入 Disk Cache;Disk Cache 也是平时最熟悉的一种缓存机制,也叫 HTTP Cache (因为不像 Memory Cache ,它遵守 HTTP 协议头中的字段);平时所说的强制缓存,协商缓存,以及 Cache-Control 等,也都归于此类;
按照缓存类型分类
按照缓存类型来进行分类,可以分为 强制缓存 和 协商缓存;
需要注意的是,无论是强制缓存还是协商缓存,都是属于 Disk Cache 或者叫做 HTTP Cache 里面的一种;
强制缓存
-
强制缓存的含义是,当客户端请求后,会先访问缓存数据库看缓存是否存在;
- 如果存在则直接返回;
- 不存在则请求真的服务器,响应后再写入缓存数据库;
-
强制缓存直接减少请求数,是提升最大的缓存策略;如果考虑使用缓存来优化网页性能的话,强制缓存应该是首先被考虑的;
-
可以造成强制缓存的字段是 Expires 和 Cache-control;
协商缓存
-
当强制缓存失效(超过规定时间)时,就需要使用协商缓存,由服务器决定缓存内容是否失效;
-
流程上说,浏览器先请求缓存数据库,返回一个缓存标识;之后浏览器拿这个标识和服务器通讯;
- 如果缓存未失效,则返回 HTTP 状态码 304 表示继续使用,于是客户端继续使用缓存;
- 如果失效,则返回新的数据和缓存规则,浏览器响应数据后,再把规则写入到缓存数据库;
- 如果缓存未失效,则返回 HTTP 状态码 304 表示继续使用,于是客户端继续使用缓存;
-
协商缓存在请求数上和没有缓存是一致的,但如果是 304 的话,返回的仅仅是一个状态码而已,并没有实际的文件内容,因此在响应体体积上的节省是它的优化点;
- 它的优化主要体现在 响应 上面通过减少响应体体积,来缩短网络传输时间;所以和强制缓存相比提升幅度较小,但总比没有缓存好;
- 协商缓存是可以和强制缓存一起使用的,作为在强制缓存失效后的一种后备方案;实际项目中他们也的确经常一同出现;
-
对比缓存有 2 组字段(不是两个):
- Last-Modified & If-Modified-Since
- Etag & If-None-Match
缓存读取规则
浏览器请求资源流程
从 Service Worker 中获取内容( 如果设置了 Service Worker )
查看 Memory Cache
查看 Disk Cache 这里又细分:
- 如果有强制缓存且未失效,则使用强制缓存,不请求服务器。这时的状态码全部是 200
- 如果有强制缓存但已失效,使用协商缓存,比较后确定 304 还是 200
发送网络请求,等待网络响应
把响应内容存入 Disk Cache (如果 HTTP 响应头信息有相应配置的话)
把响应内容的引用存入 Memory Cache (无视 HTTP 头信息的配置)
把响应内容存入 Service Worker 的 Cache Storage( 如果设置了 Service Worker )
Disk Cache 流程图
浏览器行为
-
在了解了整个缓存策略或者说缓存读取流程后,还需要了解一个东西,那就是用户对浏览器的不同操作,会触发不同的缓存读取策略;
-
对应主要有 3 种不同的浏览器行为:
- 打开网页,地址栏输入地址:查找 Disk Cache 中是否有匹配;如有则使用;如没有则发送网络请求;
- 普通刷新 (F5):因为 TAB 并没有关闭,因此 Memory Cache 是可用的,会被优先使用(如果匹配的话);其次才是 Disk Cache;
- 强制刷新 ( Ctrl + F5 ):浏览器不使用缓存,因此发送的请求头部均带有 Cache-control: no-cache(为了兼容,还带了 Pragma: no-cache );服务器直接返回 200 和最新内容;
实操案例
-
实践才是检验真理的唯一标准;上面已经将理论部分讲解完毕了,接下来用实际代码验证一下上面所讲的验证规则;下面是使用 Node.js 搭建的服务器:
const http = require('http'); const path = require('path'); const fs = require('fs'); var hashStr = "A hash string."; var hash = require("crypto").createHash('sha1').update(hashStr).digest('base64'); http.createServer(function (req, res) { const url = req.url; // 获取到请求的路径 let fullPath; // 用于拼接完整的路径 if (req.headers['if-none-match'] == hash) { res.writeHead(304); res.end(); return; } if (url === '/') { // 代表请求的是主页 fullPath = path.join(__dirname, 'static/html') + '/index.html'; } else { fullPath = path.join(__dirname, "static", url); res.writeHead(200, { 'Cache-Control': 'max-age=5', "Etag": hash }); } // 根据完整的路径 使用fs模块来进行文件内容的读取 读取内容后将内容返回 fs.readFile(fullPath, function (err, data) { if (err) { res.end(err.message); } else { // 读取文件成功,返回读取的内容,让浏览器进行解析 res.end(data); } }); }) .listen(3000, function () { console.log("服务器已启动,监听 3000 端口..."); })
-
在上面的代码中,使用 Node.js 创建了一个服务器,根据请求头的 if-none-match 字段接收从客户端传递过来的 Etag 值,如果和当前的 Hash 值相同,则返回 304 的状态码;
-
在资源方面,除了主页没有设置缓存,其他静态资源我们设置了 5 秒的缓存,并且设置了 Etag 值;
注:上面的代码只是服务器部分代码,完整代码请参阅本章节所对应的代码;
-
效果如下:
- 可以看到,第一次请求时因为没有缓存,所以全部都是从服务器上面获取资源,之后刷新页面是从 memory cache 中获取的资源,但是由于强缓存只设置了 5 秒,所以之后再次刷新页面,走的就是协商缓存,返回 304 状态码;
- 但是在这个示例中,如果修改了服务器的静态资源,客户端是没办法实时的更新的,因为静态资源是直接返回的文件,只要静态资源的文件名没变,即使该资源的内容已经发生了变化,服务器也会认为资源没有变化;
- 那怎么解决呢?解决办法也就是我们在做静态资源构建时,在打包完成的静态资源文件名上根据它内容 Hash 值添加上一串 Hash 码,这样在 CSS 或者 JS 文件内容没有变化时,生成的文件名也就没有变化,反映到页面上的话就是 url 没有变化;
- 如果你的文件内容有变化,那么对应生成的文件名后面的 Hash 值也会发生变化,那么嵌入到页面的文件 url 也就会发生变化,从而可以达到一个更新缓存的目的;这也是为什么在使用 webpack 等一些打包工具时,打包后的文件名后面会添加上一串 Hash 码的原因;
- 目前来讲,这在前端开发中比较常见的一个静态资源缓存方案;
最佳实践
频繁变动的资源
-
对于频繁变动的资源,首先需要使用 Cache-Control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效;
Cache-Control: no-cache
-
这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小;
不常变化的资源
-
通常在处理这类资源时,给它们的 Cache-Control 配置一个很大的 max-age=31536000 (一年),这样浏览器之后请求相同的 URL 会命中强制缓存;
Cache-Control: max-age=31536000
-
而为了解决更新的问题,就需要在文件名(或者路径)中添加 Hash、版本号等动态字符,之后更改动态字符,从而达到更改引用 URL 的目的,让之前的强制缓存失效 (其实并未立即失效,只是不再使用了而已);
-
在线提供的类库(如 jquery-3.3.1.min.js、lodash.min.js 等)均采用这个模式;
面试题
请求时浏览器缓存 from memory cache 和 from disk cache 的依据是什么,哪些数据什么时候存放在 Memory Cache 和 Disk Cache 中?
memory cache 是浏览器自动完成的,它不关心 http 语义,但会遵守
cache-control: no-store
指令;浏览器在请求资源后,会自动将资源加入 memory cache ,在后续的请求中,若请求的 url 地址和之前缓存的对应地址相同,则直接使用 memory cache;memory cache 只缓存 get 请求,并且缓存的内容在内存中,因此会很快的清理;disk cache 遵守 http 缓存语义,它会按照服务器响应头中指定的缓存要求进行缓存,由于它存在于磁盘中,因此,即便浏览器关闭后缓存内容也不会消失。它的保存时间由服务器的
cache-control
字段指定,当缓存失效后,会重新发送请求到服务器,进入协商缓存的流程;
怎样选择合适的缓存策略
对于一次性的资源,比如验证码图片,不进行缓存:设置响应头
cache-control: no-store
对于频繁变动的资源,比如某些数据接口,使用协商缓存:设置响应头
cache-control: no-cache
,同时配合ETag
标记,让浏览器缓存资源,但每次都会发送请求询问资源是否更新;对于静态资源,比如 JS、CSS、图片 等文件,使用强制缓存:设置响应头
cache-control: max-age=有效时长
,设置一个很长的过期时间,比如十年,然后通过文件 hash 的处理更新;
浏览器🧑💻 浏览器的离线存储-File System
上一篇