其他分享
首页 > 其他分享> > 深刻理解前端缓存

深刻理解前端缓存

作者:互联网

想看原文戳这里

前端缓存与后端缓存的区别

基本的网络请求就是三个步骤:请求、处理、响应。

后端缓存的作用主要是进行“处理”步骤,通过保留数据库连接,存储处理结果等方式缩短处理时间,尽快进入“响应”步骤。这当然不在本文的讨论范围之内。

那么前端缓存的任务就是处理剩下的两步:“请求”和“响应”。在“请求”步骤中,浏览器也可以通过存储结果的方式直接使用资源,省去了发送请求的步骤;而“响应”步骤则需要浏览器和服务器的共同配合,通过减少响应内容来缩短传输时间。这些都会在下面进行讨论。

本文的主要内容包含

第一部分:介绍缓存,按缓存位置分类(memory cache,disk cache,Service worker等)

第二部分:按失效策略分类(Cache-Control,ETag等)

第三部分:分享一些帮助理我们解原理的案例

第四部分:介绍一下缓存的应用模式

第一部分:按缓存位置分类

我以前看过的文章,大部分讨论缓存的时候直接从http协议头中的缓存字段开始,例如Cache-Control,ETag,max-age等。但是偶尔也会听到别人讨论memory cache,disk cache等。这两个分类的体系有何关联呢?是否有交叉呢?我认为这篇文章最有价值的地方就是说明了这两个体系之间的区别和联系,因为以前我也被这两个体系搞得一团糟。

实际上,HTTP协议头的那些字段,都属于disk cache的范畴,是几个缓存位置之一。因此本文从全局出发,我们先讨论缓存位置,当我们讨论到disk cache时,再详细的讲述这些协议头字段的具体细节和作用。

我们可以再chrome开发者工具中,Network ->Size一列看到一个请求的最终处理方式:如果是大小(显示为多少K,多少M等),就意味着发出了网络请求,否则就是使用了缓存(例如from memory cache,from disk cache,from ServiceWorker)。

它们的优先级顺序是:(由上到下寻找,找到就返回;没有找到就继续向下寻找)

1.Service Worker

2.Memory Cache

3.Disk Cache

4.网络请求

memory cache

memory cache是内存中的缓存(与之相对应的是disk cache:硬盘上的缓存)。按照操作系统的常理,先读取内存,再读取硬盘。

几乎所有的网络请求资源都会被浏览器自动加入到memory cache中。但也正是因为网络请求的资源数量多,而内存有限,memory cache注定只能是个“短期存储”。正常情况下,浏览器的TAB关闭后该次浏览的memory cache就宣告失效(为了给其他TAB腾出位置)。而如果极端情况下(假如一个页面的缓存就占用了很多内存),那可能在TAB关闭之前,前面页面的缓存就已经失效了。

刚才提过,几乎所有的请求资源 都能进入 memory cache,这里细分一下主要有两块:

1.preloader。如果你对这个机制不太了解,这里做一个简单的介绍,详情可以参阅这篇文章

熟悉浏览器处理流程的同学们应该了解,在浏览器打开网页的过程中,会先请求 HTML 然后解析。之后如果浏览器发现了 js, css 等需要解析和执行的资源时,它会使用 CPU 资源对它们进行解析和执行。在古老的年代(大约 2007 年以前),“请求 js/css - 解析执行 - 请求下一个 js/css - 解析执行下一个 js/css” 这样的“串行”操作模式在每次打开页面之前进行着。很明显在解析执行的时候,网络请求是空闲的,这就有了发挥的空间:我们能不能一边解析执行 js/css,一边去请求下一个(或下一批)资源呢?

这就是 preloader 要做的事情。不过 preloader 没有一个官方标准,所以每个浏览器的处理都略有区别。例如有些浏览器还会下载 css 中的 @import内容或者 <video> 的 poster等。

而这些被 preloader 请求够来的资源就会被放入 memory cache 中,供之后的解析执行操作使用。

2.preload (虽然看上去和刚才的 preloader 就差了俩字母)。实际上这个大家应该更加熟悉一些,例如 <link rel="preload">。这些显式指定的预加载资源,也会被放入 memory cache 中。

memory cache机制保证了一个页面中如果有两个相同的请求(例如两个相同的scr、相同的<img>、两个href相同的<link>),实际上最多被请求一次,避免了资源浪费。

不过在匹配缓存时,除了匹配完全相同的URL之外,还会对他们的类型,CORS中的域名规则等。因此作为一个脚本(script)类型被缓存的资源是不能用在图片(image)类型的请求中的,即使它们的src相等。

在从 memory cache 获取缓存内容时,浏览器会忽视例如 max-age=0no-cache 等头部配置。例如页面上存在几个相同 src 的图片,即便它们可能被设置为不缓存,但依然会从 memory cache 中读取。这是因为 memory cache 只是短期使用,大部分情况生命周期只有一次浏览而已。而 max-age=0 在语义上普遍被解读为“不要在下次浏览时使用”,所以和 memory cache 并不冲突。

但如果站长是真心不想让一个资源进入缓存,就连短期也不行,那就需要使用 no-store。存在这个头部配置的话,即便是 memory cache 也不会存储,自然也不会从中读取了。(后面的第二个示例有关于这点的体现)

disk cache

disk cache也叫HTTP cache,顾名思义是存储在硬盘上的缓存,因此它是持久存储的,实际上是存在于文件系统中的。而且它允许相同的资源在跨回话,甚至跨站点的情况下使用,例如两个站点都是用了同一张图片。

disk cache会严格根据HTTP头信息中的各类字段累判定哪些资源可以缓存,哪些资源不能缓存;哪些资源是可用的,哪些资源是过期需要重新请求的。当命中缓存之后浏览器会从硬盘中读取资源,虽然比从内存中读取慢了一些,但比起网络请求来说还是快了不少。绝大部分的缓存都来自disk cache。

关于HTTP的协议头中的字段,我们会在稍后进详细讨论。

凡是持久性存储都会面临容量增长问题,disk cache也不例外。在浏览器自动清理时,会有神秘的算法把“最老的”或者“最有可能过期的”资源删除,因此是一个一个删除的。不过每个浏览器识别“最老的”和“最可能过期的”资源的算法不尽相同,这也是他们差异性的体现。

Service Worker

上述的缓存策略以及缓存读取/失效的动作都是由浏览器内部判断或执行的,我们只能设置响应头的某些字段来告诉浏览器,但不能自己操作。举个例子,我们去银行存钱/取钱,你只能告诉银行职员,我要存/取多少,然后由他们经过一系列的手续之后,把钱放到金库中,或者从金库中取出多少钱来给你。

但是Service Worker的出现,给予了我们另外一种更加灵活,更加直接的操作方式。依然以存钱/取钱为例,我们可以绕开银行职员,自己走到ATM机前(绕开银行职员),自己把钱取出来或者放进去。因此我们可以自己决定存哪些钱(缓存哪些文件),什么情况下把钱取出来(路由匹配规则),取出那些钱(缓存匹配并返回)。

Service Worker能够操作的缓存使有别于浏览器内部的memory cache 或者disk cache 的。我们可以从Chrome 的F12中,Application ->Cache Storagez找到这个单独的“小金库”。除了位置不同之外,这个缓存是永久性的,即使关闭TAB或者浏览器,下次打开依然还是(memory cache是关闭就别清理的)。有两种情况会导致这个缓存中的资源被清除:手动调用API cache.delete(resource)或者容量超过限制,累浏览器全部清空。

如果Service Worker没有命中缓存,一般情况下会使用fetch()方法继续获取资源。这时候,浏览器就会去memory cache 或者disk cache 进行下一次找缓存的工作了。注意:经过Service Worker 的fetch()方法获取的资源,即使它没有命中Service Worker缓存,甚至实际走了网络请求,也会被标注为from ServiceWorker 。这个情况在后面的三个实例中有所体现。

请求网络

如果一个请求在上述3个位置都没有找到缓存,那么浏览器会正式发送网络请求去获取内容。之后容易想到,为了提升之后请求的缓存的命中率,自然要把这个资源添加到缓存中去。具体来说:
1.根据Service Worker 中的handler决定是否存入Cache Storage(额外的缓存位置)。

2.根据HTTP头部的相关字段(Cache-controal,pragma等)决定是否存入disk cache。

3.memory cache 保存一份资源的引用,以备下次使用。

第二部分:按失效策略分类

memory cache 是浏览器为了加快读取缓存速度而进行的自身优化行为,不收开发者控制,也不受HTTP协议头的约束,算是一个黑盒。Service Worker是由开发者编写的额外脚本,且缓存位置独立,出现也较晚,使用还不是太广泛。

所以我们平时最为熟悉的其实是disk cache ,也就是常说的HTTP cache(因为不像memory cache,它遵循HTTP协议头中的字段)。平时所说的强制缓存,对比缓存(也叫协商缓存),以及Cache-Control等,也都归于此类。

强制缓存(也叫强缓存)

强缓存的含义是:当客户端请求后,会先方位缓存数据库看缓存是否存在。如果存在则直接返回;如果不存在则请求真的服务器,响应后再写入缓存数据库。

强制缓存是直接减少请求树,是提升最大的缓存策略。它的优化覆盖了文章开头提到过的请求数据的全部三个步骤。如果考虑使用缓存来优化网页性能的话,强制缓存应该是首先被考虑的。

可以造成强制缓存的字段是Cache-control 和Expires。

Expires

这是HTTP1.0的字段,表示缓存到期时间,是一个绝对的事件(当前时间+缓存时间),如

Expires: Thu, 10 Nov 2017 08:45:11 GMT

在响应消息头中,设置这个字段之后,就可以高速浏览器,在未过期之前不需要请求。

但是,这个字段有两个缺点:

1.由于是绝对时间,客户可能会将客户端本地的时间进行修改,而导致浏览器判断缓存失效,重新请求该资源。此外,即使不考虑自行修改,时差或者误差等因素也可能会造成客户端与服务端事件不一致,导致缓存失效。

2.写法太复杂了。表示时间的字符串多个空格、少个字母,都会导致非法属性从而设置失败。

Cache-control

已知Expires的缺点之后,在HTTP/1.1中,增加了一个字段Cache-control,该字段表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求。

这两者的区别就是前者是绝对时间,而后者是相对时间。写法如下:
 

Cache-control: max-age=2592000

下面列举一些Cache-control字段常用的值(完整的可以查看MDN):

1.max-age:即最大有效时间,上面的例子就是使用这个字段。

2.must-revalidate:如果超过了max-age的时间,浏览器必须向服务器发送请求,验证资源是否有效。

3.no-cache:虽然字面意思是“不要缓存”,但实际上还是要求客户端缓存内容的,只是是否使用这个内容由或许的对比决定。

4.no-store:真正意义上的“不要缓存”。所有内容都不缓存,包括强制缓存和对比缓存。

5.public:所有的内容都可以被缓存(包括客户顿和代理服务器,如CDN)。

6.private:所有内容只有客户顿才可以缓存,代理服务器不能缓存。默认值。

这些值可以混合使用,例如Cache-control,max-age=2592000。在混合使用时,他们的优先级如下:

这里有一个疑问:max-age和no-cache等价吗?从规范的字面意义来看,max-age到期是应该(SHOULD)重新验证,而no-cache是必须(MUST)重新验证。但实际情况以浏览器实现为准,大部分情况它们俩的行为还是一致的。如果是max-age= 0,must-revalidate就和no-cache等价了。

顺带一提,在HTTP/1.1之前,如果想用no-cache,通常是使用Pragma字段,如Pragma:no-cache(这也是Pragma字段唯一的取值)。但是这个字段是浏览器约定俗成的实现,没有确切规范,因此缺乏可靠性。它应该只作为一个兼容字段出现,在当前的网络环境下其实作用已经很小。

总结一下,自HTTP/1.1开始,Expires逐渐被Cache-control取代。Cache-control是一个相对时间,即使客户端时间发生改变,相对时间也不会发生改变,这样可以保持客户端和服务器时间的一致性。而且Cache-control的可配置性也比较强大。

Cache-control的优先级高于Expires,为了兼容HTTP/1.0和HTTP/1.1,实际项目中两个字段我们都会设置。

对比缓存(也叫协商缓存)

当强制缓存失效(超过规定时间)时,就需要使用对比缓存,由服务器决定缓存是够失效。

流程上说,浏览器先请求缓存数据库,返回一个缓存标识。之后浏览器拿这个标识和服务器通讯。如果缓存未失效,则返回 HTTP 状态码 304 表示继续使用,于是客户端继续使用缓存;如果失效,则返回新的数据和缓存规则,浏览器响应数据后,再把规则写入到缓存数据库。

对比缓存在请求数上和没有缓存是一致的,但如果是 304 的话,返回的仅仅是一个状态码而已,并没有实际的文件内容,因此 在响应体体积上的节省是它的优化点。它的优化覆盖了文章开头提到过的请求数据的三个步骤中的最后一个:“响应”。通过减少响应体体积,来缩短网络传输时间。所以和强制缓存相比提升幅度较小,但总比没有缓存好。

对比缓存是可以和强制缓存一起使用的,作为在强制缓存失效后的一种后备方案。实际项目中他们也的确经常一同出现。

对比缓存由两组字段(不是两个字段):

last-Modified & If-Modified-Since

1.服务器通过Last-Modified 字段告知客户端,资源最后一次被修改的时间,例如

Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT

2.浏览器将这个值和内容一起记录在缓存数据中。

3.下一次请求相同资源时,浏览器从自己的缓存中找出“不确定是否过期”的缓存。因此在请求头中将上次的Last-modified的值写入到请求头的If-Modified-Since字段。

4.服务器会将If-modified-Since的值与Last-Modified字段进行对比。如果相等,则表示未修改,响应304;繁殖,则表示修改了,响应200状态码,并返回数据。

但它还是有一定缺陷的:

如果资源更新的速度是秒以下的单位,那么该缓存是不能被使用的,因为它的时间单位最低时秒。

如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,但是起不到缓存作用。

Etag & If-None-Match

为了解决上述问题,出现了一组新的字段Etag和If-None-Match

Etag存储的是文件的特殊标识(一般都是hash自动生成的),服务器存储着文件的Etag字段。之后的流程和Last-Modified一致,只是Last-Modified字段和它表示的更新时间变成了Etag字段和它所表示的文件hash,把If-Modified-Since变成了If-None-Match。服务器同样进行比较,命中返回304,没有命中返回新资源和200.

Etag 的优先级高于Last-Modified

缓存小结

当浏览器需要请求资源使

1.调用Sevice Worker的 fetch事件响应。

2.查看memory cache。

3.查看disk cache。这里又细分:

1)如果有强制缓存且未失效,则使用强制缓存,不需要请求服务器。这是的状态码全部是200。

2)如果有强制缓存但已失效,使用对比缓存,比较厚确定304还是200

4.发送网络请求,等待网络响应,

5.把响应内容存到disk cachea(如果有HTTP头信息配置可以存的话)。

6.把响应内容的引用存入memory cache(无视HTTP头信息的配置)中。

7.把响应内容存入Service Worker 的 Cache Storage(如果Service Worker的脚本资料调用了cache.put).

第三部分:分享一些帮助理我们解原理的案例

光看原理不免枯燥。我们编写一些简单的网页,通过案例来深刻理解上面那些原理。

1.memory cache & disk cache

我们写一个简单的 index.html,然后引用 3 种资源,分别是 index.jsindex.css 和 mashroom.jpg

我们给这三种资源都设置上 Cache-control: max-age=86400,表示强制缓存 24 小时。以下截图全部使用 Chrome 的隐身模式。

1.首次请求

因为是首次请求,没有任何缓存,因此全部是网络请求。

2.第二次请求

第二次请求,三个请求全部来自memory cache。因为我们没有关闭TAB,所以浏览器把缓存的应用加到了memory cache。(耗时0ms,也就是在1ms之内)

3.关闭TAB,打开新的TAB并在此请求

因为关闭了TAB,memory cache也随之清空。但是disk cache是持久的,于是所有资源来自disk cache(大约耗时3ms,因为文件很小)。

对比2和3,很明显看到memory cache还是比disk cache快得多。

2.no-cache & no-store

我们在index.html里面写一些代码,完成两个目标:

1)每种资源都(同步)请求两次

2)增加脚本异步请求图片

<!-- 把3种资源都改成请求两次 -->
<link rel="stylesheet" href="/static/index.css">
<link rel="stylesheet" href="/static/index.css">
<script src="/static/index.js"></script>
<script src="/static/index.js"></script>
<img src="/static/mashroom.jpg">
<img src="/static/mashroom.jpg">

<!-- 异步请求图片 -->
<script>
    setTimeout(function () {
        let img = document.createElement('img')
        img.src = '/static/mashroom.jpg'
        document.body.appendChild(img)
    }, 1000)
</script>

1.当我们把服务器响应设置为Cache-Control:no-cache时,我们发现打开页面之后,三组资源都只被请求一次。

从图中说明

1)如之前原理所述,虽然memory cache 是无视HTTP头信息的,但是no-store是特别的。在这个设置下,memory cache也不得不每次都请求资源。

2)异步请求和同步遵循相同的规则,在no-store情况下,依然是每次都发送请求,不进行任何缓存。

3. Service Worker & memory (disk) cache

我们尝试把 Service Worker 也加入进去。我们编写一个 serviceWorker.js,并编写如下内容:(主要是预缓存 3 个资源,并在实际请求时匹配缓存并返回)

// serviceWorker.js
self.addEventListener('install', e => {
  // 当确定要访问某些资源时,提前请求并添加到缓存中。
  // 这个模式叫做“预缓存”
  e.waitUntil(
    caches.open('service-worker-test-precache').then(cache => {
      return cache.addAll(['/static/index.js', '/static/index.css', '/static/mashroom.jpg'])
    })
  )
})

self.addEventListener('fetch', e => {
  // 缓存中能找到就返回,找不到就网络请求,之后再写入缓存并返回。
  // 这个称为 CacheFirst 的缓存策略。
  return e.respondWith(
    caches.open('service-worker-test-precache').then(cache => {
      return cache.match(e.request).then(matchedResponse => {
        return matchedResponse || fetch(e.request).then(fetchedResponse => {
          cache.put(e.request, fetchedResponse.clone())
          return fetchedResponse
        })
      })
    })
  )
})

注册 SW 的代码这里就不赘述了。此外我们还给服务器设置 Cache-Control: max-age=86400 来开启 disk cache。我们的目的是看看两者的优先级。

浏览器的行为

所谓浏览器的行为,指的是用户在浏览器如何操作时,会触发怎样的缓存策略。主要有三种:

1)打开网页,地址栏输入地址:查找disk cache 中是否有匹配。如果有则使用,如没有则发送网络请求。

2)普通刷新(F5):因为TAB并没有关闭,因此memory cache 是可以使用的,会被优先使用(如果匹配到的话)。其次才是disk cache。

3)强制刷新(Ctrl+F5):浏览器不用缓存,因此发送的请求头部均带有Cache-control: no-cache(为了兼容,还带了 Pragma: no-cache)。服务器直接返回 200 和最新内容。

了解了缓存的原理,我们可能更加关心如何在实际项目中使用它们,才能更好的让用户缩短加载时间,节约流量等。这里有几个常用的模式,供大家参考

模式 1:不常变化的资源

Cache-Control: max-age=31536000

通常在处理这类资源资源时,给它们的 Cache-Control 配置一个很大的 max-age=31536000 (一年),这样浏览器之后请求相同的 URL 会命中强制缓存。而为了解决更新的问题,就需要在文件名(或者路径)中添加 hash, 版本号等动态字符,之后更改动态字符,达到更改引用 URL 的目的,从而让之前的强制缓存失效 (其实并未立即失效,只是不再使用了而已)。

在线提供的类库 (如 jquery-3.3.1.min.js, lodash.min.js 等) 均采用这个模式。如果配置中还增加 public 的话,CDN 也可以缓存起来,效果拔群。

这个模式的一个变体是在引用 URL 后面添加参数 (例如 ?v=xxx 或者 ?_=xxx),这样就不必在文件名或者路径中包含动态参数,满足某些完美主义者的喜好。在项目每次构建时,更新额外的参数 (例如设置为构建时的当前时间),则能保证每次构建后总能让浏览器请求最新的内容。

特别注意: 在处理 Service Worker 时,对待 sw-register.js(注册 Service Worker) 和 serviceWorker.js (Service Worker 本身) 需要格外的谨慎。如果这两个文件也使用这种模式,你必须多多考虑日后可能的更新及对策。

模式 2:经常变化的资源

Cache-Control: no-cache

这里的资源不单单指静态资源,也可能是网页资源,例如博客文章。这类资源的特点是:URL 不能变化,但内容可以(且经常)变化。我们可以设置 Cache-Control: no-cache 来迫使浏览器每次请求都必须找服务器验证资源是否有效。

既然提到了验证,就必须 ETag 或者 Last-Modified 出场。这些字段都会由专门处理静态资源的常用类库(例如 koa-static)自动添加,无需开发者过多关心。

也正如上文中提到协商缓存那样,这种模式下,节省的并不是请求数,而是请求体的大小。所以它的优化效果不如模式 1 来的显著。

模式 3:非常危险的模式 1 和 2 的结合 (反例)

Cache-Control: max-age=600, must-revalidate

不知道是否有开发者从模式 1 和 2 获得一些启发:模式 2 中,设置了 no-cache,相当于 max-age=0, must-revalidate。我的应用时效性没有那么强,但又不想做过于长久的强制缓存,我能不能配置例如 max-age=600, must-revalidate 这样折中的设置呢?

表面上看这很美好:资源可以缓存 10 分钟,10 分钟内读取缓存,10 分钟后和服务器进行一次验证,集两种模式之大成,但实际线上暗存风险。因为上面提过,浏览器的缓存有自动清理机制,开发者并不能控制。

举个例子:当我们有 3 种资源: index.htmlindex.jsindex.css。我们对这 3 者进行上述配置之后,假设在某次访问时,index.js 已经被缓存清理而不存在,但 index.htmlindex.css 仍然存在于缓存中。这时候浏览器会向服务器请求新的 index.js,然后配上老的 index.htmlindex.css 展现给用户。这其中的风险显而易见:不同版本的资源组合在一起,报错是极有可能的结局。

除了自动清理引发问题,不同资源的请求时间不同也能导致问题。例如 A 页面请求的是 A.js 和 all.css,而 B 页面是 B.js 和 all.css。如果我们以 A -> B 的顺序访问页面,势必导致 all.css 的缓存时间早于 B.js。那么以后访问 B 页面就同样存在资源版本失配的隐患。

===

有开发者朋友(wd2010)在知乎的评论区提了一个很好的问题:

如果我不使用must-revalidate,只是Cache-Control: max-age=600,浏览器缓存的自动清理机制就不会执行么?如果浏览器缓存的自动清理机制执行的话那后续的index.js被清掉的所引发的情况都是一样的呀!

这个问题涉及几个小点,我补充说明一下:

  1. 'max-age=600' 和 'max-age=600,must-revalidate' 有什么区别?

    没有区别。在列出 max-age 了之后,must-revalidate 是否列出效果相同,浏览器都会在超过 max-age 之后进行校验,验证缓存是否可用。

    在 HTTP 的规范中,只阐述了 must-revalidate 的作用,却没有阐述不列出 must-revalidate 时,浏览器应该如何解决缓存过期的问题,因此这其实是浏览器实现时的自主决策。(可能有少数浏览器选择在源站点无法访问时继续使用过期缓存,但这取决于浏览器自身)

  2. 那 'max-age=600' 是不是也会引发问题?

    是的。问题的出现和是否列出 'must-revalidate' 无关,依然会存在 JS CSS等文件版本失配的问题。因此常规的网站在不同页面需要使用不同的 JS CSS 文件时,如果要使用 max-age 做强缓存,不要设置一个太短的时间。

  3. 那这类比较短的 max-age 到底能用在哪里呢?

    既然版本存在失配的问题,那么要避开这个问题,就有两种方法。

    1. 整站都使用相同的 JS 和 CSS,即合并后的文件。这个比较适合小型站点,否则可能过于冗余,影响性能。(不过可能还是会因为浏览器自身的清理策略被清理,依然有隐患)

    2. 资源是独立使用的,并不需要和其他文件配合生效。例如 RSS 就归在此类。

 

目录

第一部分:按缓存位置分类

memory cache

disk cache

Service Worker

请求网络

第二部分:按失效策略分类

强制缓存(也叫强缓存)

对比缓存(也叫协商缓存)

缓存小结

第三部分:分享一些帮助理我们解原理的案例

3. Service Worker & memory (disk) cache

模式 1:不常变化的资源

模式 2:经常变化的资源

模式 3:非常危险的模式 1 和 2 的结合 (反例)

 


 

 

 

 

 

 

 

 

 

 

标签:缓存,浏览器,请求,Cache,前端,cache,memory,深刻理解
来源: https://blog.csdn.net/weixin_45013926/article/details/97489951