持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情
引言
为什么很多网站打开后再次打开的速度会很快呢?这主要是因为在首次打开的过程中缓存了一些资源。因此,了解并用好缓存,是前端性能优化的重要一环。
浏览器缓存
根据维基百科中对web缓存的解释,Web缓存是用于临时存储(缓存)Web文档(如HTML页面和图像),以减少服务器延迟的一种信息技术。Web缓存系统会保存下通过这套系统的文档的副本;如果满足某些条件,则可以由缓存满足后续请求。
Web 缓存按存储位置来区分,包括数据库缓存、服务端缓存、CDN 缓存和浏览器缓存。其中,浏览器缓存是一种在本地保存资源副本,以供下次请求时直接使用的技术,操作简单、前端性能优化效果显著。浏览器缓存有四个存储位置,按照优先级排列如下:
- Memory Cache:是存在内存中的缓存。它不仅是最优先的,也是响应速度最快的一种缓存。它也并不长久,当渲染进程结束后即tab关闭后(甚至之前)便不复存在。
- Service Worker Cache:一种可以离线实现、可以由我们操作的缓存,它是长久性的,关闭浏览器后也仍存在。因此,可以用它实现后台推送等功能。它我们能够自由控制页面所发送网络请求的处理方式。
- HTTP Cache:大家最熟悉最有代表性的,又名disk cache。是存储在硬盘上的缓存。它是长久性的,可以跨站点使用。
- Push Cache:是HTTP2 在 server push 阶段存在的缓存。这个是比较前沿的知识,是缓存的最后一道防线。
一、Memory Cache
Memory Cache我们之前讲过,它是存在内存中的缓存。那么哪些请求资源会进入内存呢?
首先就是我们声明需要预加载的资源,比如 <link rel="preload" src="xxx" /> 这种在 html 中声明需要预加载的资源是一定会预加载并进入内存。
那么除此之外还有就涉及到浏览器的 preloader 策略,看到这里可能有的帅哥美女就要问了,这个策略是个啥呢?这个策略实际上主要是浏览器为了提升资源加载速度而使用的一种手段,在浏览器打开网页的过程中,会先请求 HTML 然后解析。之后如果浏览器发现了 js, css 等需要解析和执行的资源时,它会使用 CPU 资源对它们进行解析和执行。在这个解析的过程中浏览器还可以再加载其他资源并保存到内存中以提升整体的加载速度,这个过程我们就称为 preloader。
由于这个本身是浏览器为了提升加载速度自己所做的优化所以目前为止并没有官方给定的标准,而且往往随着内存余量的不同浏览器对不同文件是否缓存到内存看起来也完全是随机的。但是整体来看它依然遵循着一定的规律,这个规律就是“没钱就过穷日子”。
具体来说浏览器会根据内存余量的以及资源文件的大小进行综合考量,比如 Base 64格式的图片几乎永远可以被塞进内存,体积不大的 JS、CSS文件也比大体积有更大的机会被保存进内存。
二、HTTP 缓存
HTTP缓存是大家最熟悉最有代表性的,又名disk cache。它是存储在硬盘上的缓存。相较Memory Cache它是长久性的,容量更大,还可以跨站点使用。
HTTP缓存是利用HTTP Herder 中的字段来控制的。它通过Expires和Cache-Control等字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。
注:http缓存具有复用性,即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。浏览器中绝大部分的缓存都来自 Disk Cache。
在工作中使用缓存最大的问题在于如何保证缓存与实际资源一致的同时,提高缓存的命中率。也就是说我们要尽可能地让浏览器从缓存中获取资源,但同时又要保证被使用的缓存与服务端最新的资源保持一致。为此需要制定合适的缓存过期策略(简称“缓存策略”),HTTP缓存依据缓存策略不同分为有两种:强制缓存和协商缓存。其中优先级较高的是强制缓存,在命中强缓存失败的情况下,才会走协商缓存。
下面我们对这两种缓存机制进行剖析。
1、强制缓存
强制缓存是在浏览器加载资源的时候,首先访问缓存数据库查找请求结果是否已经存在,如果存在则直接返回,不会再与服务端发生通信;如果不存在则请求服务器,响应后再写入缓存数据库。 因此强制缓存大大减少了服务器的请求压力,它是前端性能提升最大的缓存策略。
强制缓存是由http头中的两个字段来控制的: Cache-control 和 Expires。浏览器根据这两个字段判断目标资源是否命中强缓存,命中的情况下会直接返回缓存文件。
(1) Expires
HTTP/1.0 中可以使用响应头部字段 Expires 来表示缓存到期时间,它对应一个未来的时间戳。
客户端第一次请求时,服务端会在响应头部添加 Expires 字段。当浏览器再次发送请求时,先会对比当前时间和 Expires 对应的时间,如果当前时间早于 Expires 时间,那么直接使用缓存,在未过期之前不要再次请求;反之,需要再次发送请求。
Expires: Tue Mar 16 2021 00:00:00 GMT
上述 Expires 信息告诉浏览器:在 2021.03.16 日之前,可以直接使用该请求的缓存。但是使用 Expires 响应头时容易产生一个问题,那就是服务端和浏览器的时间很可能不同,因此这个缓存过期时间容易出现偏差。同样的,客户端也可以通过修改系统时间来继续使用缓存或提前让缓存失效。
为了解决这个问题,HTTP/1.1 提出了 Cache-Control 响应头部字段。
(2) Cache-Control
Cache-control字段中,可通过max-age等来控制资源的有效期。它表示资源缓存的最大有效时间,是一个时间长度。在该时间内,客户端可直接使用缓存中的资源。由于max-age是一个相对时间,所以它确保了资源的过期判定不再受服务端的时间戳限制,较Expires 字段能更精准。
Cache-control 的常用值有下面几个:
- no-cache,表示使用协商缓存,即每次使用缓存前必须向服务端确认缓存资源是否更新;
- no-store,禁止浏览器以及所有中间缓存存储响应内容;
- public,公有缓存,表示可以被代理服务器缓存,可以被多个用户共享;
- private,私有缓存,不能被代理服务器缓存,不可以被多个用户共享;
- max-age,以秒为单位的数值,表示缓存的有效时间;
- must-revalidate,当缓存过期时,需要去服务端校验缓存的有效性。
这几个值可以组合使用,比如像下面这样:
cache-control: public max-age=31536000
告诉浏览器该缓存为公有缓存,有效期 1 年。
需要注意的是,cache-control 的 max-age 优先级高于 Expires,也就是说如果它们同时出现,浏览器会使用 max-age 的值。
2、协商缓存
协商缓存就是强缓存失效后,浏览器携带缓存标识向服务器发送请求,由服务器根据缓存标识来决定是否使用缓存的过程。
协商缓存的更新策略是不再指定缓存的有效时间了,而是浏览器直接发送请求到服务端进行确认缓存是否更新,如果请求响应返回的 HTTP 状态为 304,则表示缓存仍然有效。如果失效,则返回新的数据和缓存规则,浏览器响应数据后,再把规则写入到缓存数据库。控制缓存的难题就是从浏览器端转移到了服务端。
可以看出相较强制缓存,协商缓存在减少服务器请求压力方面毫无作为,它并不会减少请求的数量。因此它优先级低于强制缓存。二者可以一起部署,在命中强制缓存失败的情况下才会启用协商缓存。协商缓存在缓存有效的情况下返回的是304状态吗,因此它也可以说是在节省网络开销方面有所优化,达到了性能提升的目的。
协商缓存靠两组字段来实现:
(1) Last-Modified 和 If-Modified-Since
服务端要判断缓存有没有过期,只能将双方的资源进行对比。若浏览器直接把资源文件发送给服务端进行比对的话,网络开销太大,而且也会失去缓存的意义,所以显然是不可取的。有一种简单的判断方法,那就是通过响应头部字段 Last-Modified 和请求头部字段 If-Modified-Since 比对双方资源的修改时间。
具体工作流程如下:
-
浏览器第一次请求资源,服务端在返回资源的响应头中加入 Last-Modified 字段,该字段表示这个资源在服务端上的最近修改时间;
-
当浏览器再次向服务端请求该资源时,请求头部带上之前服务端返回的修改时间,这个请求头叫 If-Modified-Since。它的值是上一次resonses返回给他的last- modified值;
-
服务端再次收到请求,根据请求头 If-Modified-Since 的值,判断相关资源是否有变化,如果没有,则返回 304 Not Modified,并且不返回资源内容,浏览器使用资源缓存值;否则正常返回资源内容,且更新 Last-Modified 响应头内容。
这种方式虽然能判断缓存是否失效,但也存在两个问题:
-
精度问题,Last-Modified 的时间精度为秒,如果我们修改文件速度极快,在 1 秒内发生修改,那么缓存判断可能会失效;
-
准度问题,考虑这样一种情况,如果一个文件被修改,然后又被还原,内容并没有发生变化,在这种情况下,浏览器的缓存还可以继续使用,但因为修改时间发生变化,也会重新返回重复的内容。
为了解决这两个问题,Etag出现了。
(2) 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。在协商缓存中,Etag 作为last- modified的补充方案,精确度更高,优先级也比 Last-Modified 高。
既然协商缓存策略也存在一些缺陷,那么我们转移到浏览器端看看 ServiceWorker 能不能给我们带来惊喜。
三、ServiceWorker
Service Worker 是浏览器在后台独立于主线程运行的线程,也可以这样理解,它本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。它是一种可编程网络代理,使我们能够自由控制页面所发送网络请求的处理方式。
Service Worker 由事件驱动,生命周期包括 install、active、working 三个阶段。一旦 Service Worker 被 install,它将始终存在,只会在 active 与 working 之间切换,除非我们主动终止它。这是它可以用来实现离线存储的重要先决条件。
加之它脱离浏览器窗口,因此它无法直接访问DOM,可以在不影响页面性能的情况下实现推送通知和后台同步、网络代理等功能,更多功能还在进一步扩展,但其最主要的功能是通过拦截浏览器请求并返回缓存的资源文件从而实现离线缓存。
因为它是可编程的,因此它与浏览器内建的缓存机制如Memory Cache、http cache不同,我们可以编写相应程序拦截当前网站所有的请求进行判断,可以选择缓存哪些文件、如何匹配缓存、读取缓存。
1、使用方法
service workers使用步骤如下:
-
通过 serviceWorkerContainer.register() 来获取和注册service worker URL。
-
如果注册成功,service worker 就在 ServiceWorkerGlobalScope 环境中运行; 这是一个特殊类型的 worker 上下文运行环境,与主运行线程(执行脚本)相独立,同时也没有访问 DOM 的能力。
-
service worker 现在可以处理事件了。
-
受 service worker 控制的页面打开后会尝试去安装 service worker。最先发送给 service worker 的事件是安装事件(在这个事件里可以开始进行填充 IndexDB 和缓存站点资源)。这个流程同原生 APP 或者 Firefox OS APP 是一样的 — 让所有资源可离线访问。
-
当 oninstall 事件的处理程序执行完毕后,可以认为 service worker 安装完成了。
-
下一步是激活。当 service worker 安装完成后,会接收到一个激活事件(activate event)。 onactivate 主要用途是清理先前版本的 service worker 脚本中使用的资源。
-
Service Worker 现在可以控制页面了,但仅是在 register() 成功后的打开的页面。也就是说,页面起始于有没有 service worker ,且在页面的接下来生命周期内维持这个状态。所以,页面不得不重新加载以让 service worker 获得完全的控制。
下图展示了 service worker 所有支持的事件:
下面我们通过示例来具体演示,在使用 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 事件来拦截请求并加载缓存的资源了。
下面是一个利用 ServiceWorker 内部的 caches 对象来缓存文件的示例代码。
const CACHE_NAME = 'ws'
let preloadUrls = ['/index.css']
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function (cache) {
return cache.addAll(preloadUrls);
})
);
});
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 来和当前请求对象进行匹配,如果匹配上则直接返回匹配的缓存结果,否则返回该请求结果并缓存。
2、使用限制
浏览器对 ServiceWorker 有很多限制:
-
在 ServiceWorker 中无法直接访问 DOM,但可以通过 postMessage 接口发送的消息来与其控制的页面进行通信,页面可在必要时对 DOM 执行操作;
-
ServiceWorker 只能在本地环境下或 HTTPS 网站中使用,毕竟修改网络请求的能力暴露给中间人攻击会非常危险。
-
ServiceWorker 有作用域的限制,一个 ServiceWorker 脚本只能作用于当前路径及其子路径;
-
由于 ServiceWorker 属于实验性功能,所以兼容性方面会存在一些问题,可以在 Jake Archibald 的 is Serviceworker ready 网站上查看浏览器的具体支持情况。
-
Service Worker 在不用时会被中止,并在下次有需要时重启,因此不能依赖 Service Worker onfetch 和 onmessage 处理程序中的全局状态。 如果存在需要持续保存并在重启后加以重用的信息可通过访问 IndexedDB API 实现。
四、Push Cache
Push Cache 是指 HTTP2 在 server push 阶段存在的缓存。 Push Cache 的关键特性如下:
- Push Cache 是缓存的最后一道防线。浏览器只有在前述均未命中的情况下才会去询问 Push Cache。
- Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。
- 不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache。
对 PushCache 感兴趣的同学可以阅读# HTTP/2 push is tougher than I thought深入了解。