缓存

当用户打开一个网站并与之交互时,网站所需的所有资源,包括 HTML、JavaScript、CSS、图像、字体以及应用明确请求的任何数据,都是通过发出 HTTP(S) 请求来获取的。PWA 最基本的功能之一是能够将应用的某些资源明确地缓存在设备上,这意味着无需向网络发送请求即可获取这些资源。

在本地缓存资源主要有两个好处:离线操作响应速度

  • 离线操作:缓存使 PWA 能够在设备没有网络连接的情况下,或多或少地正常工作。
  • 响应速度:即使设备在线,如果 PWA 的用户界面是从缓存而不是从网络获取的,其响应速度通常会快得多。

当然,其主要缺点是新鲜度:对于需要保持最新的资源,缓存就不那么适用了。此外,对于某些类型的请求,例如 POST 请求,缓存是绝对不适用的。

这意味着是否以及何时缓存某个资源,很大程度上取决于该资源本身,因此一个 PWA 通常会对不同的资源采用不同的策略。在本指南中,我们将探讨一些常见的 PWA 缓存策略,并了解哪种策略适用于哪种资源。

缓存技术概述

PWA 建立缓存策略所依赖的主要技术是 Fetch APIService Worker APICache API

Fetch API

Fetch API 定义了一个用于获取网络资源的全局函数 fetch(),以及代表网络请求和响应的 RequestResponse 接口。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 会在其 installfetch 事件处理程序中将资源添加到缓存中。

何时缓存资源

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 事件处理程序中预缓存静态资源:

js
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 事件处理程序如下所示:

js
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 之外的所有资源都实施“缓存优先,同时刷新缓存”策略。

js
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”路径下所有资源的请求使用“网络优先”策略。

js
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 在运行,因此旧的缓存数据不再需要。

另见