深入 Web 缓存策略

林洋,YMFE 资深工程师,负责去哪儿网 Hybrid(Hy)、React Native(QRN)等移动端方案架构、开发和推进,负责一系列基于 Node 的开源平台(YIcon、YApi 等)、开发工具(小程序构建工具、YDoc、YKit 等)的管理维护工作。专注于移动前端,着眼于工程流程化。

移动互联网时代,各种互联网技术层出不穷,尤其在移动端方面,各种动态化方案如雨后春笋般,在各自的领域蓬勃生长。但是,不管哪种方案,都会涉及到资源的迭代更新问题。如何让用户在更快地使用最新资源的同时,也能结合缓存保证应用的加载效率,是这类方案必须要考虑的。本文将从浏览器缓存谈起,在涵盖 App Cache、SW Cache 等纯 Web 缓存方案的同时,也将站在大前端角度去分析不同方案的差异,最终,让大家对 web 缓存策略有一个详尽的了解。

1. 资源更新与缓存

从互联网开始向大众普及,Web 技术就成为不能缺少的一部分,其资源控制权,也从用户端转向服务端,由服务端响应客户端的请求,并推送最新的资源。理论上,可以让用户每次都能使用到最新的资源,从而让产品迭代更快速、更有效。但是,这样的逻辑也带来了一些很严重的问题:

  • 当无网时,用户端取不到相应的资源,导致产品不可用。
  • 当弱网时,用户端获取资源速度很低,使产品加载缓慢,最终导致产品体验很差。

为了解决此类问题,必须要引入资源的本地暂存机制,也就是通常说的 —— 缓存。缓存的出现,始于计算机对性能的要求。计算机为了在性能上能有指数级的增长,引入了缓存设计。简单说,存储和读取速度和硬件的成本成正比:相同空间的存储硬件,存取越快,成本越高。下图说明了计算机中缓存的设计,会分很多不同层级的缓存,当然内存也可以算作缓存的一种。

缓存设计

所以为了权衡,两者相结合,使得性价比最大。而这篇文章里主要讨论的是偏向用户端的缓存,与传统缓存类似,虽然缓存运用得越广泛,用户的体验越好,但同时也增加了相应的维护成本。

Phil Karlton 说过一句广受大家共鸣的一句话:“There are only two hard things in Computer Science: cache invalidation and naming things.(命名和缓存失效是计算机科学里面最难对付的两件事)”,可以看出缓存是大家很难去维护但不得不去维护的策略。只要存在缓存,就能得到性能和体验上的提升;而在合适的时机删除之前缓存的资源并更新最新资源,却是一个很难完美解决的问题。

当前缓存运用的场景,除了计算机硬件及系统以外,主要在数据库和网络资源两个主要方向。而其中,网络资源更多运用在用户端,尤其在 Web 技术中,浏览器基于网络协议配置的缓存是最常见,同时也是最贴近用户的使用场景。因此,为了让大家更好地了解缓存策略的细节,笔者将从 Web 缓存谈起。

2. Web 缓存

之前,大家提到 Web 缓存,大多是指浏览器缓存。但随着 HTML5 的发展(App Cache)以及 PWA 技术的推广(SW Cache),前端工程师可以可以自由控制浏览器对资源的缓存。Web 缓存从服务端配置化逐渐演变为用户端逻辑化,从由浏览器自动控制到由工程师自主设计,使资源更新更灵活,但也带来一些不易解决的问题。

下面,从最传统的浏览器缓存谈起,深入了解资源缓存的细节。

2.1 浏览器缓存 —— Browser Cache(HTTP 客户端缓存)

现在的大型网站,不管是 PC 端还是移动端,动不动就几十个请求,如果没有浏览器缓存的存在,用户体验会急剧下降,同时服务器压力和网络带宽都将面临严重的考验。因此,浏览器缓存是现代互联网中必不可少的一环。

从技术角度讲,浏览器缓存是 HTTP 缓存机制中客户端部分的一个实现。

HTTP 缓存机制分为两种,客户端缓存服务端缓存 ,而服务端缓存又分为 代理服务器缓存(例:CDN 服务)和 反向代理服务器缓存(例:Nginx 反向代理服务)。由于篇幅有限,服务端缓存部分就不加赘述了。

关于客户端缓存,浏览器缓存是其中的一种实现形式,在浏览器内核中实现基于 HTTP 缓存机制的缓存。当然,在各类网络请求的开发库中,也实现了几乎同样的逻辑。这些逻辑,都是基于 HTTP 协议中的 HEADER 来实现的,根据 HEADER 中相应配置的不同,执行不同的缓存逻辑。

HTTP 报文结构

对于客户端整体缓存逻辑,大家应该比较清楚:判断是否有缓存,如果有就直接使用缓存中的内容,如果没有则进行网络请求获取内容。(如下图)

HTTP 客户端整体缓存逻辑

但是客户端怎么根据 HTTP 的 HEADER 来更为细化地控制缓存的呢?其实 HTTP 客户端缓存有两种不同的策略机制:

  • 服务端决策缓存:由服务端决定并告知客户端是否使用缓存。
  • 客户端决策缓存:服务端告知客户端缓存时间后,由客户端判断并决定是否使用缓存。

对于这两种策略机制的区别,最明显的表象是:从 Chrome DevTool 中 Network 面板里看到缓存的请求,服务端决策缓存在 Status 一栏显示的是 304,而客户端决策缓存在 Status 一栏显示的是 200,不过在 Size 一栏会显示 from disk cache。这两种策略机制,从解释中就可以看出,区别在于上图中的检查是否有缓存的部分。

道上常说,有图有真相,所以先把两种策略机制的流程图奉上,每种策略机制都分有 缓存命中缓存未命中 两种情况。

HTTP 客户端缓存策略流程图

这两种缓存策略机制主要是由 HTTP Header 中的 Cache-Control 来决定和控制使用的。此属性常见的取值有以下6类:

  • public:全部缓存,包括客户端和服务端(时长 365 天)
  • private:仅客户端缓存(时长 365 天)
  • no-cache:不适用客户端的缓存,使用“服务端决策缓存”。并不是表面意义上的“不使用缓存”。
  • no-store:所有内容都不会被缓存,不论哪种策略机制都不会被缓存。不同浏览器对这种情况的实现不同,有些浏览器是不缓存,有些是在特定实际清除缓存,例如当前页面关闭、浏览器关闭等。
  • must-revalidation/proxy-revalidation:如果缓存内容失效,请求必须发送服务器/代理进行验证。也就是当“客户端决策缓存”未命中时,使用“服务端决策缓存”,理论上是最优的缓存策略。不过,只有最新的部分浏览器和网络库支持此配置,还未普及。
  • max-age=<s>:缓存内容在s秒后失效,仅 HTTP 1.1 可用。(HTTP 1.0 可以用 Expires

其中,对于前端资源,最常用的是 Cache-Control: no-cacheCache-Control: max-age=123,分别对应笔者上文提到的两种策略机制。而对于数据请求,一般使用 Cache-Control: no-store 来保证每次数据都是新的,而由于前文提到 no-store 的实现方式有异,最好还是加随机参数来避免缓存。

当然,Cache-Control 不仅仅这六类取值,更多的可以直接查看 HTTP 1.1 协议文档 rfc2616

在上面的流程图中,提到了“缓存标识”,此标识也是使用 HTTP Header 进行通信的,可以使用 Etag/If-None-MatchLast-Modified/If-Modified-Since 对资源进行标识,前者是资源的特征值,也可看做为标识符,而后者则是资源的更新时间。具体使用哪个,由服务端获取特征值和修改时间的效率决定,使用效率较好的一个。当然,这两个标识可以同时使用,此时 Etag/If-None-Match 的优先级要高于 Last-Modified/If-Modified-Since

Header 截图

从上文来看,Cache-Control: must-revalidation 或许是最优的缓存机制,不过由于支持度有限,而且有时候缓存策略跟业务逻辑有关,因此前端需要一种更自由,更定制化的缓存机制,因此 HTML5 的 App Cache 的出现,给广大前端开发者打了一大盆鸡血,不过事实如何呢?

2.2 HTML5 性特性 —— App Cache

笔者在 2014 年开发 Hybrid 框架时,调研过 App Cache。虽然当时已经有了相应的标准(WD-html5-20120329 ),但当时浏览器对 App Cache 的支持程度很差,很多浏览器实现的方式也不同,让笔者很是苦恼。不过,经过这几年的发展,App Cache 的完善度越来越高,也有更多的开发者采用此技术。

App Cache 本质上是通过一个配置文件(Manifest)来决定访问资源的缓存策略,并提供相应的状态和事件让开发者可以有效利用它。

App Cache 配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CACHE MANIFEST

CACHE:
# 需要缓存的列表
local.jpg
static.js
unupdate.css

NETWORK:
# 需要请求网络的资源
network.jpg

FALLBACK:
# 访问缓存失败后,备用访问的资源,第一个是访问源,第二个是替换文件*.html /offline.html
index.html /404.html

在页面的 html 标签内加入,此配置文件,即可让 App Cache 生效。

1
2
3
<!DOCTYPE HTML>
<html manifest="filename.appcache">
</html>

可见,配置中对不同资源的缓存策略进行了分别的定义。这样,开发可以自由控制不同文件的缓存策略,而不需要服务端针对不同的文件进行特殊的配置。为了让开发者可以对缓存中资源更新的情况有更多的了解,App Cache 方案给用户提供了 cachedcheckingdownloadingerrornoupdateobsoleteprogressupdateready 8个事件(window.applicationCache.on('someEvent')),以及 statuswindow.applicationCache.status)来标识它的状态。下图中,标识出每个事件的时机以及相应的 status 的取值。

App Cache 流程图

此外,开发者还可以使用 window.applicationCache.update() 方法强制启动更新逻辑,使用 window.applicationCache.abort() 方法强制停止更新逻辑。

从上文,看出 App Cache 提供一套配置化的缓存方案,通过配置来控制不同资源的缓存策略,但对在线更新并未提供太多机制和优化方案。而且 App Cache 与 Browser Cache 在逻辑上属于同一层次,对于它们之间如何协同工作,HTML5 的相关规范没有对具体细节给出非常明确的规定,同时,浏览器官方文档也没有给出非常明确的说明。这就造成在不同的浏览器,可能需要使用不同的配置和方案,来保证缓存策略的正确性,这样会使出错的几率上升。并且当出错时,你很有可能遇到计算机科学里面最难对付的两件事之一的缓存失效问题,甚至于只能让用户主动清除缓存才能解决问题。除去技术上的问题,一个项目使用 App Cache ,不仅仅需要前端开发人员,同时也需要服务端配合,这样让成本成倍增加,包括之后的维护成本也会成倍增加。总结下来,这些问题,都是 App Cache 没有被广泛使用的原因。

App Cache 看似盛宴,却又不堪,如何来真正解决资源更新和缓存问题?Google 推出的 PWA 方案给出了另一种思路。

2.3 PWA 的大跨步 —— SW Cache

PWA,全称是 Progressive Web App,渐进式 Web 应用。是 Google 2015 年提出,2016 年年中才着力推广的全新前端技术。其实与其说它是一项技术,还不如将它理解为一个方向,而这个方向的目标就是 在 Web 应用中实现与原生应用相近的用户体验

PWA VS Native

官网上给出 PWA 宣传的重点词是 : Reliable ( 可靠的 )、Fast( 快速的 )、Engaging( 可参与的 )。

PWA

而为了实现这三个特点,让 Web 应用更贴近原生应用,PWA 优先提供了下面几个个关键技术(相信后面会不断补充更多的技术):

  • Manifest:是一个 W3C 规范,它定义了一个基于 JSON 的配置文件,让 Web 应用可以和原生应用一样可以被安装,并在屏幕上有自己的入口。
  • Push Notification:接收服务端推送通知,一个“原生”应用必须有的功能。
  • App Shell: 先显示 Web 应用的主结构,再填充其他数据和结构,让加载过程更友好,用户体验更佳
  • Service Worker:另外的服务工作线程,在 Web 应用的后台执行,相比于 HTML5 提出的 Web Worker 功能更加强大。PWA 提供的新功能的逻辑,都需要在 Service Worker 中实现,例如下列基础功能:
    • 管理 Web 应用生命周期(Manifest 的眼神)
    • 消息推送(Push Notification 的实现)
    • 不限域的获取或同步数据
    • 接受计算密集型数据的更新,多页面共享该数据
    • 拦截 Web 应用中的请求并做缓存。
    • 等等 ……

而列表中最后提到的,就是本文要谈到的 SW Cache,也就是 Service Worker Cache。就像 Service Worker 不是只用于 Cache 的,Service Worker Cache 也不是只用于静态资源缓存。它可以拦截 Web 应用所有请求,通过逻辑的实现,来配置自己的缓存策略,不仅仅能缓存静态资源,业务的动态数据接口也同样可以缓存。

MDN 上可以找到 Service Worker 现在拥有的 API:

Server Worker API 列表

其中,用于缓存的是 CacheCacheStorage,只需要下面在 Service Worker 内的一段代码即可实现页面资源的缓存,并在离线时可用。

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
55
56
57
58
// 缓存的ID,唯一性标识,用于缓存的控制
const CACHE_ID = "v1.0.0";

// 为了保证线程不影响整个 Web 应用(同步 API 阻塞),Service Worker 中的 API 都是 Promise 的异步形式。
// cacheStorage.open() 获取的 Cache 对象,就是从全局对象里取的 caches 对象,因此可以直接使用 caches 对象。

// Service Worker 安装事件(并不是 Web 应用安装到手机上,可以看出 Service Worker 开始充初始化的事件)
self.addEventListener('install', event => {
// event.waitUtil 用于在安装成功之前执行一些预装逻辑
// 但是建议只做一些轻量级和非常重要资源的缓存,减少安装失败的概率
// 安装成功后 Service Worker 状态会从 installing 变为 installed
event.waitUntil(
// 使用 cache API 打开指定的 cache
caches.open(CACHE_ID).then(cache => {
// 添加要缓存的资源列表
return cache.addAll([
'./static/example.js',
'./static/example.css',
'./static/404.jpg',
'./index.html'
]);
})
);
});

// Service Worker 激活时间,此事用于删除失效的缓存
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (cacheIDs) {
return Promise.all(
cacheIDs.map(function (cacheID) {
// 如果获取到的版本和缓存版本不一致,则删除相应缓存
if (cacheID !== CACHE_ID) {
return caches.delete(cacheID);
}
})
);
})
);
});

// 拦截请求,做匹配,对于从线上请求的数据,将其缓存
self.addEventListener('fetch', function (event) {
// 从缓存中匹配相应请求
event.respondWith(caches.match(event.request).catch(function () {
// 匹配失败,从线上请求
return fetch(event.request);
}).then(function (response) {
// 将请求结果缓存
caches.open(CACHE_ID).then(function (cache) {
cache.put(event.request, response);
});
return response.clone();
}).catch(function () {
// 发生错误,则从缓存里匹配相应的 Fallback 内容
return caches.match('./static/404.jpg');
}));
});

从代码中可以看出不论是缓存的内容,还是缓存的管理,甚至添加缓存,都是由开发者实现代码来控制的,让更新和缓存逻辑完全掌控在开发者手里。上面的代码只是个基本逻辑,开发者可以根据自身的情况,设计出符合自身情况的逻辑,例如说,公共资源与业务资源分开,使用不同的缓存(不同的 CACHE_ID),等等。笔者只是在这里抛砖引玉,具体的情况还需要具体分析。

上文,在谈到 App Cache 时,提到 App Cache 与 Browser Cache 互相影响,纠缠不清。那么,SW Cache 和 Browser Cache 又是什么关系呢?请看下图。

SW Cache

相比于 Browser Cache ,SW Cache 更偏向于应用层,和 Browser Cache 是串行关系。SW Cache 匹配失败的资源请求线上时,仍旧会走 Browser Cache。 因此,对于 Browser Cache 的服务端,只要不将缓存时间设置过长,就不会影响 SW Cache 的使用。

总之,SW Cache 是一个想简单也异常简单,想复杂也可以复杂的开发者可控的缓存机制,可以让开发者根据资深情况定制不同的缓存策略。

3. 总结

这一部分,主要讲了 Web 缓存,从浏览器缓存到 HTML5 的 App Cache,再到跨时代技术 PWA 的 SW Cache。与硬件或系统缓存不同,Web 缓存是最早运用在用户端应用层的缓存策略,从浏览器缓存的前端不可控,到 App Cache、SW Cache 方案中前端有一定的控制权,策略的改变体现了开发者对用户端缓存给予了越来越多的重视。

谈到缓存,就不得不谈到另一个概念 —— 热更新。Web 应用与传统的原生应用项目,最大的优势在于可以非常及时地使用到最新版本的应用,而不是像传统原生应用一样,必须删除应用后再从应用市场下载进行重装,其操作和时间成本都很高,尤其在细微的更新或者修复 Bug 时。因此,越来越多的原生应用参考 Web 应用的缓存策略,实现自己的资源热更新机制,让绝大多数资源的更新不需要通过应用市场来更新应用,而是通过自身的机制进行更新,降低用户的升级成本。其中,在游戏方面,由于游戏应用体积一般较大,不便于每次小调整都要从应用市场更新应用,因此,使用资源的热更新机制成为了不二选择。下图就是游戏《王者荣耀》的资源更新界面。

最后做个简单的总结:Web 缓存策略是广泛应用在每一个 Web 应用中,深入理解 Web 缓存策略有利于去优化 Web 应用的性能、提高 Web 应用的使用体验。同时,也有助于理解或设计原生客户端中的热更新策略,毕竟 Web 缓存策略是应用最广泛的,而所有新生的客户端热更新策略都会借鉴其经验。