# NavigationPreloadManager

> registration.navigationPreload 如何让导航请求与 service worker 启动并行，它的四个成员与异常条件，以及 preloadResponse 与请求头如何配合。

`NavigationPreloadManager` 通过 `registration.navigationPreload` 访问，它让浏览器在启动 service worker 的同一时刻就发出导航的网络请求，而不是等 worker 启动完成再由它调用 `fetch()`；worker 随后从 `FetchEvent.preloadResponse` 读取这份提前到达的响应。它自 Chrome 59、Edge 79、Firefox 99、Safari 15.4 起可用（BCD `api.NavigationPreloadManager`），且仅限安全上下文。

## 语法

```js
// service worker 中（或页面中经由 navigator.serviceWorker.ready）
await registration.navigationPreload.enable();
await registration.navigationPreload.disable();
await registration.navigationPreload.setHeaderValue(value);
const state = await registration.navigationPreload.getState();

// service worker 的 fetch 处理函数内
const preloaded = await event.preloadResponse;   // Response 或 undefined
```

预加载是按注册保存的设置，跨 worker 重启与更新持续有效，所以只需启用一次（通常在 `activate` 中），无需每次启动都重新启用。

## 成员

该管理器有四个方法，都返回 Promise；状态对象有两个字段。

| 成员 | 返回值 | 行为 |
|---|---|---|
| `enable()` | `Promise<void>` | 为该注册开启预加载。之后 scope 内的导航都会并行发出一个带 `Service-Worker-Navigation-Preload` 请求头的 `GET`。 |
| `disable()` | `Promise<void>` | 关闭预加载。此后每次导航的 `event.preloadResponse` 都兑现为 `undefined`。 |
| `setHeaderValue(value)` | `Promise<void>` | 设置预加载请求所带 `Service-Worker-Navigation-Preload` 头的值。默认为 `true`。 |
| `getState()` | `Promise<{ enabled: boolean, headerValue: string }>` | 报告预加载是否开启及当前的头值。 |
| `FetchEvent.preloadResponse` | `Promise<Response \| undefined>` | 预加载开启时导航 `GET` 的预加载响应；子资源、非 GET 请求以及预加载关闭时为 `undefined`。 |

这个头存在的目的，是让服务器可以为预加载请求返回不同的内容（例如 worker 自己渲染壳时只返回 JSON 数据而不是完整 HTML）。按它分流的服务器必须发送 `Vary: Service-Worker-Navigation-Preload`，否则中间的 HTTP 缓存可能把预加载变体交给普通导航。

## 异常

四个方法都以 `DOMException` 或 `TypeError` 拒绝，而不是同步抛出。

- `InvalidStateError`：注册上没有处于 active 状态的 worker。在 `install` 中（worker 仍在安装）调用 `enable()` 会被拒绝；`activate` 是最早的安全时机，页面侧对应的是 `navigator.serviceWorker.ready`。
- `setHeaderValue()` 的 `TypeError`：`value` 不是合法的 HTTP 头值（必须是不含控制字符与换行的字节串）。
- 不是异常：预加载响应未被使用。处理函数返回了缓存响应且从未 `await event.preloadResponse` 时，浏览器已经花掉了这次请求。只有需要消费它时才把 Promise 交给 `event.waitUntil()`；否则要么接受这次浪费的请求，要么对总是从缓存应答的路由关闭预加载。
- 不是异常：浏览器支持该管理器但本注册关闭了预加载时，`event.preloadResponse` 兑现为 `undefined`。假定它一定是 `Response` 的代码会在第一次访问属性时抛出 `TypeError: Cannot read properties of undefined`。

:::observed
在 Chrome（英文界面）中，`registration.navigationPreload.enable()` 兑现之后，刷新受控页面会在 Network 面板看到两条导航请求：预加载那条带有请求头 `Service-Worker-Navigation-Preload: true`（在 **Headers › Request Headers** 下可见），而 worker 自己 `fetch()` 发出的那条没有；调用 `setHeaderValue('v2')` 后，下一次导航的头值变为 `v2`。在 worker 控制台执行 `await registration.navigationPreload.getState()` 返回 `{enabled: true, headerValue: 'v2'}`。
:::

## 示例

三个示例分别覆盖启用、消费与检测；第二个是每个开启了预加载的 worker 都需要的。

### 在 `activate` 中启用预加载

等 worker 进入 active 后再启用，并把调用放在 `waitUntil()` 内，以免激活在设置写入之前就结束。

```js
self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    if (self.registration.navigationPreload) {
      await self.registration.navigationPreload.enable();
    }
  })());
});
```

由于设置保存在注册上，后续不再调用 `enable()` 的 worker 版本仍然处于预加载开启状态；要去掉它，必须显式调用 `disable()`。

### 先用 `preloadResponse`，再回退到 `fetch()`

处理函数优先返回缓存命中，其次是预加载响应，最后才是普通 fetch。预加载的 Promise 总会被 await，确保浏览器提前发出的请求被消费而不是浪费。

```js
self.addEventListener('fetch', (event) => {
  if (event.request.mode !== 'navigate') return;
  event.respondWith((async () => {
    const cached = await caches.match(event.request);
    if (cached) {
      event.waitUntil(event.preloadResponse);     // 让预加载静静完成
      return cached;
    }
    const preloaded = await event.preloadResponse;
    if (preloaded) return preloaded;
    return fetch(event.request);
  })());
});
```

缓存命中后再 await `preloadResponse` 不会拖慢响应；它只是让 worker 存活到预加载落定。忽略尚未完成的预加载会让 Chrome 在页面控制台记录 `The service worker navigation preload request was cancelled before 'preloadResponse' settled. If you intend to use 'preloadResponse', use waitUntil() or respondWith() to wait for the promise to settle.`。

### 检测管理器是否存在，并在没有它时保持 fetch 处理函数正确

Firefox 99 之前与 Safari 15.4 之前的注册对象上没有 `navigationPreload`，Chrome 58 及更早版本中 `event.preloadResponse` 是 `undefined`（而不是 Promise）。下面的写法在这三种状态下都成立。

```js
// activate：只在管理器存在时启用
self.addEventListener('activate', (event) => {
  event.waitUntil(
    self.registration.navigationPreload
      ? self.registration.navigationPreload.enable()
      : Promise.resolve()               // 没有预加载：导航像以前一样等待 worker
  );
});

// fetch：把缺失的 preloadResponse 当作"没有预加载"
async function navigationResponse(event) {
  const preloaded = event.preloadResponse ? await event.preloadResponse : undefined;
  return preloaded ?? fetch(event.request);
}
```

回退的代价是每次导航都要付出 worker 的启动时间，这正是预加载要解决的问题；在决定是否改用缓存优先的应用壳（它会让预加载变得不必要）之前，先在 Performance 面板里测量 worker 的启动时间。

## 另请参阅

- [Service Workers specification: NavigationPreloadManager](https://w3c.github.io/ServiceWorker/#navigationpreloadmanager)（w3c.github.io）
- [Speed up service worker with navigation preloads](https://developer.chrome.com/blog/navigation-preload)（developer.chrome.com）
- [FetchEvent: preloadResponse property](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/preloadResponse)（developer.mozilla.org）
- [FetchEvent 与请求路由](/zh/reference/service-worker/fetch-event/)
- [Service worker 生命周期](/zh/reference/service-worker/lifecycle/)
- [导航预加载与启动性能](/zh/reference/performance/navigation-preload/)
- [Service worker 缓存策略](/zh/reference/service-worker/caching-strategies/)