前端缓存

1,382 阅读18分钟
image.png

缓存介绍

什么是缓存?

当我们第一次访问网站的时候,电脑会把网站上的图片和数据下载到电脑上,当我们再次访问该网站的时候,网站就会从电脑中直接加载出来,这就是缓存。

前端缓存/后端缓存

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

后端缓存主要集中于“处理”步骤,通过保留数据库连接,存储处理结果等方式缩短处理时间,尽快进入“响应”步骤。本文暂不讨论。

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

缓存有哪些好处?

  • 减少不必要的网络传输,节约宽带(就是省钱)
  • 更快的加载页面(就是加速)
  • 减少服务器负载,避免服务器过载的情况出现。(就是减载)

再说下缺点:

  • 占内存(有些缓存会被缓存到内存中)

网络方面的缓存分为三块:HTTP缓存、DNS缓存、CDN缓存,接下来主要介绍 HTTP 缓存,其余的会在文末进行补充。

还有本地的就是:浏览器的 本地存储 和 离线存储 ,更快提高加载速度,让页面飞起。

HTTP 缓存

http 缓存又分为两种:强制缓存 和 协商缓存。

http缓存流程图:

强制缓存

强制缓存,我们简称强缓存

从强缓存的角度出发,如果浏览器判断请求的目标资源有效命中强缓存,则可以直接从内存中读取资源,无需与服务器做任何通讯。

基于 Expires 字段实现的强缓存

在以前,我们通常会使用响应头的 Expires 字段去实现强缓存。如下图。

Expires 字段的作用是,设定一个强缓存时间。在此时间范围内,则从内存(memory cache)或硬盘(disk cache)中读取缓存并返回。

比如说将某一资源设置响应头为:Expires:new Date("2022-10-18 23:59:59")。

那么,该资源在2022-10-18 23:59:59 之前,都会去本地的内存(memory cache)或硬盘(disk cache)中读取,不会去服务器请求。

但是 Expires 已经被废弃了。对于强缓存来说,Expires已经不是实现强缓存的首选。

因为Expires判断强缓存是否过期的机制是:获取本地时间戳,并对先前拿到的资源文件中的Expires字段的时间做比较。来判断是否需要对服务器发起请求。这里有一个巨大的漏洞:“如果我本地时间不准咋办?

是的,Expires过度依赖本地时间,如果本地与服务器时间不同步,就会出现资源无法被缓存或者资源永远被缓存的情况。所以,Expires字段几乎不被使用了。现在的项目中,我们并不推荐使用Expires,强缓存功能通常使用Cache-Control字段来代替Expires字段。

基于 Cache-Control 实现的强缓存(代替 Expires 的强缓存实现方法)

Cache-Control这个字段在http1.1中被增加,Cache-Control完美解决了Expires本地时间和服务器时间不同步的问题。是当下的项目中实现强缓存的最常规方法。

Cache-Control的使用方法页很简单,只要在资源的响应头写上需要缓存多久就好了,单位是秒。

// 往响应头中写入需要缓存的时间
  res.setHeader('Cache-Control', 'max-age=10');

下图的意思就是,从该资源第一次返回的时候开始,往后的10秒钟内如果该资源被再次请求,则从缓存中读取。

Cache-Control:max-age=N,N就是需要缓存的秒数。从第一次请求资源的时候开始,往后N秒内,资源若再次请求,则直接从磁盘(或内存中读取),不与服务器做任何交互。

Cache-Control中因为max-age后面的值是一个滑动时间,从服务器第一次返回该资源时开始倒计时。所以也就不需要比对客户端和服务端的时间,解决了Expires所存在的巨大漏洞。

Cache-Control有max-age、s-maxage、no-cache、no-store、private、public这六个属性。

  • max-age: 决定客户端资源被缓存多久。
  • s-maxage: 决定代理服务器缓存的时长。
  • no-cache: 表示是强制进行协商缓存。
  • no-store: 是表示禁止任何缓存策略。
  • public: 表示资源即可以被浏览器缓存也可以被代理服务器缓存。
  • private: 表示资源只能被浏览器缓存。

no-cache 和 no-store

no_cache是Cache-Control的一个属性。它并不像字面意思一样禁止缓存,实际上,no-cache的意思是强制进行协商缓存。如果某一资源的Cache-Control中设置了no-cache,那么该资源会直接跳过强缓存的校验,直接去服务器进行协商缓存。而no-store就是禁止所有的缓存策略了。

注意,no-cache和no-store是一组互斥属性,这两个属性不能同时出现在Cache-Control中。

public 和 private

一般请求是从客户端直接发送到服务端,如下↓

但有些情况下是例外的:比如,出现代理服务器,如下↓

publicprivate就是决定资源是否可以在代理服务器进行缓存的属性。

其中,public表示资源在客户端和代理服务器都可以被缓存。

private则表示资源只能在客户端被缓存,拒绝资源在代理服务器缓存。

如果这两个属性值都没有被设置,则默认为private

注意,publicprivate也是一组互斥属性。他们两个不能同时出现在响应头的Cache-Control字段中。

max-age 和 s-maxage

max-age表示的时间资源在客户端缓存的时长,而s-maxage表示的是资源在代理服务器可以缓存的时长。

在一般的项目架构中max-age就够用。

s-maxage因为是代理服务端的缓存时长,他必须和上面说的public属性一起使用(public属性表示资源可以在代理服务器中缓存)。

注意,max-ages-maxage并不互斥。他们可以一起使用。

那么,Cache-Control如何设置多个值呢?用逗号分割,如下↓

Cache-Control:max-age=10000,s-maxage=200000,public

强制缓存就是以上这两种方法了。现在我们回过头来聊聊, Expires 难道就一点用都没有了吗?也不是,虽然 Cache-Control是Expires 的完全替代品,但是如果要考虑向下兼容的话,在 Cache-Control 不支持的时候,还是要使用 Expires ,这也是我们当前使用的这个属性的唯一理由。

协商缓存

基于 last-modified 的协商缓存

基于last-modified的协商缓存实现方式是:

  1. 首先需要在服务器端读出文件修改时间,
  2. 将读出来的修改时间赋给响应头的last-modified字段。
  3. 最后设置Cache-Control:no-cache

第一行,读出修改时间。

第二行,给该资源响应头的last-modified字段赋值修改时间

第三行,给该资源响应头的Cache-Control字段值设置为:no-cache.(上文有介绍,Cache-Control:no-cache的意思是跳过强缓存校验,直接进行协商缓存。)

当客户端读取到last-modified的时候,会在下次的请求标头中携带一个字段:If-Modified-Since。

而这个请求头中的If-Modified-Since就是服务器第一次修改时候给他的时间,也就是上图中的

那么之后每次对该资源的请求,都会带上If-Modified-Since这个字段,而务端就需要拿到这个时间并再次读取该资源的修改时间,让他们两个做一个比对来决定是读取缓存还是返回新的资源。

这样,就是基于last-modified协商缓存的所有操作了。

使用以上方式的协商缓存已经存在两个非常明显的漏洞。这两个漏洞都是基于文件是通过比较修改时间来判断是否更改而产生的。

1.因为是更具文件修改时间来判断的,所以,在文件内容本身不修改的情况下,依然有可能更新文件修改时间(比如修改文件名再改回来),这样,就有可能文件内容明明没有修改,但是缓存依然失效了。

2.当文件在极短时间内完成修改的时候(比如几百毫秒)。因为文件修改时间记录的最小单位是秒,所以,如果文件在几百毫秒内完成修改的话,文件修改时间不会改变,这样,即使文件内容修改了,依然不会 返回新的文件。

为了解决上述的这两个问题。从http1.1开始新增了一个头信息,Etag (Entity 实体标签)

基于 Etag 的协商缓存

Etag 就是将原先协商缓存的比较时间戳的形式修改成了比较文件指纹

文件指纹:根据文件内容计算出的唯一哈希值。文件内容一旦改变则指纹改变。

1.第一次请求某资源的时候,服务端读取文件并计算出文件指纹,将文件指纹放在响应头的Etag 字段中跟资源一起返回给客户端。

2.第二次请求某资源的时候,客户端自动从缓存中读取出上一次服务端返回的Etag 也就是文件指纹。并赋给请求头的if-None-Match字段,让上一次的文件指纹跟随请求一起回到服务端。

3.服务端拿到请求头中的if-None-Match字段值(也就是上一次的文件指纹),并再次读取目标资源并生成文件指纹,两个指纹做对比。如果两个文件指纹完全吻合,说明文件没有被改变,则直接返回304状态码和一个空的响应体并return。如果两个文件指纹不吻合,则说明文件被更改,那么将新的文件指纹重新存储到响应头的Etag 中并返回给客户端

Etag 的缺点

  • Etag 需要计算文件指纹这样意味着,服务端需要更多的计算开销。如果文件尺寸大,数量多,并且计算频繁,那么Etag 的计算就会影响服务器的性能。显然,Etag 在这样的场景下就不是很适合。
  • Etag 有强验证和弱验证,所谓将强验证,Etag 生成的哈希码深入到每个字节。哪怕文件中只有一个字节改变了,也会生成不同的哈希值,它可以保证文件内容绝对的不变。但是,强验证非常消耗计算量。Etag 还有一个弱验证,弱验证是提取文件的部分属性来生成哈希值。因为不必精确到每个字节,所以他的整体速度会比强验证快,但是准确率不高。会降低协商缓存的有效性。
  • Etag 感知文件精准度要高于 Last-Modified
  • 同时使用时,服务器校验优先级 Etag /If-None-Match
  • Last-Modified 性能上要优于 Etag ,因为 Etag 生成过程中需要服务器付出额外开销,会影响服务器端的性能,所以它并不能完全替代 Last-Modified,只能作为补充和强化

强缓存和协商缓存的区别

  • 优先查找强缓存,没有命中再查找协商缓存
  • 强缓存状态码是200,协商缓存是304
  • 强缓存不发请求到服务器,所以有时候资源更新了浏览器还不知道,但是协商缓存会发请求到服务器,资源是否有更新,服务器肯定知道
  • 大部分web服务器都默认开启协商缓存。
  • 目前项目大多数使用缓存方案 :
  1. 协商缓存一般存储:HTML
  2. 强缓存一般存储:css, image, js,文件名带上 hash

webpack 常用的三种 hash

  • hash:跟整个项目的构建相关,只要项目里有文件更改,整个项目构建的hash值都会更改,并且全部文件都共用相同的hash值。
  • chunkhash :不同的入口文件进行依赖文件解析、构建对应的 chunk ,生成对应的哈希值,文件本身修改或者依赖文件修改, chunkhash 值会变化。(js 适用)
  • contenthash:每个文件自己单独的 hash 值,文件的改动只会影响自身的 hash 值。(css和图片等适用)

刷新对于强缓存和协商缓存的影响

  • 当ctrl+f5强制刷新网页时,直接从服务器加载,跳过强缓存和协商缓存。
  • 当f5刷新网页时,跳过强缓存,但是会检查协商缓存。
  • 浏览器地址栏中写入URL,回车 浏览器发现缓存中有这个文件了,不用继续请求了,直接去缓存拿。(最快)

DNS 缓存

进入页面的时候会进行DNS查询,找到域名对应的服务器的IP地址,再发送请求

网上流程图很多,我从中借鉴了两张

DNS域名查找先在客户端进行递归查询,如图

在任何一步找到就会结束查找流程,而整个过程客户端只发出一次查询请求

如果都没有找到,就会走DNS服务器设置的转发器,如果没设置转发模式,则向13根发起解析请求,这里就是迭代查询,如图

13根:

全球共有13个根域服务器IP地址,不是13台服务器!

因为借助任播技术,可以在全球设立这些IP的镜像站点,所以访问的不是唯一的那台主机

很明显,整个过程会发出多次查询请求

在第一次进入页面后就会把DNS解析的地址记录缓存在客户端,之后再进的话至少不需要发起后面的迭代查询了,从而速度更快

CDN 缓存

当我们发送一个请求时,浏览器本地缓存失效的情况下,CDN会帮我们去计算哪得到这些内容的路径短而且快。

比如在广州请求广州的服务器就比请求新疆的服务器响应速度快得多,然后向最近的CDN节点请求数据

CDN会判断缓存数据是否过期,如果没有过期,则直接将缓存数据返回给客户端,从而加快了响应速度。如果CDN判断缓存过期,就会向服务器发出回源请求,从服务器拉取最新数据,更新本地缓存,并将最新数据返回给客户端。

CDN不仅解决了跨运营商和跨地域访问的问题,大大降低访问延时的同时,还起到了分流的作用,减轻了源服务器的负载

本地存储

Cookie

最早被提出来的本地存储方式,在每一次 http 请求携带 Cookie,可以判断多个请求是不是同一个用户发起的,特点是:

  • 有安全问题,如果被拦截,就可以获得 Session 所有信息,然后将 Cookie 转发就能达到目的。
  • 每个域名下的Cookie不能超过20个,大小不能超过4kb
  • Cookie在请求新页面的时候都会被发送过去
  • Cookie创建成功名称就不能修改
  • 跨域名不能共享Cookie

如果要跨域名共享Cookie有两个方法

  • 用 Nginx 反向代理
  • 在一个站点登录之后,往其他网站写 Cookie。服务端的 Session 存储到一个节点,Cookie 存储 SessionId

LocalStorage

是H5的新特性,是将信息存储到本地,它的存储大小比 Cookie 大得多,有5M,而且是永久存储,除非主动清理,不然会一直存在

受到同源策略限制,就是端口、协议、主机地址有任何一样不同都不能访问,还有在浏览器设为隐私模式下,也不能读取 LocalStorage

它的使用场景就很多了,比如存储网站主题、存储用户信息、等等,存数数据量多或者不怎么改变的数据都可以用它

SessionStorage

SessionStorage 也是H5新特性,主要用于临时保存同一窗口或标签页的数据,刷新页面时不会删除,但是关闭窗口或标签页之后就会删除这些数据

SessionStorage 和 LocalStorage 一样是在本地存储,而且都不能被爬虫爬取,并且都有同源策略的限制,只不过 SessionStorage 更加严格,只有在同一浏览器的同一窗口下才能共享

它的 API 和 LocalStorage 也一样 getItem、setItem、removeItem、clear、key

它的使用场景一般是具有时效性的,比如存储一些网站的游客登录信息,还有临时的浏览记录等

indexDB

是浏览器本地数据库,有以下特点

  • 键值对储存:内部用对象仓库存放数据,所有类型的数据都可以直接存入,包括js对象,以键值对的形式保存,每条数据都有对应的主键,主键是唯一的
  • 异步:indexDB操作时用户依然可能进行其他操作,异步设计是为了防止大量数据的读写,拖慢网页的表现
  • 支持事务:比如说修改整个表的数据,修改了一半的时候报了个错,这时候会全部恢复到没修改之关的状态,不存在修改一半成功的情况
  • 同源限制:每一个数据库应创建它对应的域名,网页只能访问自身域名下的数据库
  • 存储空间大:一般来说不少于250MB,甚至没有上限
  • 支持二进制存储:比如ArrayBuffer对象和Blob对象

前端存储方式除了上面四个,还有WebSQL,类似于SQLite,是真正意义上的关系型数据库,可以使用sql进行操作,只是用js时要进行转换,比较麻烦

以上四个的区别

CookieSessionStorageLocalStorageindexDB
存储大小4k5M或更大5M或更大无限
存储时间可指定时间,没指定关闭窗口就失效浏览器窗口关闭就失效永久有效永久有效
作用域同浏览器,所有同源标签页当前标签页同浏览器,所有同源标签页
存在于请求中来回传递客户端本地客户端本地客户端本地
同源策略同浏览器,只能被同源同路径页面访问共享自己用同浏览器,只能被同源页面访问共享

离线存储

Service Worker

Service Worker是运行js主线程之外的,在浏览器背后的独立线程,自然也无法访问DOM,它相当于一个代理服务器,可以拦截用户发出的请求,修改请求或者直接向用户发出回应,不用联系服务器。比如加载JS和图片,这就让我们可以在离线的情况下使用网络应用

一般用于离线缓存(提高首屏加载速度)、消息推送、网络代理等功能。使用Service Worker的话必须使用https协议,因为Service Worker中涉及到请求拦截,需要https保障安全

用Service Worker来实现缓存分三步:

  • 一是注册
  • 然后监听install事件后就可以缓存文件
  • 下次再访问的时候就可以通过拦截请求的方式直接返回缓存的数据
// index.js 注册
if (navigator.serviceWorker) { 
    navigator.serviceWorker .register('sw.js').then( registration => {
        console.log('service worker 注册成功')
    }).catch((err)=>{
        console.log('servcie worker 注册失败')
    })
} 
// sw.js  监听 `install` 事件,回调中缓存所需文件 
self.addEventListener('install', e => {
    // 打开指定的缓存文件名
    e.waitUntil(caches.open('my-cache').then( cache => {
        // 添加需要缓存的文件
        return cache.addAll(['./index.html', './index.css'])
    }))
})
// 拦截所有请求事件 缓存中有请求的数据就直接用缓存,否则去请求数据 
self.addEventListener('fetch', e => { 
    // 查找request中被缓存命中的response
    e.respondWith(caches.match(e.request).then( response => {
        if (response) {
            return response
        }
        console.log('fetch source')
    }))
})

参考资料:

  1. juejin.cn/post/694793…
  2. juejin.cn/post/694793…
  3. juejin.cn/post/699335…