# Service worker 缓存策略

> Cache First、Network First、Stale-While-Revalidate、Network Only、Cache Only 各返回什么、代价是什么，两个陷阱，以及对应的 Workbox 类。

缓存策略是 service worker 的 `fetch` 处理器用来在 Cache Storage 副本与网络之间取舍、并决定先后顺序的规则。五种有名字的策略覆盖了全部组合；每一种都只是一段很短的 `respondWith()` 函数体，Workbox 在 `workbox-strategies` 中把每种策略封装为一个类。底层原语（`FetchEvent`、`caches.match()`、`cache.put()`）已属 Baseline 广泛可用（Chrome 40、Firefox 44、Safari 11.1；BCD `api.FetchEvent`），所以选择的依据是代价，而不是支持度。

## 工作原理

每种策略对同样三个问题给出不同回答：第一次查找去哪里、查找失败时怎么办、之后是否更新缓存。

| 策略 | 第一次查找 | 未命中或失败时 | 是否更新缓存 | 读者付出的代价 |
|---|---|---|---|---|
| Cache First | `caches.match()` | `fetch()`，然后 `cache.put()` | 仅在未命中时 | 过期条目会一直被返回，直到缓存名改变或条目被删除 |
| Network First | `fetch()` | `caches.match()` | 每次成功都更新 | 每次请求都要等一次完整网络往返，离线时还要加上浏览器判定断网的时间 |
| Stale-While-Revalidate | `caches.match()` | `fetch()` | 总是在后台更新 | 首次之后的每次请求都恰好返回一份过期响应 |
| Network Only | `fetch()` | 失败 | 从不 | 完全没有离线行为 |
| Cache Only | `caches.match()` | 失败 | 从不 | 任何未预缓存的资源都是硬失败 |

两条机制适用于所有会写缓存的策略。`Response` 的 body 是只能读一次的流，处理器必须先 `clone()`，把一份交给 `cache.put()`、另一份返回；漏掉克隆时 Chromium 会抛出 `TypeError: Failed to execute 'put' on 'Cache': Response body is already used`。另外 `fetch()` 只在网络失败时拒绝：`404` 或 `500` 会正常兑现，所以不检查 `response.ok` 就调用 `cache.put()` 的策略会把错误页缓存起来。

Opaque 响应（以 `mode: 'no-cors'` 发起的跨源请求）的 `status` 为 `0`，body 不可读。它们可以被存储，但 Chromium 在配额核算中把每条 opaque 响应按约 7 MB 计入，以防止通过体积推断跨源内容，因此对第三方字体或图片使用 Cache First 会以远超实际传输字节的速度消耗配额。

Network First 还有一个隐藏的延迟问题：设备处于强制门户或已失效的 Wi-Fi 时，`fetch()` 不会很快拒绝，而是等到 TCP 超时。应加一个显式超时（`AbortSignal.timeout()`）并把超时视作未命中，这正是 Workbox `NetworkFirst` 的 `networkTimeoutSeconds` 选项所做的事。

## 示例

下面手写的处理器各实现上表中的一行；最后一个示例给出同样路由的 Workbox 写法。

### 正确克隆的 Stale-While-Revalidate

有缓存副本时立即返回；网络响应为下一次请求刷新缓存。`clone()` 发生在 `put()` 之前，返回的是原始 `Response`。

```js
self.addEventListener('fetch', (event) => {
  if (event.request.destination !== 'image') return;
  event.respondWith((async () => {
    const cache = await caches.open('images-v1');
    const cached = await cache.match(event.request);
    const network = fetch(event.request).then((response) => {
      if (response.ok) cache.put(event.request, response.clone());
      return response;
    });
    event.waitUntil(network.catch(() => {}));   // 让 worker 存活到后台刷新完成
    return cached ?? network;
  })());
});
```

没有 `event.waitUntil(network)` 时，浏览器可能在 `respondWith()` 落定后、后台刷新完成前就终止 worker；`catch` 则避免网络失败变成未处理的拒绝。

### 带超时与离线兜底的 Network First

处理器让网络请求与 3 秒计时器赛跑；超时或失败则回退到缓存，导航请求再进一步回退到预缓存的 `/offline.html`。

```js
self.addEventListener('fetch', (event) => {
  if (event.request.mode !== 'navigate') return;
  event.respondWith((async () => {
    const cache = await caches.open('pages-v1');
    try {
      const response = await fetch(event.request, { signal: AbortSignal.timeout(3000) });
      if (response.ok) cache.put(event.request, response.clone());
      return response;
    } catch {
      return (await cache.match(event.request)) ?? cache.match('/offline.html');
    }
  })());
});
```

`AbortSignal.timeout()` 自 Chrome 103、Firefox 100、Safari 16 起可用（BCD `api.AbortSignal.timeout_static`）；更老的 worker 中改为构造 `AbortController`，在 `setTimeout` 里调用 `abort()`。

### 在依赖策略之前检测 Cache Storage

`caches` 只在安全上下文（HTTPS 与 `localhost`；BCD `api.caches`）中暴露，纯 HTTP 页面同样没有 `navigator.serviceWorker.register()`。依赖预缓存资源的页面应在注册 worker 前检测，worker 则应把缺失的 `caches` 当作 Network Only 处理。

```js
// 页面中
if ('serviceWorker' in navigator && 'caches' in window) {
  navigator.serviceWorker.register('/sw.js');
} else {
  // 没有 Cache Storage：应用只能在线运行，不要宣称支持离线。
}

// worker 中
self.addEventListener('fetch', (event) => {
  if (!('caches' in self)) return;             // 直接放行给网络
  // …策略代码…
});
```

处理器不调用 `respondWith()` 直接返回，浏览器就会像没有 worker 一样执行请求，这是正确的降级行为。

### 用 Workbox 写同样的路由

`workbox-strategies` 中的每个类对应上表的一行，并以选项形式补上克隆、`ok` 检查（通过 `CacheableResponsePlugin`）与超时。

```js
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

registerRoute(({ request }) => request.mode === 'navigate',
  new NetworkFirst({ cacheName: 'pages', networkTimeoutSeconds: 3 }));
registerRoute(({ request }) => request.destination === 'image',
  new StaleWhileRevalidate({ cacheName: 'images' }));
registerRoute(({ url }) => url.pathname.startsWith('/assets/'),
  new CacheFirst({ cacheName: 'assets', plugins: [new CacheableResponsePlugin({ statuses: [200] })] }));
```

不加 `CacheableResponsePlugin` 时，`CacheFirst` 与 `StaleWhileRevalidate` 默认缓存状态为 `200` 或 `0`（opaque）的任何响应；`NetworkFirst` 只缓存 `200`。

:::observed
在 Chrome DevTools（英文界面）中，由上述任一策略应答的请求会在 Network 面板的 **Size** 列显示 `(ServiceWorker)`，worker 自己发出的 `fetch()` 则作为第二行出现，并带有标记"由 service worker 发起"的齿轮图标。未克隆就缓存响应时，worker 控制台报错为 `TypeError: Failed to execute 'put' on 'Cache': Response body is already used`。
:::

## 另请参阅

- [Service Workers specification: FetchEvent](https://www.w3.org/TR/service-workers/#fetchevent)（w3.org）
- [Workbox strategies module](https://developer.chrome.com/docs/workbox/modules/workbox-strategies)（developer.chrome.com）
- [Caching](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Caching)（developer.mozilla.org）
- [Cache API](/zh/reference/service-worker/cache-api/)
- [FetchEvent 与路由](/zh/reference/service-worker/fetch-event/)
- [离线兜底](/zh/reference/service-worker/offline-fallback/)
- [Workbox](/zh/reference/service-worker/workbox/)