前端缓存详解

前端缓存是我们日常开发中非常重要的一部分,利用缓存我们可以做很多的事情。例如:

  • 利用浏览器缓存用于保存数据,用作性能优化
  • 利用http缓存减少与服务器之间的通信,降低服务器压力和请求时间

了解完前端缓存能做的事情后,接下来了解一下前端缓存的组成和实现吧~

前端缓存分类

首先,前端缓存可以分为 HTTP缓存浏览器缓存

  • HTTP缓存负责发起Http请求时约定保存指定对应的数据。HTTP缓存又分为强缓存协商缓存
  • 浏览器缓存指的是浏览器提供给我们用于数据保存的内存空间,浏览器缓存又有很多种,分为:CookielocalStoragesessionStorageIndexDB

介绍完前端缓存的分类,接下来详细了解一下每一个缓存的作用和使用。

HTTP缓存

强缓存

首先来看一下一个HTTP请求的流程

image.png

可以看到,在向服务器请求之前,会先向浏览器缓存查找,是否有这个缓存,查找缓存的根据是url
如果有缓存则立即返回数据。如果没有找到缓存或者缓存已经过期,此时需要分情况讨论:

  • 如果缓存已经过期,http请求会携带缓存标识到服务器中,服务器返回信息后将数据和标识缓存在浏览器缓存中
  • 如果没有查找到,则直接发起HTTP请求,返回后将数据和标识保存到缓存中

设置强缓存

强缓存是通过设置HTTP请求头来设置的,具体的字段有:

  • Expires: HTTP/1.0 使用的字段,值为资源的过期时间。利用服务器时间与客户端作对比来判断缓存是否过期。(如果服务器与客户端时间不同步,则会造成缓存失效)

  • Cache-Control: Cache-Control字段同样也可以设置强缓存,而且优先级比Expires高。Cache-Control使用max-age来设置缓存过期时间,即在HTTP请求中返回一个资源有效的数,避免了Expires时间比较存在的问题。除此之外,Cache-Control还提供资源缓存策略。

Cache-Control取值[1]

字段名 作用
public 所有内容都将被缓存(客户端和代理服务器都可以缓存)
private 所有内容只有客户端可以缓存 (默认取值)
no-cache 客户端缓存内容,但是是否使用缓存则需要经过协商缓存决定
no-store 所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存
max-age=x 缓存内容将在 x 秒后失效

来看一个简单的🌰:

首先简单搭起一个服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const http = require('http');
const fs = require('fs');
const path = require('path');

const filePath = path.resolve(__dirname, 'file', 'test.js');
const htmlPath = path.resolve(__dirname, 'file', 'test.html');
const imgPath = path.resolve(__dirname, 'file', 'image.jpg');

const app = http.createServer((req, res) => {
const path = req.url;
if (path === '/file.js') {
res.setHeader('Content-Type', 'text/javascript');
// 设置强缓存
res.setHeader('Cache-Control', 'max-age=600');
fs.createReadStream(filePath).pipe(res);
} else if (path === '/index.html') {
fs.createReadStream(htmlPath).pipe(res);
} else if (path === '/image.jpg') {
// 设置强缓存
res.setHeader('Cache-Control', 'max-age=600');
fs.createReadStream(imgPath).pipe(res);
} else {
res.end('Hello World');
}
});

app.listen('8888', () => {
console.log('server running at 8888');
});

前端声明一个简单的html文件,文档中有一张图片,而且调用ajax去请求/file这个接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<html>
<head>
<title>test</title>
</head>
<body>
<img src="/image.jpg" />
<script lang="text/javascript">
(() => {
const request = new XMLHttpRequest()
request.open("GET", "/file")
request.send()
request.onreadystatechange = (e) => {
if (e.target.status === 200 && e.target.readyState === 4) {
console.log(e.target.responseText)
}
}
})()
</script>
</body>
</html>

浏览器收到Cache-Control后,就会存放在浏览器缓存中,下一次调用时如果未过期则会使用浏览器的缓存。如图所示

image.png

返回结果:

image.png

image.png

可以看到,后端返回了一个Cache-Control的字段,这个就是用于强缓存的字段,如果浏览器检测到有此标识,则将数据保存到浏览器缓存中。

那么我们要如何判别是否触发了强缓存呢?很简单,我们刷新一下页面看一下。

image.png

当我们再次刷新页面时,接口的size变成了disk cache。表示这个缓存存放在硬盘中。此时,除非缓存时间已经过,或者手动请求浏览器缓存。访问改接口都会返回浏览器保存在本地的数据。
除了存放在硬盘中,强缓存还可以存放在 内存(memory cache) 中,可以看到图片的缓存为memory cache

  • disk cache: 存放在硬盘中的缓存,读取需要进行I/O操作
  • memory cache: 存放在内存中的缓存,速度快,但是有时间限制。浏览器一但关闭,缓存就会消失

看到上文我们可以知道,强缓存也分为disk cachememory cache,那么什么时候会使用memory cache呢?
这取决于使用浏览器的缓存策略:通常一些小的图片,部分js文件会存放在内存中,css文件则存放在硬盘中。

除了上面两种缓存,还有下面几种缓存:

  • Service Worker:一个独立于当前页面,可以在浏览器后台运行的脚本。具体可以看这里
  • Push Cache: 推送缓存是HTTP/2中的内容,当disk,memory,service worker都没有被使用的时候才会触发,具体的Push Cache可以看这里

缓存的执行顺序:Service Worker -> Memory Cache -> Disk Cache -> Push Cache

协商缓存

HTTP除了强缓存,还有协商缓存,当强缓存失效后,浏览器会携带缓存标识去请求资源,服务器根据缓存标识来决定是否使用缓存。协商缓存调用过程如下

image.png

可以看到,强缓存的优先级比协商缓存高,当强缓存失效时,才会触发协商缓存。而协商缓存原理是:通过缓存标识去请求服务器对比该资源是否有改变过,如果没有改变过,则可以继续使用强缓存失效的数据。

也就是说,协商缓存主要有两种情况:

  • 标识与服务器的表示一致,返回304,请求浏览器缓存得到数据
  • 表示与服务器的不一致,请求拿到对应资源,返回200

使用协商缓存

使用协商缓存与强缓存一样,都是通过HTTP请求头实现。设置协商缓存的字段有两个,分别是:

  • Last-Modified: 返回资源时,该资源的最后修改时间
  • Etag: 表示当前资源的唯一标识(由服务器生成)

当浏览器接收到有协商缓存字段请求时,会将该标识保存在浏览器缓存中,并在下一次请求该资源时带上该标识,浏览器在带上请求时与服务器设置的字段的不一样,他们分别是:

  • If-Modified-Since: 对应的是Last-Modifyed。通过对比时间判断资源是否有修改
  • If-None-Match: 对应的是Etag。通过对比该资源的唯一标识,判断资源是否有修改

来看一下协商缓存的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 服务器端
const http = require('http');
const fs = require('fs');
const path = require('path');

const filePath = path.resolve(__dirname, 'file', 'test.js');
const htmlPath = path.resolve(__dirname, 'file', 'test.html');
const imgPath = path.resolve(__dirname, 'file', 'image.jpg');

function formatTime(date) {
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
}

const app = http.createServer((req, res) => {
const path = req.url;
if (path === '/file.js') {
const modifyDate = req.headers['if-modified-since'];
if (
modifyDate &&
formatTime(new Date(modifyDate)) ===
formatTime(new Date(fs.statSync(filePath).mtime))
) {
res.statusCode = 304;
res.end();
} else {
res.setHeader('Content-Type', 'text/javascript');
res.setHeader('Cache-Control', 'max-age=5');
// 使用 Last-Modified 触发协商缓存
// 通常是文件的最后修改时间
res.setHeader('Last-Modified', fs.statSync(filePath).mtime);
fs.createReadStream(filePath).pipe(res);
}
} else if (path === '/index.html') {
fs.createReadStream(htmlPath).pipe(res);
} else if (path === '/image.jpg') {
const getEtag = req.headers['if-none-match'];
if (getEtag && getEtag === '123') {
res.statusCode = 304;
res.end();
} else {
res.setHeader('Cache-Control', 'max-age=5');
// 使用Etag触发协商缓存
res.setHeader('Etag', '123');
fs.createReadStream(imgPath).pipe(res);
}
} else {
res.end('Hello World');
}
});

app.listen('8888', () => {
console.log('server running at 8888');
});

可以看到,在读取JS文件,我们使用Last-Modified触发协商缓存,读取图片文件时,使用Etag来触发协商缓存。对应地分别使用If-Modified-SinceIf-None-Match来捕捉浏览器发送过来的标识。

访问 localhost:8888/index.html, 返回结果如下

image.png

可以看到这两个资源都触发了协商缓存,资源都是从浏览器缓存中得到的。

小结

  • 强缓存是直接存放在内存硬盘中的缓存,通过HTTP请求头的ExpiresCache-Control设置强缓存。存储在内存的缓存速度非常快,但是空间有限。存储在硬盘的缓存读取时需要I/O操作,所以比内存缓存要慢
  • 协商缓存是当强缓存失效时触发的缓存,通过HTTP请求头的Last-ModifiedEtag设置协商缓存。服务器对应地需要获取请求头中的If-Modified-SinceIf-None-Match来判断标识是否一致。一致则返回304状态码,浏览器收到304状态吗后直接取浏览器缓存中的数据
  • 强缓存优先级高于协商缓存

浏览器缓存

什么是Cookie

首先来了解一下什么是Cookie

HTTP协议本身是无状态的。什么是无状态呢,即服务器无法判断用户身份。Cookie实际上是一小段的文本信息(key-value格式)。客户端向服务器发起请求,如果服务器需要记录该用户状态,就使用response向客户端浏览器颁发一个Cookie。客户端浏览器会把Cookie保存起来。当浏览器再请求该网站时,浏览器把请求的网址连同该Cookie一同提交给服务器。服务器检查该Cookie,以此来辨认用户状态。[5]

上面引用提到:

  • Cookie是服务器向客户端返回的,浏览器接收到后会把Cookie保存起来。
  • 当浏览器再次请求该域名时,会把Cookie也带上交给服务器。
  • 服务器检测到cookie后,来确认用户信息。

举个🌰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const http = require('http');
const fs = require('fs');
const path = require('path');

const filePath = path.resolve(__dirname, 'file', 'test.js');
const htmlPath = path.resolve(__dirname, 'file', 'test.html');

const app = http.createServer((req, res) => {
const path = req.url;
if (path === '/file.js') {
fs.createReadStream(filePath).pipe(res);
} else if (path === '/index.html') {
// 设置Cookie
res.setHeader('Set-Cookie', ['user=imuser', 'type=user']);
fs.createReadStream(htmlPath).pipe(res);
} else {
res.end('Hello World');
}
});

app.listen('8888', () => {
console.log('server running at 8888');
});

浏览器访问 index.html ,浏览器发现后Set-Cookie字段后,将他保存到缓存中

image.png

在浏览器中也可以看到它保存在Cookies中了

image.png

现在我们来请求同一个源中的/file.js接口

1
2
3
4
5
6
7
8
const request = new XMLHttpRequest()
request.open("GET", "/file.js")
request.send()
request.onreadystatechange = (e) => {
if (e.target.status === 200 && e.target.readyState === 4) {
console.log(e.target.responseText)
}
}

image.png

可以看到Request Headers中自动带上了Cookie字段,我们再来修改一下服务端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const http = require('http');
const fs = require('fs');
const path = require('path');

const filePath = path.resolve(__dirname, 'file', 'test.js');
const htmlPath = path.resolve(__dirname, 'file', 'test.html');

const app = http.createServer((req, res) => {
const path = req.url;
if (path === '/file.js') {
// 获取cookie
const cookie = req.headers.cookie;
const cookieArr = cookie ? cookie.split(';') : [];
const cookieObj = {};
// 将cookie转化为键值对
cookieArr.forEach((val) => {
const tmp = val.split('=');
if (tmp.length === 2) cookieObj[tmp[0].trim()] = tmp[1].trim();
});
// 判断用户信息
if (cookieObj.user === 'imuser') {
fs.createReadStream(filePath).pipe(res);
} else {
res.end('404 not found');
}
} else if (path === '/index.html') {
res.setHeader('Set-Cookie', ['user=imuser', 'type=user']);
fs.createReadStream(htmlPath).pipe(res);
} else {
res.end('Hello World');
}
});

app.listen('8888', () => {
console.log('server running at 8888');
});

上面代码中,修改了一下/file.js的逻辑:

  • 进入请求时,需要先获取cookie,cookie进入时是以字符串形式给到服务器端,需要手动转化成对象
  • 转化cookie后,判断用户是否正确,如果正确则返回对应的数据,如果错误则返回对应提示。

可见,cookie可以保存用户状态。除了服务器端可以获取到cookie,客户端也可以拿到cookie。只需调用document.cookie即可拿到cookie字符串。

Cookie属性

属性名 作用
Key=Value 键值对
Path 指定哪些路径可以接受Cookie
Domain 指定哪些域名可以接受Cookie
Secure 设置后 Cookie 只应通过被HTTPS协议加密过的请求发送给服务端
HttpOnly 防止客户端修改和访问Cookie,避免XSS攻击
Expires 指定Cookie过期时间
SameSite Cookie 允许服务器要求某个cookie在跨站请求时不会被发送,防止CSRF攻击

Cookie很小,大约只有4KB,所以通常只会用来存放一些用户信息,如果想用稍微大一点的浏览器缓存,可以使用浏览器提供的Storage

Storage

浏览器Storage是HTML5的新引入的,可以在浏览器端保存数据,分为LocalStorageSessionStorage

两者调用方法,大小和API都是一致的,大约有5MB。唯一不同的是SessionStorage在关闭页面后就会消失,而LocalStorage除非用户手动清除,不然会一致存在。

注意,Storage只在同源页面下共享。

IndexDB

IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。该 API 使用索引实现对数据的高性能搜索。虽然 Web Storage 在存储较少量的数据很有用,但对于存储更大量的结构化数据来说力不从心。[7]

IndexDB是在前端运行的一个数据库系统,想要了解更多点击这里

IndexDB存储数据举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

var customerData = [
{ ssn: "111", name: "Bill", age: 35, email: "bill@test.com" },
{ ssn: "222", name: "Donna", age: 32, email: "donna@test.com" }
];
var request = indexedDB.open("testDB", 2) // 打开一个数据库
request.onerror = (err) => {
console.log(err)
}

request.onupgradeneeded = (e) => {
const db = e.target.result
const objectStore = db.createObjectStore("test", { keyPath: "ssn" }) // 创建一个数据对象
// 使用事务的 oncomplete 事件确保在插入数据前对象仓库已经创建完毕
objectStore.transaction.oncomplete = function(event) {
// 将数据保存到新创建的对象仓库
var customerObjectStore = db.transaction("test", "readwrite").objectStore("test");
// 循环插入数据
customerData.forEach(function(customer) {
customerObjectStore.add(customer);
});
// 读取数据
setTimeout(() => {
var transaction = db.transaction(['test']);
var getStore = transaction.objectStore('test');
var res = getStore.get("111")
res.onerror = () => {} // 异常捕捉
res.onsuccess = () => {
console.log(res.result)
}
}, 2000)
};
}

可以看到,
执行结果:

image.png

总结

HTTP缓存

  • 强缓存

    • 触发:设置HTTP请求头的expirescache-control字段,cache-control的优先级比expires
    • 缓存位置:硬盘内存
    • 读取缓存顺序:Service Worker -> Memory Cache -> Disk Cache -> Push Cache
  • 协商缓存

    • 触发:设置HTTP请求头的last-modifiedEtag字段。last-modified通常是数据的最后更改时间;Etag为数据在服务器的唯一标识
    • 捕捉缓存:服务器端需要获取HTTP请求头的if-modified-sinceif-none-match字段。判断请求携带过来标识于对应数据的标识是否一致,一致则返回304。浏览器判断到304状态码后从浏览器缓存中读取数据
    • 优先级: Etag优先级比last-modify

浏览器缓存

  • Cookie

    • 概念:Cookie是一小段的文本信息,由服务器主动发送到浏览器中。
    • 触发:服务器通过请求头的Set-Cookie字段将数据发送给浏览器。浏览器收到后会将数据保存起来。下一次请求对应的域名后会自动把Cookie放到请求头的Cookie字段中。
  • Storage

    • 概念:HTML5新增的特效,可以在浏览器端保存数据,分为sessionStoragelocalStoragesessionStorage在页面关闭时会消失。localStorage除非用户手动请求,不然一直会存在浏览器中。
    • 缓存共享:只在同源的页面中共享缓存
  • IndexDB

    • 概念:前端用于保存大量结构化数据的数据库。

参考

  1. 前端缓存详解
  2. 前端缓存
  3. 一文读懂前端缓存
  4. HTTP/2 push is tougher than I thought
  5. 深入理解Cookie
  6. IndexedDB