缓存
当用户打开一个网站并与之交互时,网站所需的所有资源,包括 HTML、JavaScript、CSS、图像、字体以及应用明确请求的任何数据,都是通过发出 HTTP(S) 请求来获取的。PWA 最基本的功能之一是能够将应用的某些资源明确地缓存在设备上,这意味着无需向网络发送请求即可获取这些资源。
在本地缓存资源主要有两个好处:离线操作和响应速度。
- 离线操作:缓存使 PWA 能够在设备没有网络连接的情况下,或多或少地正常工作。
- 响应速度:即使设备在线,如果 PWA 的用户界面是从缓存而不是从网络获取的,其响应速度通常会快得多。
当然,其主要缺点是新鲜度:对于需要保持最新的资源,缓存就不那么适用了。此外,对于某些类型的请求,例如 POST 请求,缓存是绝对不适用的。
这意味着是否以及何时缓存某个资源,很大程度上取决于该资源本身,因此一个 PWA 通常会对不同的资源采用不同的策略。在本指南中,我们将探讨一些常见的 PWA 缓存策略,并了解哪种策略适用于哪种资源。
缓存技术概述
PWA 建立缓存策略所依赖的主要技术是 Fetch API、Service Worker API 和 Cache API。
Fetch API
Fetch API 定义了一个用于获取网络资源的全局函数 fetch(),以及代表网络请求和响应的 Request 和 Response 接口。fetch() 函数接受一个 Request 对象或一个 URL 作为参数,并返回一个 Promise,该 Promise 会兑现为一个 Response 对象。
fetch() 函数在 Service Worker 和应用主线程中都可用。
Service Worker API
Service Worker 是 PWA 的一部分:它是一个独立的脚本,运行在自己的线程中,与应用的主线程分离。
一旦 Service Worker 被激活,每当应用请求一个由该 Service Worker 控制的网络资源时,浏览器就会在 Service Worker 的全局作用域中触发一个名为 fetch 的事件。这个事件不仅针对主线程发出的显式 fetch() 调用,也针对浏览器在页面导航后为加载页面和子资源(如 JavaScript、CSS 和图像)而发出的隐式网络请求。
通过监听 fetch 事件,Service Worker 可以拦截请求并返回一个自定义的 Response。特别是,它可以返回本地缓存的响应,而不是总是访问网络;或者在设备离线时返回本地缓存的响应。
Cache API
Cache 接口为 Request/Response 对提供了持久化存储。它提供了添加和删除 Request/Response 对的方法,以及查找与给定 Request 匹配的缓存 Response 的方法。缓存既可以在应用主线程中使用,也可以在 Service Worker 中使用:因此,一个线程可以将响应添加到缓存中,而另一个线程可以检索它。
最常见的情况是,Service Worker 会在其 install 或 fetch 事件处理程序中将资源添加到缓存中。
何时缓存资源
PWA 可以在任何时候缓存资源,但在实践中,大多数 PWA 会在以下几个时机选择缓存资源:
-
在 Service Worker 的
install事件处理程序中(预缓存):当 Service Worker 安装时,浏览器会在 Service Worker 的全局作用域中触发一个名为install的事件。此时,Service Worker 可以预缓存资源,即从网络获取它们并存储在缓存中。注意:Service Worker 的安装时间与 PWA 的安装时间不同。Service Worker 的
install事件在 Service Worker 下载并执行后立即触发,这通常在用户访问你的网站时就会发生。即使用户从未将你的网站作为 PWA 安装,其 Service Worker 也会被安装和激活。
-
在 Service Worker 的
fetch事件处理程序中:当 Service Worker 的fetch事件触发时,Service Worker 可能会将请求转发到网络,并缓存得到的响应。这可能发生在缓存中尚无响应时,或为了用更新的响应来更新缓存时。 -
响应用户请求:PWA 可能会明确邀请用户下载资源以便稍后使用,届时设备可能处于离线状态。例如,音乐播放器可能会邀请用户下载曲目以便稍后播放。在这种情况下,应用主线程可以获取资源并将响应添加到缓存中。特别是当请求的资源很大时,PWA 可能会使用 后台获取 API,此时响应将由 Service Worker 处理,并将其添加到缓存中。
-
定期地:使用定期后台同步 API,Service Worker 可以定期获取资源并缓存响应,以确保 PWA 即使在设备离线时也能提供相当新的响应。
缓存策略
缓存策略是一种算法,用于决定何时缓存资源、何时提供缓存的资源以及何时从网络获取资源。在本节中,我们将总结一些常见的策略。
这并非详尽无遗的列表:它只是为了说明 PWA 可以采取的各种方法。
缓存策略需要在离线操作、响应速度和新鲜度之间取得平衡。不同的资源在这方面有不同的要求:例如,应用的基本 UI 可能相对静态,而显示产品列表时,拥有最新的数据可能至关重要。这意味着一个 PWA 通常会对不同的资源采用不同的策略,一个 PWA 可能会使用这里描述的所有策略。
缓存优先
在此策略中,我们将预缓存一些资源,然后仅对这些资源实施“缓存优先”策略。也就是说:
- 对于预缓存的资源,我们将:
- 在缓存中查找资源,如果找到就返回该资源。
- 否则,访问网络。如果网络请求成功,则将资源缓存起来以备下次使用。
- 对于所有其他资源,我们将始终访问网络。
对于 PWA 确定需要、在本应用版本中不会改变,且需要尽快获取的资源,预缓存是一种合适的策略。这包括,例如,应用的基本用户界面。如果这些被预缓存,那么应用的 UI 可以在启动时无需任何网络请求即可渲染。
首先,Service Worker 在其 install 事件处理程序中预缓存静态资源:
const cacheName = "MyCache_1";
const precachedResources = ["/", "/app.js", "/style.css"];
async function precache() {
const cache = await caches.open(cacheName);
return cache.addAll(precachedResources);
}
self.addEventListener("install", (event) => {
event.waitUntil(precache());
});
在 install 事件处理程序中,我们将缓存操作的结果传递给事件的 waitUntil() 方法。这意味着如果缓存因任何原因失败,Service Worker 的安装也会失败;反之,如果安装成功,Service Worker 就可以确定资源已被添加到缓存中。
fetch 事件处理程序如下所示:
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open("MyCache_1");
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
return Response.error();
}
}
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (precachedResources.includes(url.pathname)) {
event.respondWith(cacheFirst(event.request));
}
});
我们通过调用事件的 respondWith() 方法来返回资源。如果我们对某个请求不调用 respondWith(),那么该请求就会被发送到网络,就好像 Service Worker 没有拦截它一样。因此,如果一个请求没有被预缓存,它就会直接访问网络。
当我们将 networkResponse 添加到缓存时,必须克隆该响应,并将副本添加到缓存中,同时返回原始响应。这是因为 Response 对象是流式的,所以只能被读取一次。
你可能会想,为什么我们要为预缓存的资源回退到网络。既然它们被预缓存了,我们难道不能确定它们一定在缓存中吗?原因是缓存有可能被清除,无论是被浏览器还是被用户。虽然这种情况不太可能发生,但如果发生了,PWA 除非能回退到网络,否则将无法使用。请参阅删除缓存数据。
缓存优先,同时刷新缓存
“缓存优先”的缺点是,一旦一个响应进入缓存,直到安装新版本的 Service Worker,它就再也不会被刷新。
“缓存优先,同时刷新缓存”策略,也称为“stale-while-revalidate”,与“缓存优先”类似,不同之处在于我们总是向网络发送请求,即使在缓存命中后也是如此,并用网络响应来刷新缓存。这意味着我们获得了“缓存优先”的响应速度,同时也能得到一个相当新的响应(只要请求被相当频繁地发出)。
当响应速度很重要,且新鲜度有一定重要性但并非至关重要时,这是一个很好的选择。
在这个版本中,我们对除 JSON 之外的所有资源都实施“缓存优先,同时刷新缓存”策略。
function isCacheable(request) {
const url = new URL(request.url);
return !url.pathname.endsWith(".json");
}
async function cacheFirstWithRefresh(request) {
const fetchResponsePromise = fetch(request).then(async (networkResponse) => {
if (networkResponse.ok) {
const cache = await caches.open("MyCache_1");
cache.put(request, networkResponse.clone());
}
return networkResponse;
});
return (await caches.match(request)) || (await fetchResponsePromise);
}
self.addEventListener("fetch", (event) => {
if (isCacheable(event.request)) {
event.respondWith(cacheFirstWithRefresh(event.request));
}
});
请注意,我们是异步更新缓存(在 then() 处理程序中),因此应用不必等待网络响应接收完毕就可以使用缓存的响应。
网络优先
我们要看的最后一个策略是“网络优先”,它是“缓存优先”的逆过程:我们尝试从网络获取资源。如果网络请求成功,我们返回响应并更新缓存。如果失败,我们再尝试缓存。
这对于那些需要获取尽可能新的响应,但有缓存资源总比没有好情况下的请求很有用。例如,消息应用的最近消息列表可能就属于这一类。
在下面的示例中,我们对获取应用“inbox”路径下所有资源的请求使用“网络优先”策略。
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open("MyCache_1");
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
return cachedResponse || Response.error();
}
}
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname.match(/^\/inbox/)) {
event.respondWith(networkFirst(event.request));
}
});
仍然有些请求,没有响应比一个可能过时的响应要好,对于这些请求,只有“仅网络”策略是合适的。例如,如果一个应用正在显示可用产品列表,如果列表是过时的,会让用户感到沮丧。
删除缓存数据
缓存的存储空间有限,如果超出限制,浏览器可能会驱逐应用的缓存数据。具体的限制和行为因浏览器而异:详情请参阅存储配额和驱逐标准。在实践中,缓存数据被驱逐的情况非常罕见。用户也可以随时清除应用的缓存。
PWA 应该在 Service Worker 的 activate 事件中清理其缓存的任何旧版本:当此事件触发时,Service Worker 可以确定没有先前版本的 Service Worker 在运行,因此旧的缓存数据不再需要。
另见
- Service Worker API
- Fetch API
- 存储配额和逐出标准
- Service Worker 缓存策略,来自 developer.chrome.com (2021)
- 离线指南,来自 web.dev (2020)