# Service worker 离线兜底

> 当缓存和网络均不可用时，如何使用 service worker fetch 事件和预缓存的兜底响应，为用户提供有意义的离线页面。

**一句话：** 离线兜底是一个预缓存的 HTML 页面（或 API 响应），当导航请求失败且所请求的 URL 没有缓存版本时，service worker 会返回它——让用户看到有用的信息，而非浏览器错误页面。

## 为什么需要兜底页面

当 Network First 策略失败（无网络且该 URL 无缓存）时，service worker 有两种选择：

1. 放行请求——浏览器显示自己的离线错误页面（对用户来说是死路一条）。
2. 返回预缓存的兜底页面——一个品牌化、有帮助的响应，告知用户当前处于离线状态。

兜底页面不能替代逐 URL 的缓存；它是从未加载过的 URL 或缓存条目已过期时的安全网。

## 预缓存兜底页面

在 `install` 事件期间预缓存离线页面，确保它始终可用：

```js
const FALLBACK_URL = '/offline.html';
const CACHE_NAME = 'offline-fallback-v1';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.add(FALLBACK_URL))
  );
});
```

对于单页应用，兜底页面通常是 `/index.html`（应用外壳），它已被预缓存。如果外壳本身能渲染出有用的"您当前离线"状态，就无需单独的离线页面。

## 提供兜底响应

仅对网络和缓存均失败的导航请求返回兜底页面：

```js
self.addEventListener('fetch', (event) => {
  if (event.request.mode !== 'navigate') return;

  event.respondWith(
    fetch(event.request).catch(() =>
      caches.match(event.request).then(
        (cached) =>
          cached ?? caches.open(CACHE_NAME).then((c) => c.match(FALLBACK_URL))
      )
    )
  );
});
```

这个模式的流程：
1. 尝试网络请求。
2. 失败时，检查该 URL 是否有缓存版本。
3. 仍无结果时，返回通用离线兜底页面。

## 图片和字体的兜底

对于非导航请求，可以返回兜底 SVG 或占位符，而非网络错误：

```js
const IMAGE_FALLBACK = '/fallback-image.svg';

self.addEventListener('fetch', (event) => {
  if (event.request.destination === 'image') {
    event.respondWith(
      fetch(event.request).catch(() => caches.match(IMAGE_FALLBACK))
    );
  }
});
```

## API 兜底响应

对于 JSON API 请求，返回结构化的兜底内容而非 HTML 错误页面：

```js
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request).catch(() =>
        new Response(JSON.stringify({ error: 'offline' }), {
          headers: { 'Content-Type': 'application/json' },
          status: 503,
        })
      )
    );
  }
});
```

## 使用 Workbox

Workbox 的 `setCatchHandler` 是处理未匹配 fetch 失败的惯用方式：

```js
import { setCatchHandler, NavigationRoute, registerRoute } from 'workbox-routing';
import { precacheAndRoute, matchPrecache } from 'workbox-precaching';

// 将兜底页面作为清单的一部分预缓存
precacheAndRoute(self.__WB_MANIFEST); // 包含 /offline.html

// 对任何失败的导航请求返回离线页面
setCatchHandler(async ({ event }) => {
  if (event.request.destination === 'document') {
    return matchPrecache('/offline.html');
  }
  return Response.error();
});
```

## 设计离线页面

一个好的离线兜底页面应当：

- **清晰告知用户处于离线状态**——不要让用户猜测为何内容加载失败。
- **无需网络即可正常运行**——避免需要 API 调用的脚本。页面必须仅凭其预缓存的 HTML 即可渲染。
- **体现产品的视觉风格**——不要使用通用的浏览器错误样式。
- **在条件允许时链接到已缓存的内容**（例如"您最近访问过的页面"）。

## 实践清单

- [ ] 在 `install` 中用 `event.waitUntil(cache.add('/offline.html'))` 预缓存兜底页面。
- [ ] 通用兜底仅用于导航请求（`mode === 'navigate'`）；子资源失败应静默降级或返回类型化兜底内容。
- [ ] 通过 DevTools → 网络 → "离线"并导航到未缓存的 URL 来测试兜底效果。
- [ ] 对于 SPA，确认应用外壳本身能优雅地处理离线状态，无需单独的离线页面。
- [ ] 为兜底缓存名称加版本号，并在 `activate` 中与其他旧缓存一并清理。

## 相关参考

- [缓存策略](/zh/reference/service-worker/caching-strategies/) — Network First 是最常与兜底搭配使用的策略
- [Cache API](/zh/reference/service-worker/cache-api/) — 预缓存用 `cache.add()`；请求时用 `caches.match()`
- [Workbox](/zh/reference/service-worker/workbox/) — `setCatchHandler` 和 `matchPrecache`