# 离线策略

> 让 PWA 在无网络时仍能加载与使用：预缓存应用外壳、按请求类型路由 fetch、清理旧缓存，并提供离线回退页。

完成本指南后，断网状态下你的应用照样能加载并保持可用，因为一个 Service Worker 会为每类请求从正确的地方
作答：应用本身用缓存的外壳，数据走网络并以缓存回退，两者都未命中的导航则返回一张存好的离线页。
并不存在单一的「离线模式」；离线是一组按请求做出的决定：何时信任缓存、何时信任网络。

你需要一个已注册的 Service Worker（没有的话先做[入门](/zh/guides/getting-started/)），并了解 `install`
与 `activate` 何时触发，见
[Service worker 生命周期：install、activate 与更新](/zh/reference/service-worker/lifecycle/)。
下面的代码是一份完整的 `sw.js`，每一步往里加一个处理函数。

## 在 install 时预缓存外壳

在 `install` 中打开一个带版本号的缓存，用 `addAll()` 放入每个视图都需要的 HTML、CSS 与 JavaScript，
再加上离线页。缓存名里的版本号是下一次发布得以整体替换的依据。只要有一个文件抓取失败，`addAll()` 就会
拒绝，而 `waitUntil()` 收到被拒绝的 Promise 会放弃本次安装，所以只列出你能控制的文件。

```js
const VERSION = 'v3';
const SHELL_CACHE = `shell-${VERSION}`;
const DATA_CACHE = `data-${VERSION}`;
const SHELL = ['/', '/app.css', '/app.js', '/offline.html'];

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL)));
});
```

带哈希文件名的外壳文件可以在缓存存活期间一直缓存优先；不带哈希的 `/app.js` 只在 `VERSION` 改变、
worker 重新安装时才更新。

## 按请求类型路由每个 fetch

逐请求决定。导航请求先走网络让用户看到最新 HTML，网络失败时用缓存的外壳。外壳资源缓存优先：即时响应，
由下一个 worker 版本更新。API 响应网络优先、以最近一次成功的副本回退，代价是在线时每次请求都要等一次
完整的网络往返。图片与字体用 stale-while-revalidate：缓存副本立即返回，后台抓取刷新它，所以可能有一次
请求拿到过期内容。

```js
self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return; // POST 之类直接走网络

  const url = new URL(request.url);
  if (request.mode === 'navigate') {
    event.respondWith(networkFirst(request, SHELL_CACHE, '/offline.html'));
  } else if (SHELL.includes(url.pathname)) {
    event.respondWith(caches.match(request).then((hit) => hit || fetch(request)));
  } else if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request, DATA_CACHE));
  } else if (request.destination === 'image' || request.destination === 'font') {
    event.respondWith(staleWhileRevalidate(request, DATA_CACHE));
  }
});

async function networkFirst(request, cacheName, fallbackPath) {
  const cache = await caches.open(cacheName);
  try {
    const fresh = await fetch(request);
    if (fresh.ok) cache.put(request, fresh.clone());
    return fresh;
  } catch {
    const cached = await cache.match(request);
    if (cached) return cached;
    if (fallbackPath) return cache.match(fallbackPath);
    throw new Error(`offline and ${request.url} is not cached`);
  }
}

async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  const refresh = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  });
  return cached || refresh;
}
```

`cache.put()` 会消耗响应体，所以代码先 `clone()` 再存，返回原始响应。各策略的取舍以及与 Workbox 的对应关系见
[选择缓存策略](/zh/guides/offline/caching-strategies/)。

## 在 activate 时删除旧缓存

`activate` 在新 worker 接管页面、旧 worker 退出之后运行，这是删除上一版本缓存的安全时机。缓存在整个源内共享，
所以按自己的前缀匹配，而不是全部删除。这个处理函数要短：fetch 会排队等待过长的激活。

```js
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((name) => (name.startsWith('shell-') || name.startsWith('data-')) && !name.endsWith(VERSION))
          .map((name) => caches.delete(name)),
      ),
    ),
  );
});
```

## 用 Background Sync 排队失败的写入

`networkFirst` 对 `GET` 返回的是旧数据；失败的 `POST` 需要重试。在存在 `ServiceWorkerRegistration.sync`
的地方，把请求体存进 IndexedDB 并注册一个 sync 标签；网络恢复时浏览器触发 `sync`。该 API 只有 Chromium
实现（版本见 [Background Sync 后台同步支持](/zh/compatibility/background-sync/)），所以注册抛错时页面也要能工作。

```js
// 页面中，POST 失败之后：
async function queueForSync(tag) {
  const registration = await navigator.serviceWorker.ready;
  if (!('sync' in registration)) return false; // 改为提示「已保存到本地，稍后重试」
  try {
    await registration.sync.register(tag);
    return true;
  } catch {
    return false;
  }
}

// sw.js 中：
self.addEventListener('sync', (event) => {
  if (event.tag === 'outbox') event.waitUntil(replayOutbox(event.lastChance));
});
```

`event.lastChance` 在浏览器的最后一次重试时为 `true`，这是告诉用户写入已丢弃的时机。

## 预缓存大文件前先查存储预算

每个缓存都计入源的配额，而逐出可能一次清空全部。`navigator.storage.estimate()` 返回保守估计的 `quota`
与当前 `usage`（单位字节）；因为压缩与去重，数字只是近似值。对必须保留的数据请求
`navigator.storage.persist()`，见[存储持久化、配额与逐出](/zh/reference/storage/persistence/)。

```js
const { usage, quota } = await navigator.storage.estimate();
console.log(`${(usage / 1048576).toFixed(1)} MB used of ${(quota / 1048576).toFixed(0)} MB`);
```

## 断网重新加载

先在线加载一次应用让 `install` 跑完，然后在 Chrome DevTools 打开 **Network** 面板，在 **Disable cache**
复选框旁的 **Network throttling** 下拉菜单中选择 **Offline**（英文界面），再重新加载。外壳从 `shell-v3`
绘制，`/api/` 请求返回最近缓存的副本，导航到未缓存的 URL 时显示 `/offline.html`。也要从冷启动测试已安装的
应用；热标签页的内存里早已备好一切。

:::observed
在 Chrome DevTools 中选中 **Offline** 后，**Network** 标签旁会出现一个警告图标，Service Worker 没有从缓存作答的
每个请求都会在 Console 记录为 `Failed to load resource: net::ERR_INTERNET_DISCONNECTED`。
:::

## 另请参阅

- [选择缓存策略](/zh/guides/offline/caching-strategies/)
- [Service worker 生命周期：install、activate 与更新](/zh/reference/service-worker/lifecycle/)
- [Service worker 离线兜底](/zh/reference/service-worker/offline-fallback/)
- [存储持久化、配额与逐出](/zh/reference/storage/persistence/)
- [The Offline Cookbook](https://web.dev/articles/offline-cookbook)（web.dev）
- [Background Synchronization API](https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API)（developer.mozilla.org）
- [StorageManager: estimate() method](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/estimate)（developer.mozilla.org）

← 返回[指南](/zh/guides/)总览。