# FetchEvent 与请求路由

> service worker 收到的 FetchEvent 成员，respondWith() 与 waitUntil() 的规则及错误结果，以及按 destination、mode 与 URL 路由的写法。

受控客户端发出的每一个导航请求与子资源请求，都会在 service worker 中派发一个 `FetchEvent`；调用 `event.respondWith()` 可以用任意 `Response` 取代网络响应，不调用则请求原样走网络。该接口自 Chrome 40、Edge 17、Firefox 44、Safari 11.1 起可用（BCD `api.FetchEvent`），后来加入的 `preloadResponse` 与 `handled` 在各自条目中说明。

## 语法

```js
self.addEventListener('fetch', (event) => {
  event.respondWith(responsePromise);   // 可选：接管响应
  event.waitUntil(promise);             // 可选：为副作用延长 worker 存活时间
});
```

`respondWith()` 必须在处理函数内同步调用；处理函数返回后，浏览器已经决定是否走网络。`waitUntil()` 则可以在事件仍在处理期间的任何时刻调用，包括在 `respondWith()` 的 Promise 链内部。

## 成员

事件暴露请求对象与客户端标识；两个方法分别控制响应与 worker 的生命周期。

| 成员 | 类型 | 说明 |
|---|---|---|
| `request` | `Request` | 被拦截的请求。`url`、`method`、`headers`、`mode`、`destination`、`credentials` 是路由时用到的属性。 |
| `respondWith(r)` | `void` | 接受 `Response` 或 `Promise<Response>`。Promise 的落定结果就是客户端收到的响应。 |
| `waitUntil(p)` | `void` | 把事件生命周期延长到 `p` 落定，这样在 `respondWith()` 之后发起的缓存写入不会因 worker 被终止而中断。 |
| `clientId` | `string` | 发出请求的客户端 id。导航请求时为空，因为正在导航的客户端尚不存在。 |
| `resultingClientId` | `string` | 导航将要创建的客户端 id。子资源请求时为空。 |
| `replacesClientId` | `string` | 被导航取代的客户端 id（仅同窗口导航）。 |
| `handled` | `Promise<void>` | 响应交付给客户端后兑现；事件以网络错误结束时拒绝（BCD `api.FetchEvent.handled`）。 |
| `preloadResponse` | `Promise<Response \| undefined>` | 导航预加载的响应；预加载未执行时为 `undefined`，见 `NavigationPreloadManager` 条目。 |

`request.destination` 是最可靠的路由键：导航为 `'document'`，另有 `'script'`、`'style'`、`'image'`、`'font'`，而 `fetch()` 与 `XMLHttpRequest` 调用为 `''`。对顶层页面，`request.mode === 'navigate'` 与 `destination === 'document'` 等价，但前者也匹配 iframe，后者对 iframe 为 `'iframe'`。

## 异常

`respondWith()` 同步抛错；响应侧的失败则以网络错误交给客户端，而不是 worker 内的异常。

- `InvalidStateError`：在处理函数返回之后才调用 `respondWith()`，或对同一事件第二次调用。
- 客户端收到网络错误：传给 `respondWith()` 的 Promise 被拒绝，或兑现为一个不是 `Response` 的值。Chromium 在 worker 控制台记录 `The FetchEvent for "<url>" resulted in a network error response: an object that was not a Response was passed to respondWith().`，页面侧则看到 `TypeError: Failed to fetch`（导航请求则是错误页）。
- 客户端收到网络错误：`Response` 的 body 已被使用，例如未 `clone()` 就交给了 `cache.put()`。
- `new Request(event.request, init)` 的 `TypeError`：请求是导航（`mode: 'navigate'`）且 `init` 非空；导航请求需要改头时，改为从 `event.request.url` 构造新的 `Request`。

:::observed
在 Chrome（英文界面）中，处理函数写成 `event.respondWith(fetch(event.request).then(() => undefined))` 时，页面请求在 Network 面板中以 `net::ERR_FAILED` 失败，service worker 控制台显示 `The FetchEvent for "https://example.com/app.js" resulted in a network error response: an object that was not a Response was passed to respondWith().`；这句话的两段都是 Chromium `fetch_respond_with_observer.cc` 中的字符串常量。
:::

## 示例

下面的处理函数都写成只有匹配的分支才调用 `respondWith()`；其他请求全部放行给网络。

### 按 destination 与 URL 前缀路由

图片走缓存优先的辅助函数，API 调用绕过缓存，其余交给浏览器。

```js
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (url.origin !== self.location.origin) return;          // 第三方：不拦截

  if (event.request.destination === 'image') {
    event.respondWith(cacheFirst(event.request, 'images-v1'));
  } else if (url.pathname.startsWith('/api/')) {
    event.respondWith(fetch(event.request));                 // 显式的仅网络
  }
});

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

即使是仅网络的路由，对其他源提前返回也很重要：一旦调用了 `respondWith()`，整个响应（包括 CORS 行为与 opaque 响应）就由 worker 负责。

### 用 `waitUntil()` 为导航返回应用壳

单页应用对每次导航都用缓存的壳作答，并在后台刷新该壳，同时让 worker 为这次刷新保持存活。

```js
self.addEventListener('fetch', (event) => {
  if (event.request.mode !== 'navigate') return;
  event.respondWith(
    caches.match('/index.html').then((cached) => cached ?? fetch(event.request))
  );
  event.waitUntil(
    fetch('/index.html').then((fresh) =>
      fresh.ok ? caches.open('shell-v1').then((c) => c.put('/index.html', fresh)) : undefined
    ).catch(() => {})
  );
});
```

没有 `waitUntil()` 时，浏览器可能在 `respondWith()` 一落定就终止 worker，丢掉后台刷新。`catch` 则避免离线刷新变成未处理的拒绝。

### 检测 service worker 支持，并在没有它时让请求照常工作

没有受控 worker 的页面（首次访问、非安全源、强制刷新）也必须能加载。按条件注册，并且不要让页面代码依赖拦截。

```js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').catch((err) => {
    console.warn('Service worker registration failed; running without offline support', err);
  });
}
// 页面代码在两种情况下都以同样方式调用 fetch('/api/items')：
// worker 只是一层优化，没有 worker 接管时请求同样成立。
```

在 worker 控制页面之前 `navigator.serviceWorker.controller` 为 `null`，Chrome 与 Firefox 中强制刷新（Shift+Reload）之后也是这个状态；读取它的代码必须处理 `null`。

## 另请参阅

- [Service Workers specification: FetchEvent interface](https://w3c.github.io/ServiceWorker/#fetchevent-interface)（w3c.github.io）
- [FetchEvent: respondWith() method](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/respondWith)（developer.mozilla.org）
- [FetchEvent](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent)（developer.mozilla.org）
- [Service worker 缓存策略](/zh/reference/service-worker/caching-strategies/)
- [Cache API](/zh/reference/service-worker/cache-api/)
- [NavigationPreloadManager](/zh/reference/service-worker/navigation-preload/)
- [离线兜底](/zh/reference/service-worker/offline-fallback/)