缓存是提高数据读取读取性能的技术,在软件开发中广泛使用,比如常见的CPU缓存、数据库缓存、浏览器缓存等。
缓存是Web前端性能优化的必要手段之一,既能保证用户在第一时间里获取到最新的资源,又能减少网络请求。浏览器缓存策略主要包括以下两种:
HTTP缓存ServiceWorker
HTTP 缓存
HTTP缓存需要浏览器和服务器进行配置使用,比如Nginx、Apache可以设置不同的HTTP缓存策略。
HTTP缓存主要是对浏览器的静态文件起作用,分为强制缓存和协商缓存。
强制缓存
强制缓存策略是浏览器根据资源过期时间来决定,是否使用缓存。如果在缓存中找到资源,没有过期,则直接使用缓存资源,状态码是200, 否则直接进行请求。
强制缓存是根据Expires 和 Cache-control来决定的。
Expires
HTTP/1.0 中可以使用响应头部字段 Expires 来设置缓存时间,它对应一个未来的时间戳。
expires: Thu, 06 Mar 2031 12:35:43 GMT
Expires有一个致命的缺点是: 它采用的是时间是以服务器的为准,但是浏览器进行判断是将本地的时间与此时间进行对比,这样会导致时间精度上存在误差。因此为了为了更精准的控制资源,http/1.1新增了Cache-control响应头字段。
Cache-control
Cache-control是新增了http/1.1响应头字段,用来控制浏览器强制缓存。
它的常用值有下面几个:
| 字段 | 说明 |
|---|---|
| no-cache | 表示使用协商缓存,即每次使用缓存前必须向服务端确认缓存资源是否更新; |
| no-store | 禁止浏览器以及所有中间缓存存储响应内容 |
| public | 公有缓存,表示可以被代理服务器缓存,可以被多个用户共享 |
| private | 私有缓存,不能被代理服务器缓存,不可以被多个用户共享 |
| max-age | 以秒为单位的数值,表示缓存的有效时间 |
| must-revalidate | 当缓存过期时,需要去服务端校验缓存的有效性 |
其值可以组合使用:
cache-control: public, max-age=31536000
需要注意的是Expires 和 cache-control 的 max-age 优先级高于 Expires,如果它们同时出现,浏览器会优先使用 max-age 的值。
协商缓存
协商缓存策略是每次请求都会发送请求,经过服务器端对资源的对比,来决定是使用本地缓存,还是新的资源。请求响应返回的 HTTP 状态为 304,则表示缓存仍然有效。控制缓存的难题就是从浏览器端转移到了服务端。
控制协商缓存有两种方式:
Last-Modified和If-Modified-SinceETag和If-None-Match
Last-Modified 和 If-Modified-Since
服务端要判断缓存有没有过期,采取的方式是资源进行对比。如果浏览器直接把资源发送给服务端进行比对的话,网络开销太大,而且也会失去缓存的意义,所以显然是不可取的。
有一种简单的判断方法,是通过响应头部字段 Last-Modified 和请求头部字段 If-Modified-Since 比对双方资源的修改时间。
工作流程如下:
-
浏览器第一次请求资源,服务端在返回资源的响应头中加入
Last-Modified字段,该字段表示这个资源在服务端上的最近修改时间。 -
当浏览器再次向服务端请求该资源时,请求头部带上之前服务端返回的修改时间,这个请求头叫
If-Modified-Since -
服务端再次收到请求,根据请求头
If-Modified-Since的值,判断相关资源是否有变化,如果没有,则返回304 Not Modified,并且不返回资源内容,浏览器使用资源缓存值;否则正常返回资源内容,且更新Last-Modified响应头内容。
这种方式虽然能判断缓存是否失效,但也存在两个问题:
-
精度问题,
Last-Modified的时间精度为秒,如果在1秒内发生修改,那么缓存判断可能会失效。 -
准度问题,如果一个文件被修改,然后又被还原,内容并没有发生变化,在这种情况下,浏览器的缓存还可以继续使用,但因为修改时间发生变化,也会重新返回重复的内容。
ETag 和 If-None-Match
为了解决精度问题和准度问题,HTTP 提供了另一种不依赖于修改时间,而依赖于文件哈希值的精确判断缓存的方式,那就是响应头部字段 ETag 和请求头部字段 If-None-Match。
工作流程如下:
-
浏览器第一次请求资源,服务端会在响应头中加入
Etag字段,Etag字段值为该资源的哈希值; -
浏览器再次从服务端请求这个资源时,会在请求头上加上
If-None-Match,值为之前响应头部字段ETag的值; -
服务端再次收到请求,将请求头
If-None-Match字段的值和响应资源的哈希值进行比对,如果两个值相同,则说明资源没有变化,返回304 Not Modified;否则就正常返回资源内容,无论是否发生变化,都会将计算出的哈希值放入响应头部的ETag字段中。
这种缓存比较的方式也会存在一些问题,具体表现在以下两个方面。
-
计算成本。生成哈希值相对于读取文件修改时间而言是一个开销比较大的操作,尤其是对于大文件而言。如果要精确计算则需读取完整的文件内容,如果从性能方面考虑,只读取文件部分内容,又容易判断出错。
-
计算误差。
HTTP并没有规定哈希值的计算方法,不同服务端可能会采用不同的哈希值计算方式。这样带来的问题是,同一个资源,在两台服务端产生的Etag可能是不相同的,所以对于使用服务器集群来处理请求的网站来说,使用Etag的缓存命中率会有所降低。
需要注意:协商缓存中,Etag 优先级比 Last-Modified 高。
ServiceWorker
Service workers 可以理解为充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。主要目的是实现离线缓存,它会拦截网络请求并根据网络是否可用采取来适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。
使用方法
可以分为三步:
- 注册
- 安装
- 监听
在使用 ServiceWorker 脚本之前先要通过“注册”的方式加载它。
if ('serviceWorker' in window.navigator) {
window.navigator.serviceWorker
.register('./sw.js')
.then(console.log)
.catch(console.error)
} else {
console.warn('浏览器不支持 ServiceWorker!')
}
考虑到浏览器的兼容性,判断 window.navigator 中是否存在 serviceWorker 属性,然后通过调用这个属性的 register 函数来告诉浏览器 ServiceWorker 脚本的路径。
浏览器获取到 ServiceWorker 脚本之后会进行解析,解析完成会进行安装。可以通过监听 “install” 事件来监听安装,但这个事件只会在第一次加载脚本的时候触发。要让脚本能够监听浏览器的网络请求,还需要激活脚本。
在脚本被激活之后,我们就可以通过监听 fetch 事件来拦截请求并加载缓存的资源了。
下面是上面”注册“的sw.js文件的内容:
const CACHE_NAME = 'ws'
// 这里是为什么是数组呢?因为们可以监听多个资源
let preloadUrls = ['/index.css']
/* 监听安装事件,install 事件一般是被用来设置你的浏览器的离线缓存逻辑 */
self.addEventListener('install', function (event) {
/* 通过这个方法可以防止缓存未完成,就关闭serviceWorker */
event.waitUntil(
/* 创建一个名叫 CACHE_NAME 的缓存版本 */
caches.open(CACHE_NAME)
.then(function (cache) {
/* 指定要缓存的内容,地址为相对于跟域名的访问路径 */
return cache.addAll(preloadUrls);
})
);
});
/* 注册 fetch 事件,拦截全站的请求 */
self.addEventListener('fetch', function (event) {
event.respondWith(
/* 在缓存中匹配对应请求资源直接返回 */
caches.match(event.request)
.then(function (response) {
// 匹配上直接返回缓存资源
if (response) {
return response;
}
return caches.open(CACHE_NAME).then(function (cache) {
const path = event.request.url.replace(self.location.origin, '')
return cache.add(path)
})
.catch(e => console.error(e))
})
);
})
这段代码首先监听 install 事件,在回调函数中调用了 event.waitUntil() 函数并传入了一个 Promise 对象。event.waitUntil 用来监听多个异步操作,包括缓存打开和添加缓存路径。如果其中一个操作失败,则整个 ServiceWorker 启动失败。
然后监听了 fetch 事件,在回调函数内部调用了函数 event.respondWith() 并传入了一个 Promise 对象,当捕获到 fetch 请求时,会直接返回 event.respondWith 函数中 Promise 对象的结果。
在这个 Promise 对象中,我们通过 caches.match 来和当前请求对象进行匹配,如果匹配上则直接返回匹配的缓存结果,否则返回该请求结果并缓存。
使用限制
- 在
ServiceWorker中无法直接访问DOM,但可以通过postMessage接口发送的消息来与其控制的页面进行通信; ServiceWorker只能在本地环境下或HTTPS网站中使用;ServiceWorker有作用域的限制,一个ServiceWorker脚本只能作用于当前路径及其子路径;- 由于
ServiceWorker属于实验性功能,所以兼容性方面会存在一些问题。
总结
Expires和cache-control的max-age优先级高于Expires。强缓存的优先级高于协商缓存。协商缓存中,Etag优先级比Last-Modified高。ServiceWorker可以用来实现离线缓存,主要实现原理是拦截浏览器请求并返回缓存的资源文件。