1. 前端缓存概述
1.1 什么是缓存
缓存(Cache)的原始意义是指访问速度比一般随机存取存储器(RAM)快的一种RAM,通常它不像系统主存那样使用DRAM技术,而使用昂贵但较快速的SRAM技术。 如今缓存的概念已被扩充,不仅在CPU和主内存之间有Cache,而且在内存和硬盘之间也有Cache(磁盘缓存),乃至在硬盘与网络之间也有某种意义上的Cache──称为Internet临时文件夹或网络内容缓存等。凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为Cache。
1.2 什么是前端缓存
前端缓存主要是指HTTP缓存和浏览器缓存,前端缓存可以加快页面加载速度、减轻服务器负担、提高用户体验、支持离线使用等,但同时也面临着缓存过期、用户安全、缓存清除等问题。
1.3 前端缓存的分类
2. HTTP缓存
对于一些具有重复性的 HTTP 请求,比如每次请求得到的数据都一样的,我们可以把这对「请求-响应」的数据都缓存在浏览器,那么下次就直接读取浏览器的数据,不必在通过网络获取服务器的响应了。 避免发送 HTTP 请求的方法就是通过缓存技术,HTTP 协议的头部有不少是实现缓存的字段,HTTP 缓存有两种实现方式,分别是强缓存和协商缓存。 HTTP 缓存流程图:
2.1 强缓存
强缓存指的是只要浏览器判断缓存没有过期,则直接使用浏览器的本地缓存,决定是否使用缓存的主动性在于浏览器这边。 如下图中,返回的是 200 状态码,但在 size 项中标识的是 from memory cache/from disk cache,就是使用了强缓存。 强缓存是利用 Expires、Cache-Control 这两个 HTTP 响应头部字段实现的,它们都用来表示资源在客户端缓存的有效期。
2.1.1 Expires
Expires 是 HTTP/1.0 中定义缓存的字段,其给出了缓存过期的绝对时间,即在此时间之后,响应资源过期。 示例:
Expires: Mon, 29 May 2023 08:36:55 GMT
上述示例表示该资源将在 2023-05-29 16:36:55 之后过期,而在该时间之前浏览器可以直接从浏览器缓中读取数据,无需再次请求服务器,即命中强缓存。 但是因为 Expires 设置的缓存过期时间是一个绝对时间,所以会受客户端时间的影响而变得不精准。
2.1.2 Cache-Control
Cache-Control 首部字段是 HTTP/1.1 中定义缓存的字段,其用于控制缓存的行为,可以组合使用多种指令,多个指令之间可以通过 “,” 分隔。常用的指令有:max-age、s-maxage、public/private、no-cache、no-store 等。 示例:
Cache-Control: max-age:3600, s-maxage=3600, public
Cache-Control: no-cache
Cache-Control: no-store
max-age 指令给出了缓存过期的相对时间,单位为秒数。当其与 Expires 同时出现时,max-age 的优先级更高。但往往为了做向下兼容,两者都会经常出现在响应首部中。同时 max-age 还可在请求首部中被使用,告知服务器客户端希望接收一个存在时间(age)不大于多少秒的资源。 public/private 决定资源是否可以在代理服务器进行缓存,其中,public 表示资源在客户端和代理服务器都可以被缓存,private 表示资源只能在客户端被缓存,拒绝资源在代理服务器缓存。默认值为 private。 s-maxage 决定了代理服务器缓存的时长,必须和 public 属性一起使用才能起到作用。 no-cache 表示强制进行协商缓存,如果某一资源的Cache-control中设置了no-cache,那么该资源会直接跳过强缓存的校验,直接去服务器进行协商缓存。 no-store 表示禁止任何缓存策略。
2.2 协商缓存
2.2.1 Last-Modified 和 If-Modified-Since
Last-Modified 代表资源的最后修改时间。当浏览器第一次接收到服务器返回资源的 Last-Modified 值后,其会把这个值存储起来,并再下次访问该资源时通过携带 If-Modified-Since 请求首部发送给服务器验证该资源有没有过期。 示例:
Last-Modified: Mon, 29 May 2023 08:36:55 GMT
If-Modified-Since: Mon, 29 May 2023 08:36:55 GMT
如果在 If-Modified-Since 字段指定的时间之后资源发生了更新,那么服务器会将更新的资源发送给浏览器(状态码200)并返回最新的 Last-Modified 值,浏览器收到资源后会更新缓存的 If-Modified-Since 的值。 如果在 If-Modified-Since 字段指定的时间之后资源都没有发生更新,那么服务器会返回状态码 **304 Not Modified **的响应,即命中协商缓存。 虽然起到了缓存作用,但是还是有一定缺陷的:
- 如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒。
- 如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,所以起不到缓存的作用。
2.2.2 Etag 和 If-None-Match
为了解决 Last-Modified 和 If-Modified-Since 的缺陷,出现了 Etag 和 If-None-Match。 Etag 首部字段用于代表资源的唯一性标识,服务器会按照指定的规则生成资源的标识。当资源发生变化时,Etag 的标识也会更新。同样的,当浏览器第一次接收到服务器返回资源的 Etag 值后,其会把这个值存储起来,并在下次访问该资源时通过携带 If-None-Match 请求首部发送给服务器验证该资源有没有过期。 示例:
Etag: "29322-09SpAhH3nXWd8KIVqB10hSSz66"
If-None-Match: "29322-09SpAhH3nXWd8KIVqB10hSSz66"
如果服务器发现 If-None-Match 值与 Etag 不一致时,说明服务器上的文件已经被更新,那么服务器会发送更新后的资源给浏览器并返回最新的 Etag 值,浏览器收到资源后会更新缓存的 If-None-Match 的值。 如果服务器发现 If-None-Match 值与 Etag 一致,说明服务器上的文件未更新,那么服务器会返回状态码 **304 Not Modified **的响应,即命中协商缓存。
3. HTTP缓存案例
3.1 强缓存为什么那么快
我们在使用浏览器的时候,经常有一种感觉,第一次访问网页会有点慢,再次访问就快了许多,这一现象的背后,主要是强缓存起到了作用。 以 Odeon 首页加载为例: 第一次访问: 我们重点关注下 Size 和 Time 列的数据,Size 列表示浏览器从服务器获取资源的大小,Time 列表示资源加载耗时。因为几乎每一个资源都需要从服务器获取并加载,所以网页打开速度会受到影响,这里浏览器加载完了页面的所有资源(图片、脚本、样式、字体等),1.1 MB 的数据被传输到了本地。 当浏览器发起 HTTP 请求时,会向浏览器缓存进行一次询问,若浏览器缓存没有该资源的缓存数据,那么浏览器便会向服务器发起请求,服务器接收请求后将资源返回给浏览器,浏览器会将资源的响应数据存储到浏览器缓存中,这便是强缓存的生成过程。 第二次访问: 我们发现 Size 一列大部分由原先的资源加载大小变成了 disk cache(磁盘缓存),而变成这一数据对应的 Time 列资源加载速度异常之快,传输到本地的数据降到了 226 KB。主要是/basic/menu/all(GET)、/basic/users/hueuser/user-cluster-project-role(GET)这两个接口的数据。理论上 GET 请求是安全且幂等的,因为它是「只读」操作,无论操作多少次,服务器上的数据都是安全的,且每次的结果都是相同的。所以,可以对 GET 请求的数据做缓存,可以缓存到浏览器本身上,也可以缓存到代理服务器上。 在发起请求时浏览器缓存告诉浏览器它那有该资源的缓存数据并且还没有过期,于是浏览器直接加载了缓存中的数据资源,这便是强缓存生效过程。 强缓存除了 disk cache(磁盘缓存),还有 memory cache(内存缓存)。此时,我们不关闭 Tab 页,重新刷新下 Odeon 首页: 可以观察到走 memory cache 的资源 Time 列都是 0ms。可见,memory Cache 比 disk cache 更快,快到不需要时间。 按照缓存位置的读取顺序,相比 disk cache,浏览器会优先读取 memory Cache。通过对以上开发者工具图例的对比不难得出,读取磁盘缓存会存在稍许的耗时,而读取内存缓存是及时性的,不存在耗时。 介绍强缓存时说过 “强缓存是利用 Expires、Cache-Control 这两个 HTTP 响应头部字段实现的”,可是观察下图可以看到资源命中了强缓存,但是没有响应头并没有 Expires、Cache-Control 这两个字段,这是因为命中了浏览器的启发式缓存。 先介绍一个公式:缓存新鲜度 = max-age || (expires - date) 当 max-age 存在时缓存新鲜度等于 max-age 的秒数,是一个时间单位,就像保质期为 6 个月一样。当 max-age 不存在时,缓存新鲜度等于 expires - date 的值,expires 我们应该已经熟悉,它是一个绝对时间,表示缓存过期的时间,date表示创建报文的日期时间,可以理解为服务器返回新资源的时间。 上图中的 max-age、expires 都不存在,反而有 Last-Modified 字段,资源却没有命中协商缓存反而命中了强缓存。这是因为,报头中没有用来确定强缓存过期时间的字段,这便无法使用上面提到的缓存新鲜度公式,反而浏览器会触发启发式缓存。 启发式缓存对于缓存新鲜度计算公式:缓存新鲜度 = max(0, (date - last-modified)) * 0.1 根据响应报头中 date 与 last-modified 值之差与 0 取最大值后取其值的百分之十作为缓存时间。 下为 MDN 中启发式缓存的介绍:
3.2 浏览器行为——Chrome的三种加载模式
在使用浏览器的过程中,经常需要在浏览器地址栏输入对应的网址、刷新网页、清理浏览器缓存、打开控制台调试等等,上述种种行为其实换一种角度来看,都可以和缓存有关,所以有必要介绍下缓存的载体——浏览器(以Chrome为例)。 Chrome 具备三种加载模式,除了通过快捷键的方式触发,这三种模式只在开发者工具打开时才能够使用,此时我们打开开发者工具,在浏览器刷新按钮上右键鼠标便会展示这几种模式,如下图所示:
- 正常重新加载
正常重新加载这种模式对于浏览器的用户来说都很熟悉,也是我们常说的“刷新网页”,和直接点击浏览器上的刷新按钮效果一样,用户触发该模式在控制台可以看到大多数资源会命中强缓存: 上图中刷新页面后大部分资源直接会从浏览器缓存(memory cache/disk cache)中读取,由此我们可以得出“正常重新加载”模式会优先读取缓存。
- 硬性重新加载
硬性重新加载模式强调的是“硬性”,可以理解为我们常说的“强制刷新网页”,比如当代码部署到服务器上后仍然访问的是“旧”页面时,很多人会习惯性的强制刷新一下(Ctrl + F5)便好了,而使用“正常重新加载”却无法解决。于是我们会以为硬性重新加载会清空缓存重新向服务器请求,这是一个误区,我们来观察下硬性重新加载后的控制台: 可以看到所有资源都重新向服务器获取,这个没有问题,但是检查下请求报头我们会发现,使用硬性重新加载后所有资源的请求首部都被加上了 cache-control: no-cache 和 pragma: no-cache,两者的作用都表示告知(代理)服务器不直接使用缓存,要求向源服务器发起请求,而 pragma 则是为了兼容 HTTP/1.0。 因此硬性重新加载并没有清空缓存,而是禁用缓存,其效果类似于在开发者工具 Network 面板勾选了 Disable cache 选项: 可以看到加载 Odeon 首页时有的资源命中强缓存,有的资源没有命中强缓存,这是因为硬性重新加载并没有清空缓存,当异步资源在页面加载完后插入时,其加载时仍然优先读取缓存,如果使用清空缓存并硬性重新加载便不会出现这种现象。
- 清空缓存并硬性重新加载
该模式顾名思义,其比硬性重新加载多了清空缓存的操作,因此触发该操作会将浏览器存储的本地缓存都清空掉后再重新向服务器发送请求,同时其影响的并不是当前网站,所有访问过的网站缓存都将被清除。 还有一种资源“始终”命中 memory cache,就是 base64 图片。从本质上看 base64 图片其实就是一堆字符串,其伴随着页面的渲染而加载,浏览器会对其进行解析,会损耗一定的性能。按照浏览器的“节约原则”,base64 格式的图片被塞进 memory cache 可以视作浏览器为节省渲染开销的“自保行为”。
4. 浏览器缓存
4.1 Memory Cache 和 Disk Cache
- memory cache
memory cache(内存缓存)是存储在浏览器内存中的。其优点为获取速度快、优先级高,从内存中获取资源耗时为 0 ms,而其缺点也显而易见,比如生命周期短,当网页关闭后内存就会释放,同时虽然内存非常高效,但它也受限制于计算机内存的大小,是有限的。那么如果要存储大量的资源,这是还得用到磁盘缓存。
- disk cache
disk cache(硬盘缓存)是存储在计算机硬盘中的。其优缺点与 memory cache 正好相反,比如优点是生命周期长,不触发删除操作则一直存在,而缺点则是获取资源的速度相对内存缓存较慢。 disk cache 会根据保存下来的资源的 HTTP 首部字段来判断它们是否需要重新请求,如果重新请求那便是强缓存的失效流程,否则便是生效流程。 获取顺序:
- 浏览器会率先查找内存缓存,如果资源在内存中存在,那么直接从内存中加载;
- 如果内存中没找到,接下去会去磁盘中查找,找到便从磁盘中获取;
- 如果磁盘中也没有找到,那么就进行网络请求,并将请求后符合条件的资源存入内存和磁盘中。
4.2 存储型缓存
浏览器存储型缓存包含了 Cookie、Web Storage、IndexedDB 等,它们也是我们日常开发中经常会接触的缓存,其是造成“登录一个网站后再次访问的时候就已经是登录状态”现象的主要原因。
4.2.1 Cookie 存储方案
Cookie 的存储空间很小,不能超过 4KB,因此这一缺点也限制了它用于存储较大容量数据的能力。 不建议将非用户身份类的数据存储在 Cookie 中,因为 Cookie 在同域下会伴随着每一次资源请求的请求报头传递到服务端进行验证,试想一下如果大量非必要的数据存储在 Cookie 中,伴随着请求响应会造成多大的无效资源传输及性能浪费。 在 Cookie 存储 API 方面,浏览器提供的原始 API 使用起来也不是特别方便,比如:
// 存储 Cookie
document.cookie='odeon_username=hueuser; domain=bigdata.iflytek.com'
// 读取 Cookie
// 只能通过 document.cookie 读取所有 Cookie 并进行字符串截取,非常不便
// 删除 Cookie
let date = new Date()
date.setTime(date.getTime() - 10000) // 设置一个过期时间
document.cookie=`odeon_username=hueuser; domain=bigdata.iflytek.com; expires=${date.toGMTString()}`
如此操作起来会编写大量重复糟心的代码,因此封装 Cookie 的增删改查操作十分必要。推荐使用 js-cookie 库,其 API 操作如下:
import Cookies from 'js-cookie'
// 存储 Cookie
Cookies.set('odeon_username', 'hueuser', { domain: 'bigdata.iflytek.com' })
// 读取 Cookie
Cookies.get('odeon_username')
// 删除 Cookie
Cookies.remove('odeon_username')
4.2.2 Web Storage 存储方案
在验证用户身份及维持状态方面,Cookie 有明显的特点和优势,但其并不是存储网页数据的小能手,相反 Web Storage 在这方面却有显著的优势。 Web Storage 作为 HTML5 推出的浏览器存储机制,其又可分为 Session Storage 和 Local Storage,两者相辅相成。 Session Storage 作为临时性的本地存储,其生命周期存在于网页会话期间,即使用 Session Storage 存储的缓存数据在网页关闭后会自动释放,并不是持久性的。而 Local Storage 则存储于浏览器本地,除非手动删除或过期,否则其一直存在,属于持久性缓存。 Web Storage 与 Cookie 相比存储大小得到了明显的提升,一般为 2.5-10M 之间(各家浏览器不同),这容量对于用于网页数据存储来说已经十分充足。 我们再来看一下 Web Storage 相关的操作 API(以 Local Storage 为例):
// 存储 Local Storage 数据
localStorage.setItem('x-cluster', 'hf')
// 读取 Local Storage 数据
localStorage.getItem('x-cluster')
// 删除 Local Storage 数据
localStorage.removeItem('x-cluster')
在存储简单的数据类型时,Web Storage 提供的原始 API 可以轻松完成任务,但是一旦数据类型变为 Object 类型时,其应付起来就变得捉襟见肘,主要原因在于使用 Web Storage 存储的数据最终都会转化成字符串类型,比如:
localStorage.setItem('length', 15)
localStorage.getItem('length') // 最终获取的会是字符串 '15'
而存储对象时如果没有提前采用序列化方法 JSON.stringify 转化为字符串对象,那么最终获取的值会变成 [object Object]。 因此 Web Storage 的原始存储方案会存在繁碎的序列化与反序列化的缺点:
let userinfo = { name: 'wentang', age: 18 }
// 存储时进行序列化操作
localStorage.setItem('userinfo', JSON.stringify(userinfo))
// 获取时进行反序列化操作
JSON.parse(localStorage.getItem('userinfo'))
我们可以对其API进行二次封装,也可以使用目前 npm 市场上也有相关封装 Web Storage 的包,比如 web-storage-cache。
4.2.3 IndexedDB 存储方案(了解)
通过使用 Web Storage,我们实现了网页间数据的临时和持久化存储,但和大容量的数据库相比 Web Storage 存储的空间还是相对有限,此时最终的解决方案便是 —— IndexedDB。 IndexedDB 是一个大规模的 NoSQL 存储系统,它几乎可以存储浏览器中的任何数据内容,包括二进制数据(ArrayBuffer 对象和 Blob 对象),其可以存储不少于 250M 的数据。 在使用 IndexedDB 前,我们需要判断浏览器是否支持:
if (!('indexedDB' in window)) {
console.log('浏览器不支持 IndexedDB')
return
}
在浏览器支持的前提下,我们便可以对其进行增删改查操作。首先我们先得打开或者创建数据库:
let idb
// 打开名为 odeon,版本号为 1 的数据库,如果不存在则自动创建
let request = window.indexedDB.open('odeon', 1)
// 错误回调
request.onerror = function (event) {
console.log('打开数据库失败')
}
// 成功回调
request.onsuccess = function (event) {
idb = request.result
console.log('打开数据库成功')
}
如果是新建数据库那么会触发版本变化的 onupgradeneeded 方法,因为此时版本是从有到无的:
request.onupgradeneeded = function(e) {
idb = e.target.result;
console.log('running onupgradeneeded')
// 新建对象表时,先判断该表是否存在
if (!idb.objectStoreNames.contains('store')) {
// 创建名为 store 的表,以 id 为主键
let storeOS = idb.createObjectStore('store', { keyPath: 'id' })
}
};
当我们创建完数据库表(仓库)后,就可以对其进行数据的新增操作:
// 新增方法
function addItem(item) {
// 新增时必须指定表名和操作模式
let transaction = idb.transaction(['store'], 'readwrite')
// 获取表对象
let store = transaction.objectStore('store')
// 调用 add 方法新增数据
store.add(item)
}
let data = {
id: 1, // 主键 id
name: 'wentang',
age: 18,
}
addItem(data) // 调用新增方法
通过主键 id 我们可以方便的获取到想要的数据:
// 读取方法
function readItem(id) {
// 创建事务,指定表名
let transaction = idb.transaction(['store'])
// 获取表对象
let store = transaction.objectStore('store')
// 调用 get 方法获取数据
let requestStore = store.get(id)
requestStore.onsuccess = function() {
if (requestStore.result) {
console.log(requestStore.result) // { id: 1, name: 'wetang', age: 18 }
}
}
}
readItem(1) // 获取主键 id 为 1 的数据
更多关于 IndexedDB 的 API 大家可以参考 w3 的文档:www.w3.org/TR/IndexedD… 当然我们也可以不必使用原始的 API 像上面那样进行比较繁琐的操作,目前 npm 市场上比较流行的封装 IndexedDB 的包 idb 可以简化原始 API 的操作流程。比如使用 idb 库后我们可以将上述创建数据库、新增和读取方法换成类似 localStorage 一样的同步方式:
import { openDB } from 'idb'
const dbPromise = openDB('juejin', 1, {
upgrade(db) {
db.createObjectStore('store', { keyPath: 'id' })
},
})
// 新增方法
export async function add(val) {
return (await dbPromise).add('store', val)
}
// 读取方法
export async function get(key) {
return (await dbPromise).get('store', key)
}
5. 前端缓存的性能优化策略
Web缓存性能优化是一种提高网站加载速度和提高用户体验的方法。通过使用缓存,可以减少服务器的负载和网络延迟,从而提高页面的响应速度。以下是一些关于Web缓存性能优化的建议:
- 频繁变动的资源,比如HTML,采用协商缓存
- CSS、JS、图片等资源采用强缓存,避开启发式缓存,使用 contenthash 命名
- 缓存API响应,在相同请求再次发生时,可以直接从缓存中获取结果,而无需重新查询API
- 采用效率更高的br压缩算法,可以减小传输文件的大小,从而缩短加载时间
- 合并请求,如果把多个访问小文件的请求合并成一个大的请求,虽然传输的总资源还是一样,但是减少请求,也就意味着减少了重复发送的 HTTP 头部
- 延迟加载,对于不需要立即显示的资源(如图片或视频),可以使用延迟加载技术。这样,只有当用户滚动到这些资源时,它们才会开始加载
- 善用preload和prefetch,我们可以优化浏览器资源加载的顺序和时机
- 善用base64,有效命中memory cache
- 使用HTTP/2,HTTP/2协议提供了性能改进,如多路复用和服务器推送。启用HTTP/2可以进一步提高网站性能