我们在应用的性能方面取得了巨大的进步。我们的 JavaScript 现在根据应用的路径被分成更小的块,我们推迟加载不太重要的部分,直到我们的应用有空闲时间。我们还引入了渐进增强功能,以尽快向用户展示我们的内容,并了解了如何根据 RAIL 指标分析我们的应用性能。
然而,我们的 web 应用中仍然存在一个核心的低效率问题。如果我们的用户离开我们的页面去其他地方(我知道,他们怎么敢)然后返回,我们会再次重复相同的过程:下载index.html,下载不同的 JavaScript 包,下载图像,等等。
我们要求我们的用户每次访问页面时,一遍又一遍地下载完全相同的文件。他们的设备有足够的内存为我们存储这些文件。我们为什么不把它们保存到用户的设备上,然后根据需要检索它们呢?
欢迎使用缓存。在本章中,我们将介绍以下内容:
- 什么是缓存?
- 缓存 API
- 在我们的服务工作器中使用缓存 API
- 测试我们的缓存
缓存是减少网络请求或计算的行为。后端缓存可能包括保存严格计算的结果(例如,生成统计数据),这样当客户端第二次请求时,我们就不必再次处理数据。客户端缓存通常包括保存网络请求的响应,这样我们就不必再次调用。
正如我们前面所说,服务工作器是位于我们的应用和网络之间的代码位。这意味着它们非常适合缓存,因为它们可以截获网络请求并使用请求的文件进行响应,从缓存而不是服务器抓取文件;节省了时间。
从更广泛的角度来看,可以将缓存视为不必多次执行相同的操作,即使用内存存储结果。
渐进式 Web 应用缓存的好处在于,由于缓存存储在设备内存中,因此无论网络连接如何,缓存都是可用的。这意味着无论设备是否连接,都可以访问缓存中存储的所有内容。突然,我们的网站离线了。
对于在 Wi-Fi 区域之间跳跃的移动用户来说,便利因素可能是巨大的,让他们能够快速查看来自朋友的消息或一组方向(任何没有漫游计划的人都会知道这种感觉)。这也不仅仅是纯粹的离线用户的优势;对于间歇性或低质量连接的用户来说,能够在网络跳入跳出时继续工作而不丢失功能是一个巨大的成功。
因此,只要一下子,我们就可以为所有用户提高应用的性能,并使其离线可用。然而,在我们开始在 Chatastrophe 中实现缓存(希望不是 cachetastrophe)之前,让我们看一个关于缓存重要性的故事。
2013 年,美国政府推出了https://healthcare.gov/ ,一个供公民注册《平价医疗法案》(也称为奥巴马医改的网站。从一开始,这个网站就被严重的技术问题所困扰。对于成千上万的人来说,它根本无法加载。
平心而论,该网站承受着巨大的压力,在运营的第一个月,访问量估计为 2000 万次(来源--http://www.bbc.com/news/world-us-canada-24613022 ),但该菌株是意料之中的。
如果你正在为数百万人建立一个网站,让他们注册医疗保健(所有这些都是同时开始的),那么绩效可能是你最关心的问题,但最终,https://healthcare.gov/ 未能交付。
为了应对危机(这威胁到 ACA 的信誉),政府组建了一个团队来解决问题,有点像复仇者,但软件开发人员(所以根本不是复仇者)。
考虑到现场的目标,工程师们震惊地发现https://healthcare.gov/ 未实现基本缓存。没有一个因此,每次用户访问站点时,服务器都必须处理网络请求并生成信息以进行回复。
这种缓存的缺乏产生了复合效应。第一批用户堵塞了管道,因此第二批用户得到了一个加载屏幕。作为回应,他们刷新了屏幕,发出越来越多的网络请求,等等。
一旦开发人员实现了缓存,他们就将响应时间缩短了四分之三。从那时起,该网站甚至能够处理高峰流量。
查塔斯多夫可能没有处理https://healthcare.gov/ 流量级别(尚未…),但缓存始终是一个好主意。
我们将使用的缓存机制是Web 缓存 API。
Note that the Mozilla Developer Network defines the Cache API as experimental technology, and as of August 2017, it only has the support of Chrome, Firefox, and the latest version of Opera.
API 规范有一些我们需要讨论的怪癖。首先,可以在缓存中存储多个缓存对象。通过这种方式,我们能够存储缓存的多个版本,命名为我们喜欢的任何字符串。
这就是说,浏览器对从任何一个站点可以存储多少数据都有限制。如果缓存太满,它可能会简单地删除该来源的所有数据,因此我们最好的办法是存储最小值。
然而,还有一个额外的困难。缓存中的项永远不会过期,除非显式删除,因此如果我们继续尝试将新的缓存对象填充到缓存中,最终它将变得太满并删除所有内容。管理、更新和删除缓存对象完全取决于我们。换句话说,我们必须清理我们自己的烂摊子。
我们将使用五种方法与缓存 API 交互:open、addAll、match、keys和delete。在下文中,缓存将引用缓存 API 本身,缓存将引用特定缓存对象,以区分在单个缓存上调用的方法与 API 本身:
Caches.open()将缓存对象名称(也称为缓存键)作为参数(可以是任何字符串),创建新的缓存对象或以相同名称打开现有的缓存对象。它返回一个Promise,以缓存对象作为参数解析,然后我们可以使用它。Cache.addAll()接收 URL 数组。然后,它将从服务器获取这些 URL,并将结果文件存储在当前缓存对象中。它的小表亲是Cache.add,它对一个 URL 也有同样的作用。Caches.match()将网络请求作为一个参数(我们将在接下来的过程中看到如何抓住它)。它在缓存中查找与 URL 匹配的文件,并返回与该文件解析的Promise。然后,我们可以返回该文件,不再需要向服务器发出请求。它的大哥是Caches.matchAll()。Caches.keys()返回所有现有缓存对象的名称。然后,我们可以通过将其密钥传递给Caches.delete()来删除过期的。
缓存 API 中的最后一个方法是Caches.put,我们在这里不使用它,但可能会感兴趣。这将接收网络请求并获取它,然后将结果保存到缓存中。如果您希望缓存每个请求,而不必提前定义 URL,那么这非常有用。
我们的构建过程会自动为我们生成一个asset-manifest.json文件,其中包含应用包含的每个 JavaScript 文件的列表。它看起来像这样:
{
"main.js": "static/js/main.8d0d0660.js",
"static/js/0.8d0d0660.chunk.js": "static/js/0.8d0d0660.chunk.js",
"static/js/1.8d0d0660.chunk.js": "static/js/1.8d0d0660.chunk.js",
"static/js/2.8d0d0660.chunk.js": "static/js/2.8d0d0660.chunk.js"
}换句话说,我们有一个要缓存的每个 JS 文件的列表。更重要的是,资产清单是用每个文件的新哈希更新的,所以我们不必担心保持最新。
因此,我们可以在Cache.addAll()方法旁边使用资产清单中的 URL,一次过立即缓存所有 JavaScript 资产。我们还需要手动将静态资产(映像)添加到缓存中,但要做到这一点,我们必须利用服务工作器生命周期方法并进行一些基本设置。
在本节中,我们将浏览三个主要的 service worker 生命周期事件,并在每个事件中分别与缓存交互。最后,我们将为所有静态文件提供自动缓存。
不过,有一个警告——在开发中使用缓存充其量是可以忍受的,最坏的情况是令人愤怒。“你为什么不更新?”我们对着屏幕大喊大叫,直到我们意识到我们的缓存一直在提供旧代码;这发生在我们最好的人身上。在本节中,我们将采取措施避免缓存我们的开发文件,并避开此问题,但在将来,请记住,奇怪的错误可能是由缓存引起的。
There are only two hard things in Computer Science: cache invalidation and naming things. -- Phil Karlton
还有一个例子:
There are two hard problems in computer science: cache invalidation, naming things, and off-by-1 errors. -- Leon Bambrick
当我们的服务工作器安装时,我们希望继续设置缓存,并开始缓存相关资产。因此,我们的安装活动分步指南如下:
- 打开相关的缓存。
- 获取我们的资产清单。
- 解析 JSON。
- 将相关 URL 添加到缓存中,再加上静态资产。
让我们敞开心扉开始工作吧!
如果你还有console.log事件监听器要安装,那太好了!删除console.log;否则,按如下方式设置:
self.addEventListener('install', function() {
});在该函数的正上方,我们还将缓存对象名称分配给一个变量:
const CACHE_NAME = ‘v1’;这个名称可以是任何名称,但我们希望在每次部署时都升级版本,以确保旧缓存无效,并且每个人都可以获得最新的代码。
现在,让我们浏览一下我们的清单。
在讨论好东西之前,我们需要讨论可扩展事件。
一旦我们的服务工作器被激活并安装,它可能会立即进入“等待”模式——等待它必须响应的事件发生。但是,我们不希望它进入等待模式,而我们处于打开缓存的中间,这是异步操作。所以,我们需要一种告诉服务工作器的方法:“嘿,不要认为自己完全安装好了,直到缓存被填充。”
我们这样做的方式是通过event.waitUntil()。此方法会延长事件(此处为安装事件)的生命周期,直到解决其中的所有承诺。
如图所示:
self.addEventListener('install', event => {
event.waitUntil(
// Promise goes here
);
});现在我们可以打开缓存了。我们的缓存 API 在 caches 全局变量中可用,因此我们可以调用caches.open():
const CACHE_NAME = 'v1';
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
});
);
});由于目前不存在名为'v1'的缓存对象,我们将自动创建一个。一旦我们得到缓存对象,我们就可以进入步骤 2。
获取资产清单听起来与听起来完全一样:
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
fetch('asset-manifest.json')
.then(response => {
if (response.ok) {
}
})
});
);
});请注意,我们在开发中不应该有资产清单;在继续之前,我们需要确保请求-响应是正常的,以免抛出错误。
令人惊讶的是,我们的asset-manifest.json返回了一些 JSON。让我们分析一下:
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
fetch('asset-manifest.json')
.then(response => {
if (response.ok) {
response.json().then(manifest => {
});
}
})
});
);
});现在我们有了一个 manifest 变量,它是一个与asset-manifest.json内容匹配的普通 JavaScript 对象。
因为我们有一个 JavaScript 对象来访问 URL,所以我们可以选择要缓存的内容,但在本例中,我们需要所有内容,所以让我们迭代对象并获得 URL 数组:
response.json().then(manifest => {
const urls = Object.keys(manifest).map(key => manifest[key]);
})我们还想缓存index.html和我们的图标,所以让我们插入/和/img/icon.png:
response.json().then(manifest => {
const urls = Object.keys(manifest).map(key => manifest[key]);
urls.push(‘/’);
urls.push('/img/icon.png');
})现在,我们可以使用cache.addAll()将所有这些 URL 添加到缓存中。请注意,我们指的是打开的特定缓存对象,而不是常规缓存变量:
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
fetch('asset-manifest.json').then(response => {
if (response.ok) {
response.json().then(manifest => {
const urls = Object.keys(manifest).map(key => manifest[key]);
urls.push('/');
urls.push('/img/icon.png');
cache.addAll(urls);
});
}
});
})
);
});完成!我们有缓存,但它还没有什么价值,因为我们无法从缓存中检索项目。让我们下一步做。
当我们的应用从服务器请求一个文件时,我们希望在服务工作器内部拦截该请求,并使用缓存的文件(如果存在)进行响应。
我们可以通过侦听 fetch 事件来执行此操作,如图所示:
self.addEventListener('fetch', event => {
});作为参数传入的事件具有两个感兴趣的属性。第一个是event.request,它是目标 URL。我们将使用它来查看缓存中是否有该项,但该事件还有一个名为respondWith的方法,它基本上意味着“停止此网络请求,并使用以下命令响应它。”
这里是不直观的部分——我们实际上是在调用event.respondWith后立即取消这个获取事件。这意味着如果缓存中没有该项,则必须启动另一个获取请求(谢天谢地,这不会触发另一个事件侦听器;这里没有递归)。这是需要记住的。
那么,让我们调用event.respondWith,然后使用caches.match查看是否有与 URL 匹配的文件:
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
});
);
});在本例中,响应将是有问题的文件或 null。如果是文件,我们会退回;否则,我们将发出另一个获取请求并返回其结果。以下是一行版本:
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});就这样!现在,我们的资产清单中的所有文件获取请求都将首先进入缓存,并且只有在所述文件不存在时才发出实际的网络请求。
激活事件是我们三个服务工作器事件中的第一个,所以我们最后讨论它似乎有些奇怪,但是有一个很好的理由。
激活事件是在执行缓存清理时发生的。我们确保清除所有过期的缓存对象,这样我们的浏览器缓存份额就不会变得太混乱并终止。
为此,我们基本上删除了名称与当前值CACHE_NAME不匹配的任何缓存对象。
“但是 Scott,”你说,“如果我们的服务工作器没有正确更新,并且仍然包含旧的CACHE_NAME,该怎么办?”这是一个正确的观点。但是,如前所述,我们的服务工作器应该在它与以前的服务工作器之间存在字节大小的差异时自动更新,所以这不应该是一个问题。
这一次,我们的流程没有那么密集,但我们还是将其细分:
- 抓取缓存名称列表。
- 绕过去。
- 删除密钥与
CACHE_NAME不匹配的任何缓存。
一个快速提醒——如果你想把 CSS 和 JS 分开存放在一个单独的缓存中,你可以有多个缓存。这样做没有真正的好处,但你可能喜欢有条理的事情。一种可行的方法是创建一个CACHE_NAMES对象,如下所示:
const VERSION = ‘v1’
const CACHE_NAMES = {
css: `css-${VERSION}`,
js: `js-${VERSION}`
};然后,在接下来的步骤中,我们必须迭代该对象;只是要记住一些事情。
好了,我们开始工作吧。
同样,在完成这个异步代码时,我们必须执行一个event.waitUntil()。这意味着我们最终必须将一个Promise返回给event.waitUntil(),这将影响我们编写代码的方式。
首先,我们通过调用cache.keys()获取缓存密钥列表,它返回一个承诺:
self.addEventListener('activate', event => {
event.waitUntil(
cache.keys().then(keyList => {
})
);
});我们需要检查每个键,如果它与我们的CACHE_NAME不匹配,则调用caches.delete()。由于我们可能要删除多个缓存,并且多次调用caches.delete(),它本身返回一个Promise,因此我们将映射keyList并使用Promise.all()返回一组承诺。
下面是它的样子:
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keyList => {
Promise.all(keyList.map(key => {
}));
})
);
});删除密钥与CACHE_NAME不匹配的任何缓存。
一个简单的if语句,再调用caches.delete(),我们就完成了:
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keyList => {
Promise.all(
keyList.map(key => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
})
);
})
);
});现在,我们的缓存将精确到我们想要的大小(仅在缓存对象上),并且将在每次服务工作器激活时进行检查。
因此,缓存保持最新状态的内在机制是存在的。每次更新 JavaScript 时,我们都应该在服务工作器中更新版本。这会导致我们的服务工作器进行更新,从而重新激活,从而触发对以前缓存的检查和失效;一个漂亮的系统。
使用**yarn start在本地快速运行应用,检查是否存在任何明显错误(打字错误等),如果一切正常,则启动yarn deploy**。
打开 live 应用和 Chrome 开发工具。关闭“应用|服务工作器”下的“重新加载时更新”,刷新一次,然后转到“网络”选项卡。您应该看到如下内容:
If this doesn't work, try Unregistering any service workers under Application | Service Workers, and then reload twice.
关键点是(来自服务工作器)在我们的 JavaScript 文件旁边。我们的静态资产由我们的服务工作器缓存提供服务,如果您滚动到“网络”选项卡的顶部,您将看到以下内容:
文档本身由服务工作器提供,这意味着我们可以在任何网络条件下运行我们的应用,甚至离线运行;让我们试试看。单击“网络”选项卡顶部的“脱机”复选框,然后单击“重新加载”。
如果一切顺利,即使我们没有网络连接,应用的加载时间也应该没有差别!我们的应用仍然会加载,聊天信息也会加载。
消息加载是 Firebase 数据库的一个优点,这不是我们真正的做法,而是让文档本身从缓存加载,这才是真正的成就!
当然,我们的用户体验设置不适合离线访问。我们应该有一些方法来通知用户他们当前处于脱机状态,也许是通过某种对话,但我们将把这作为一个扩展目标。
我们实现了渐进式的梦想——一个可以在任何网络条件下工作的应用,包括完全没有网络的情况。缓存是一个困难的主题,所以请拍拍自己的背,让它走到今天。
然而,在我们过于激动并将原型提交给 Chatastrophe 董事会之前,让我们确保我们做得正确。我们需要一些方法在我们的项目上打上橡皮图章,上面写着:“批准!这是一个进步的 Web 应用!”。
幸运的是,一家名为谷歌的小型初创公司为我们提供了一个工具,可以做到这一点。
接下来是审计我们完成的渐进式 web 应用,即 victory lap。

