为什么要用浏览器缓存

浏览器缓存就是把一个已经请求过的资源拷贝一份存储起来,当下次需要该资源时,浏览器会根据缓存机制决定直接使用缓存资源还是再次向服务器发送请求。

浏览器缓存最主要的作用是减少网络传输的损耗以及降低服务器压力

浏览器缓存之memory cache 和disk cache

浏览器的http缓存

打开谷歌浏览器的控制面板,进入到network界面,取消勾选Disable cache。输入网址打开一个页面,然后刷新,然后关闭标签重新打开页面,可以看到network里面有三种状态200 from memory cache200 from disk cache304 Not Modified

  • 200 from memory cache 不访问服务器,直接从内存中读取缓存(杀死进程也就是关闭浏览器后,数据将不存在)(仅用于派生资源)。

  • 200 from disk cache 不访问服务器,从磁盘中读取缓存(杀死进程时,数据仍然存在)(仅用于派生资源)。

  • 304 Not Modified 访问服务器,对比文件修改时间,没有更新则服务器返回304状态,然后从缓存读取数据。

三级缓存原理

  • 先去内存看,如果有,直接加载

  • 如果内存没有,择取硬盘获取,如果有直接加载

  • 如果硬盘也没有,那么就进行网络请求

  • 加载到的资源缓存到硬盘和内存

设置浏览器缓存

304是协商缓存还是要和服务器通信一次,要想断绝服务器通信,就要强制浏览器使用本地缓存(cache-control/expires),一般有如下几种方式设置浏览器缓存。

1、通过HTTP的META设置expires和cache-control

 <meta http-equiv="Cache-Control" content="max-age=7200" />
 <meta http-equiv="Expires" content="Sun Oct 15 2019 20:39:53 GMT+0800 (CST)" />

这样写的话仅对该网页有效,对网页中的图片或其他请求无效。

2、服务器配置图片,css,js,flash的缓存

因为涉及到后端配置,这里就不做展开了。

HTTP Header中与缓存有关的字段

1、HTTP中缓存相关的key

key 说明
Cache-Control 指定缓存机制,覆盖其它设置
Pragma http1.0字段,指定缓存机制
Expires http1.0字段,指定缓存的过期时间
Last-Modified 资源最后一次的修改时间
ETag 唯一标识请求资源的字符串

2、验证缓存资源是否有效的key

key 说明
If-Modified-Since 缓存校验字段, 值为资源最后一次的修改时间, 即上次收到的Last-Modified值
If-Unmodified-Since 同上, 处理方式与之相反
If-Match 缓存校验字段, 值为唯一标识请求资源的字符串, 即上次收到的ETag值
If-None-Match 同上, 处理方式与之相反

Cache-Control

语法为: “Cache-Control : cache-directive”

  • public 资源将被客户端和代理服务器缓存
  • private 资源仅被客户端缓存, 代理服务器不缓存
  • no-store 请求和响应都不缓存
  • no-cache 相当于max-age:0,must-revalidate即资源被缓存, 但是缓存立刻过期, 同时下次访问时强制验证资源有效性
  • max-age 缓存资源, 但是在指定时间(单位为秒)后缓存过期
  • s-maxage 同上, 依赖public设置, 覆盖max-age, 且只在代理服务器上有效
  • max-stale 指定时间内, 即使缓存过时, 资源依然有效
  • min-fresh 缓存的资源至少要保持指定时间的新鲜期
  • must-revalidation / proxy-revalidation 如果缓存失效, 强制重新向服务器(或代理)发起验证(因为max-stale等字段可能改变缓存的失效时间)
  • only-if-cached 仅仅返回已经缓存的资源, 不访问网络, 若无缓存则返回504
  • no-transform 强制要求代理服务器不要对资源进行转换, 禁止代理服务器对 Content-Encoding, Content-Range, Content-Type字段的修改(因此代理的gzip压缩将不被允许)

假设所请求资源于4月5日缓存, 且在4月12日过期。

当max-age 与 max-stale 和 min-fresh 同时使用时, 它们的设置相互之间独立生效, 最为保守的缓存策略总是有效. 这意味着, 如果max-age=10 days, max-stale=2 days, min-fresh=3 days, 那么:

根据max-age的设置, 覆盖原缓存周期, 缓存资源将在4月15日失效(5+10=15);根据max-stale的设置, 缓存过期后两天依然有效, 此时响应将返回110(Response is stale)状态码, 缓存资源将在4月14日失效(12+2=14);根据min-fresh的设置, 至少要留有3天的新鲜期, 缓存资源将在4月9日失效(12-3=9);由于客户端总是采用最保守的缓存策略, 因此, 4月9日后, 对于该资源的请求将重新向服务器发起验证。

Pragma

http1.0字段, 通常设置为Pragma:no-cache, 作用同Cache-Control:no-cache。 当一个no-cache请求发送给一个不遵循HTTP/1.1的服务器时, 客户端应该包含pragma指令。 为此, 勾选上disable cache时, 浏览器自动带上了pragma字段。pragma: no-cache

Expires

Expires:Wed, 05 Apr 2019 00:55:35 GMT设置到期时间,以服务器时间为参考系, 其优先级比 Cache-Control:max-age 低, 两者同时出现在响应头时, Expires将被后者覆盖。如果Expires, Cache-Control: max-age, 或 Cache-Control:s-maxage 都没有在响应头中出现, 并且也没有其它缓存的设置, 那么浏览器默认会采用一个启发式的算法, 通常会取响应头的Date - Last-Modified值的10%作为缓存时间。

ETag

服务器资源的唯一标识符, 浏览器可以根据ETag值缓存数据, 节省带宽。如果资源已经改变, etag可以帮助防止同步更新资源的相互覆盖。 ETag 优先级比 Last-Modified 高。

If-Match

缓存校验字段, 其值为上次收到的一个或多个etag 值。常用于判断条件是否满足, 如下两种场景:

对于 GET 或 HEAD 请求, 结合 Range 头字段, 它可以保证新范围的请求和前一个来自相同的源, 如果不匹配, 服务器将返回一个416(Range Not Satisfiable)状态码的响应。 对于 PUT 或者其他不安全的请求, If-Match 可用于阻止错误的更新操作, 如果不匹配, 服务器将返回一个412(Precondition Failed)状态码的响应。

If-None-Match

缓存校验字段, 结合ETag字段, 常用于判断缓存资源是否有效, 优先级比If-Modified-Since高。

对于 GET 或 HEAD 请求, 如果其etags列表均不匹配, 服务器将返回200状态码的响应, 反之, 将返回304(Not Modified)状态码的响应。 无论是200还是304响应, 都至少返回 Cache-Control, Content-Location, Date, ETag, Expires, and Vary 中之一的字段。 对于其他更新服务器资源的请求, 如果其etags列表匹配, 服务器将执行更新, 反之, 将返回412(Precondition Failed)状态码的响应。

Last-Modified

用于标记请求资源的最后一次修改时间, 格式为GMT(格林尼治标准时间)。

If-Modified-Since

缓存校验字段, 其值为上次响应头的Last-Modified值, 若与请求资源当前的Last-Modified值相同, 那么将返回304状态码的响应, 反之, 将返回200状态码响应。

If-Unmodified-Since

缓存校验字段, 语法同上。表示资源未修改则正常执行更新, 否则返回412(Precondition Failed)状态码的响应。 常用于如下两种场景:

不安全的请求, 比如说使用post请求更新wiki文档, 文档未修改时才执行更新。 与 If-Range 字段同时使用时, 可以用来保证新的片段请求来自一个未修改的文档。

浏览器缓存命中机制

1)浏览器在加载资源时,先根据这个资源的一些http header判断它是否命中强缓存,强缓存如果命中,浏览器直接从自己的缓存中读取资源,不会发请求到服务器。

2)当强缓存没有命中的时候,浏览器一定会发送一个请求到服务器,通过服务器端依据资源的另外一些http header验证这个资源是否命中协商缓存,如果协商缓存命中,服务器会将这个请求返回,但是不会返回这个资源的数据,而是告诉客户端可以直接从缓存中加载这个资源。

3)当协商缓存也没有命中的时候,浏览器直接从服务器加载资源数据。

1、强缓存:expires、cache-control

当浏览器对某个资源的请求命中了强缓存时,返回的HTTP状态为200,在chrome的开发者工具的network里面 size会显示为from cache。强缓存是利用Expires或者Cache-Control这两个http response header实现的,它们都用来表示资源在客户端缓存的有效期。

Expires是HTTP 1.0提出的一个表示资源过期时间的header,它描述的是一个绝对时间,由服务器返回,用GMT格式的字符串表示,如:Expires:Thu, 31 Dec 2037 23:55:55 GMT,包含了Expires头标签的文件,就说明浏览器对于该文件缓存具有非常大的控制权。

例如,一个文件的Expires值是2020年的1月1日,那么就代表,在2020年1月1日之前,浏览器都可以直接使用该文件的本地缓存文件,而不必去服务器再次请求该文件,哪怕服务器文件发生了变化。

所以,Expires是优化中最理想的情况,因为它根本不会产生请求,所以后端也就无需考虑查询快慢。它的缓存原理,如下:

  • 浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在response的header加上Expires的header,如:

  • 浏览器在接收到这个资源后,会把这个资源连同所有response header一起缓存下来(所以缓存命中的请求返回的header并不是来自服务器,而是来自之前缓存的header)

  • 浏览器再请求这个资源时,先从缓存中寻找,找到这个资源后,拿出它的Expires跟当前的请求时间比较,如果请求时间在Expires指定的时间之前,就能命中缓存,否则就不行;

  • 如果缓存没有命中,浏览器直接从服务器加载资源时,Expires Header在重新加载的时候会被更新;

Expires是较老的强缓存管理header,由于它是服务器返回的一个绝对时间,在服务器时间与客户端时间相差较大时,缓存管理容易出现问题,比如:随意修改下客户端时间,就能影响缓存命中的结果。所以在HTTP 1.1的时候,提出了一个新的header,就是Cache-Control,这是一个相对时间,在配置缓存的时候,以秒为单位,用数值表示,如:Cache-Control:max-age=315360000,它的缓存原理是:

  • 浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在response的header加上Cache-Control的header,如:

  • 浏览器在接收到这个资源后,会把这个资源连同所有response header一起缓存下来

  • 浏览器再请求这个资源时,先从缓存中寻找,找到这个资源后,根据它第一次的请求时间和Cache-Control设定的有效期,计算出一个资源过期时间,再拿这个过期时间跟当前的请求时间比较,如果请求时间在过期时间之前,就能命中缓存,否则就不行;

  • 如果缓存没有命中,浏览器直接从服务器加载资源时,Cache-Control Header在重新加载的时候会被更新;

Cache-Control描述的是一个相对时间,在进行缓存命中的时候,都是利用客户端时间进行判断,所以相比较Expires,Cache-Control的缓存管理更有效,安全一些。

这两个header可以只启用一个,也可以同时启用,当response header中,Expires和Cache-Control同时存在时,Cache-Control优先级高于Expires

此外,还可以为 Cache-Control 指定 public 或 private 标记。如果使用 private,则表示该资源仅仅属于发出请求的最终用户,这将禁止中间服务器(如代理服务器)缓存此类资源。对于包含用户个人信息的文件(如一个包含用户名的 HTML 文档),可以设置 private,一方面由于这些缓存对其他用户来说没有任何意义,另一方面用户可能不希望相关文件储存在不受信任的服务器上。

2、协商缓存:Last-Modified&Etag

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

协商缓存是利用的是【Last-Modified,If-Modified-Since】和【ETag、If-None-Match】这两对Header来管理的。

【Last-Modified,If-Modified-Since】的控制缓存的原理,如下:

  • 浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在response的header加上Last-Modified的header,这个header表示这个资源在服务器上的最后修改时间:

  • 浏览器再次跟服务器请求这个资源时,在request的header上加上If-Modified-Since的header,这个header的值就是上一次请求时返回的Last-Modified的值:

  • 服务器再次收到资源请求时,根据浏览器传过来If-Modified-Since和资源在服务器上的最后修改时间判断资源是否有变化,如果没有变化则返回304 Not Modified,但是不会返回资源内容;如果有变化,就正常返回资源内容。当服务器返回304 Not Modified的响应时,response header中不会再添加Last-Modified的header,因为既然资源没有变化,那么Last-Modified也就不会改变。

  • 浏览器收到304的响应后,就会从缓存中加载资源。

  • 如果协商缓存没有命中,浏览器直接从服务器加载资源时,Last-Modified Header在重新加载的时候会被更新,下次请求时,If-Modified-Since会启用上次返回的Last-Modified值。

【Last-Modified,If-Modified-Since】都是根据服务器时间返回的header,一般来说,在没有调整服务器时间和篡改客户端缓存的情况下,这两个header配合起来管理协商缓存是非常可靠的,但是有时候也会服务器上资源其实有变化,但是最后修改时间却没有变化的情况,而这种问题又很不容易被定位出来,而当这种情况出现的时候,就会影响协商缓存的可靠性。所以就有了另外一对header来管理协商缓存,这对header就是【ETag、If-None-Match】。它们的缓存管理的方式是:

  • 浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在response的header加上ETag的header,这个header是服务器根据当前请求的资源生成的一个唯一标识,这个唯一标识是一个字符串,只要资源有变化这个串就不同,跟最后修改时间没有关系,所以能很好的补充Last-Modified的问题:

  • 浏览器再次跟服务器请求这个资源时,在request的header上加上If-None-Match的header,这个header的值就是上一次请求时返回的ETag的值:

  • 服务器再次收到资源请求时,根据浏览器传过来If-None-Match和然后再根据资源生成一个新的ETag,如果这两个值相同就说明资源没有变化,否则就是有变化;如果没有变化则返回304 Not Modified,但是不会返回资源内容;如果有变化,就正常返回资源内容。与Last-Modified不一样的是,当服务器返回304 Not Modified的响应时,由于ETag重新生成过,response header中还会把这个ETag返回,即使这个ETag跟之前的没有变化:

Etag和Last-Modified非常相似,都是用来判断一个参数,从而决定是否启用缓存。但是ETag相对于Last-Modified也有其优势,可以更加准确的判断文件内容是否被修改,从而在实际操作中实用程度也更高。

【Last-Modified,If-Modified-Since】和【ETag、If-None-Match】一般都是同时启用,这是为了处理Last-Modified不可靠的情况。有一种场景需要注意:分布式系统里多台机器间文件的Last-Modified必须保持一致,以免负载均衡到不同机器导致比对失败;分布式系统尽量关闭掉ETag(每台机器生成的ETag都会不一样)

浏览器缓存之cookie、sessionStorage、localStorage

cookie 大小限制为4KB左右,如果不在浏览器中设置过期时间,cookie被保存在内存中,生命周期随浏览器的关闭而结束,这种cookie简称会话cookie。如果在浏览器中设置了cookie的过期时间,cookie被保存在硬盘中,关闭浏览器后,cookie数据仍然存在,直到过期时间结束才消失。Cookie是服务器发给客户端的特殊信息,cookie是以文本的方式保存在客户端,每次请求时都带上它。

sessionStorage 一般为大小限制为5MB,sessionStorage引入了一个“浏览器窗口”的概念,sessionStorage是在同源的窗口中始终存在的数据。只要这个浏览器窗口没有关闭,即使刷新页面或者进入同源另一个页面,数据依然存在。在关闭浏览器窗口后被销毁。

localStorage 一般为大小限制为5MB,永久存在除非主动删除。

浏览器缓存之Service Workers 和 Cache Storage

Service Workers主要用做持久化的离线存储。独立于主线程,处理复杂耗时运算,通过postMessage告诉主线程处理结果(持久化的web worker)。

Service Workers具有以下特点:

  • 必须在HTTPS下才能工作(http://localhost和http://127.0.0.1除外)
  • 一旦install就一直存在,除非手动unregisrer
  • 异步实现,内部通过promise实现
  • 工作在worker context上,不能操作DOM
  • localStorage是同步方法,不能在Service Workers里使用,一般使用cache API

注册service worker

// 判断浏览器是否支持serviceWorker
if('serviceWorker' in navigator){
    // 注册serviceWorker
    navigator.serviceWorker.register('/test_demo.js',{
        scope: './'
    }).then((rg)=>{
        // 注册成功
    }).catch((err)=>{
        // 注册失败
    })
}

注意:Service Worker文件的地址是相对于根路径的

Service Worker注册成功后,浏览器上已经有一个属于我们的worker context,接下来浏览器会进入/test_demo.js为站点里面的页面安装并激活Service Worker。

安装service worker

为了使用离线缓存能力,我们需要使用service worker的全局对象cache API,它会一直存在直到unregister我们的service worker。

self.addEventListener('install',(event)=>{
    // 监听serviceWorker安装成功,则调用此方法
    event.waitUntil(
        // 操作CacheStorage,使用之前先开发缓存空间
        caches.open('cache_name_v1').then((cache)=>{
            return cache.addAll([
                '/',
                'index.html',
                'main.css',
                'main.js',
            ])
        })
    )
})

捕获请求并缓存数据

每次任何被 Service Worker 控制的资源被请求到时,都会触发 fetch 事件,这些资源包括了指定的 scope 内的 html 文档,和这些 html 文档内引用的其他任何资源(比如 index.html 发起了一个跨域的请求来嵌入一个图片,这个也会通过 Service Worker)。 Service Worker其实就是凭借 scope 和 fetch 事件两大利器管理站点。

self.addEventListener('fetch',(event)=>{
    // 劫持http请求的响应
    event.respondWith(
        caches.match(event.request).then((response)=>{
            // 如果serviceWorker有返回则直接返回
            if(response){
                return response
            }

            // 如果没有返回则请求服务器
            var request = event.request.clone();
            return fetch(request).then((httpRes)=>{
                // 如果请求失败
                if(!httpRes || httpRes.status !=== 200){
                    return httpRes
                }

                // 如果请求成功,则缓存
                var responseClone = httpRes.clone();
                caches.open('cache_name_v1').then((cache)=>{
                    cache.put(event.request, responseClone)
                })
                return httpRes
            })

        })
    )
})

注意:我们可以在install的时候进行静态资源缓存,也可以通过fetch事件处理回调来代理页面请求从而实现动态资源缓存。install 的优点是第二次访问即可离线,缺点是需要将需要缓存的 URL 在编译时插入到脚本中,增加代码量和降低可维护性;fetch 的优点是无需更改编译过程,也不会产生额外的流量,缺点是需要多一次访问才能离线可用。

缓存的更新

// 安装阶段跳过等待
self.addEventListener('install',(event)=>{
    event.waitUntil(self.skipWaiting())
})

// 更新
self.addEventListener('activate',(event)=>{
    event.waitUntil(
        Promise.all([
            // 更新客户端
            self.client.claim(),
            // 清理旧版本
            caches.keys().then((cacheList)=>{
                return Promise.all(
                    cacheList.map((cacheName)=>{
                        // 如果cache版本不是最新的则删除
                        if(cacheName!=='cache_name_v1'){
                            return caches.delete(cacheName)
                        }
                    })
                )
            })
        ])
    )
})

浏览器缓存之indexedDB

indexedDB和localStorage都是本地持久化存储。indexedDB存储比较适合键值对较多的数据,而如果使用localStorage处理则每次写入写出都要字符串话和对象化,很麻烦,而indexedDB则不需要转换数据。

打开数据库

var request = window.indexedDB.open(databaseName, version);

方法接受两个参数,第一个是字符串表示数据库的名字,第二个是整数表示数据库的版本(默认为1)。

返回一个IDBRequest对象,通过error、success、upgradeneeded三种事件处理数据库的操作结果。

  • error 表示数据库打开失败
  • success 表示数据库打开成功,可以拿到数据库对象,var db; request.onsuccess = ()=>{ db = request.result; }
  • upgradeneeded 如果数据库不存在或者版本号大于数据库实际版本号,则会发生数据库升级事件(不存在则为新建数据库)。request.onupgradeneeded = (event)=>{db = event.target.result;}

新建表

如下,新建一个名字为table_test的表,且主键为id(主键可以自动生成,设置autoIncrement为true即可)。使用createIndex方法创建索引,三个参数分别为索引名称,索引所在属性,配置对象(说明该索引是否存在重复值)。

request.onupgradeneeded = (event)=>{
    db = event.target.result;
    var objectStore;

    // 如果表不存在则新建表
    if(!db.objectStoreNames.contains('table_test')){
        // 新建表
        objectStore = db.createObjectStore('table_test',{
            keyPath: 'id'
        })

        // 新建索引
        objectStore.createIndex('id', 'id', {unique: true});
        objectStore.createIndex('name', 'name');
        objectStore.createIndex('sex', 'sex');
    }
}

添加数据

新增数据是指向对象仓库写入数据记录,需要通过事务完成。

// 创建一个事务
var request = db.transaction('project', 'readwrite');

// 打开存储对象
var objectStore = transaction.objectStore('project');

// 向存储对象添加数据
var data = {id: 1, name: '张三', sex: '男'};
objectStore.add(data);

// 处理添加数据成功或失败
request.onsuccess = (event)=>{}
request.onerror = (event)=>{}

删除数据

// 删除索引为1的
var request = db.transaction('project', 'readwrite').objectStore('project').delete(1);

request.onsuccess = function (event) {
    console.log('数据删除成功');
};

编辑数据

var newData =  { id: 1, name: '李四', sex: '女' };
var request = db.transaction('project', 'readwrite').objectStore('project').put(newData);

request.onsuccess = function (event) {
    console.log('数据更新成功');
};

获取数据

1、获取一条数据

// objectStore.get读取数据,参数是主键的值
var request = db.transaction('project').transaction.objectStore('project').get(1);

request.onsuccess = (event)=>{
    if(request.result){
        console.log(request.result.id)
        console.log(request.result.name)
        console.log(request.result.sex)
    }
}

2、获取全部数据

var objectStore = db.transaction('project').objectStore('project');
objectStore.openCursor().onsuccess = (event)=>{
    var cursor = event.target.result;
    if(cursor){
        console.log(cursor.value.id)
        console.log(cursor.value.name)
        console.log(cursor.value.sex)
        cursor.continue();
    }
}

使用索引

索引的意义在于,可以让你搜索任意字段,也就是说从任意字段拿到数据记录。如果不建立索引,默认只能搜索主键(即从主键取值)。

var request = db.transaction('project', 'readonly').objectStore('project').index('name').get('李四');

request.onsuccess = (e)=>{
    var res = e.target.result;
    if(res){
        console.log(res)
    }
}

浏览器缓存问题解答

主资源和派生资源

**什么是派生资源?**Webkit资源分成两类,一类是主资源,比如HTML页面,或者下载项,一类是派生资源,比如HTML页面中内嵌的图片或者脚本、样式表链接。

主资源和派生资源有很大的不同。对于下载失败的处理,主资源会有报错提示,而派生资源如一张图片,可能就是图不显示或者显示代替说明文字而已,不向用户报错。对于加载的处理,主资源是立刻发起的,而派生资源则可能会为了优化网络,在队列中等待。对于缓存的处理,主资源目前是没有缓存的,而派生资源是有缓存机制的。

强缓存、协商缓存、启发式缓存

  • 强缓存 cache-control和expires设置的缓存时间,第一次请求后,文件被缓存在本地,其中cache-control优先级高于expires,返回状态码 200 OK (from disk cache)或者 200 OK (from memory cache)。
  • 协商缓存 http请求的response header中有 last-modified和Etag,浏览器再次请求该资源时候,会在 request header中携带If-none-match(等于上次Etag的值)和If-modifed-since(等于上次last-modified的值)向服务发送请求,服务器对比If-none-match和Etag,If-modified-since和last-modified的值,如果一致,则返回304状态码,body为空。如果不一致,则返回200状态码,body为文件内容。其中Etag的优先级高于last-modified

  • 启发式缓存 如果没有cache-control和expires的缓存设置,浏览器依然会有一个本地缓存策略,就是启发式缓存。它利用http response header中的 Date(当前时间) 和 Last-Modified 的值进行计算,其计算方法如下:本地缓存时间 = (Date - Last-Modified) * 10%。因此,启发式缓存的缓存时间可长可短,建议明确设置缓存时间

突破本地离线存储大小限制

本地存储存在大小限制,如localStorage限制大小为5M,我们可以使用localforage突破限制。

参考:

[1] 浏览器缓存机制

[2] 彻底理解浏览器的缓存机制

[3] 浏览器缓存机制剖析

[4] IndexedDB:浏览器端数据库

[5] 前端优化:浏览器缓存技术介绍

Author:tenado
CeateTime:2019-05-08
Link:https://www.kelede.win/posts/%E6%B5%8F%E8%A7%88%E5%99%A8%E7%BC%93%E5%AD%98%E6%9C%BA%E5%88%B6/
License:本站博文无特别声明均为原创,转载请保留原文链接及作者
Previous:Vue实现SSR效果 Next:深入了解promise