# Navigation API

> window.navigation 把单页应用路由收拢到一处：navigate()、navigate 事件与 intercept()、HTML 标准定义的每个异常，以及 History API 回退方案。

`window.navigation` 是 HTML 标准给「基于 `history.pushState()` 做路由」准备的替代品：文档发起的每一次导航都会触发同一个 `navigate` 事件，`event.intercept()` 把它转成由你的处理函数渲染的同文档切换。同一个对象还以 `navigation.entries()` 暴露会话历史，每个条目带稳定的 key，于是「后退」变成 `navigation.traverseTo(key)`，不必再数 `history.go()` 的偏移量。

Chrome 102 在桌面与 Android 上发布了 `Navigation`，Edge 102 跟进（BCD `api.Navigation`）；`intercept()` 方法在 Chrome 105 到来，取代了 Chrome 102 到 108 存在过的 `transitionWhile()`。Firefox 147 与 macOS、iOS 上的 Safari 26.2 同时发布了 `Navigation`、`NavigateEvent` 和 `intercept()`。Chrome 的 `navigate()` 仍接受 `javascript:` URL，与规范相悖（[Chromium bug 439994590](https://crbug.com/439994590)）。

## 语法

```js
navigation.navigate(url)
navigation.navigate(url, options)
navigation.reload()
navigation.reload(options)
navigation.traverseTo(key)
navigation.traverseTo(key, options)
navigation.back()
navigation.back(options)
navigation.forward()
navigation.forward(options)
navigation.updateCurrentEntry(options)

navigation.addEventListener("navigate", (event) => {
  event.intercept(options);
});
```

`navigate()`、`reload()`、`traverseTo()`、`back()` 与 `forward()` 都返回 `NavigationResult`：一个含 `committed` 与 `finished` 两个 Promise 的普通对象，二者都以到达的 `NavigationHistoryEntry` 兑现。被 `intercept()` 接管的同文档导航中，`committed` 在 URL 与 `navigation.currentEntry` 更新后兑现，`finished` 在所有处理函数的 Promise 完结后兑现。跨文档导航，以及状态码 204、205 或带 `Content-Disposition: attachment` 头的响应，两个 Promise 都不会完结。`intercept()` 返回 `undefined`，只在 `navigate` 事件派发期间有效；`updateCurrentEntry()` 同步返回 `undefined`。

## 参数

`navigate()` 接受一个按文档 base URL 解析的 `url` 字符串，外加可选的 `NavigationNavigateOptions` 字典。`reload()` 接受 `NavigationReloadOptions`，三个遍历方法接受 `NavigationOptions`，`updateCurrentEntry()` 接受 `NavigationUpdateCurrentEntryOptions`，其唯一成员 `state` 为必填。

| 成员 | 所属字典 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `info` | `NavigationOptions`（所有方法） | `any` | 否 | 原样交给随之触发的 `navigate` 事件的 `event.info`，之后丢弃；用来传 UI 提示，例如滑动方向。 |
| `state` | `NavigationNavigateOptions`、`NavigationReloadOptions`、`NavigationUpdateCurrentEntryOptions` | `any`，须可结构化序列化 | 否（`updateCurrentEntry()` 为必填） | 存到新条目上，用 `entry.getState()` 读回。导航最终跨文档时被忽略。 |
| `history` | `NavigationNavigateOptions` | `"auto"`、`"push"` 或 `"replace"` | 否，默认 `"auto"` | `"auto"` 推入新条目，除非这次导航必须是替换（与当前条目 URL 相同、初始的 `about:blank` 文档，或沙箱 iframe 这类只有一个条目的会话历史）；这些情况下指定 `"push"` 会报错。 |

`event.intercept()` 接受 `NavigationInterceptOptions` 字典。多次调用会追加处理函数；后传的 `focusReset` 或 `scroll` 覆盖先传的，Chrome 会在控制台打印一条警告。

| 成员 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `handler` | `() => Promise<undefined>` | 否 | URL 提交后运行。它的 Promise 决定 `finished`、`navigation` 上的 `navigatesuccess` 或 `navigateerror` 事件，以及 `navigation.transition`。 |
| `precommitHandler` | `(controller) => Promise<undefined>` | 否 | URL 变更前运行，可以调用 `controller.redirect(url)`；只有 `event.cancelable` 为 true 时允许。 |
| `focusReset` | `"after-transition"` 或 `"manual"` | 否 | `finished` 完结后焦点是否移到第一个 `autofocus` 元素（没有则移到 `<body>`）。 |
| `scroll` | `"after-transition"` 或 `"manual"` | 否 | `finished` 完结后浏览器是否自动恢复（遍历）或重置（推入）滚动位置；选 `"manual"` 时在内容就绪后自行调用 `event.scroll()`。 |

`NavigateEvent` 本身带着路由决策所需的事实：`canIntercept`、`destination`（含 `url`、`key`、`index`、`sameDocument` 与 `getState()`）、`navigationType`（`"push"`、`"replace"`、`"reload"` 或 `"traverse"`）、`hashChange`、`downloadRequest`、`formData`、`userInitiated`、`signal` 与 `info`。`hasUAVisualTransition` 在 Chrome 118 加入，`sourceElement` 在 Chrome 135 加入（BCD `api.NavigateEvent`）。

## 异常

方法层面的失败以「early error result」形式呈现：`committed` 与 `finished` 以同一个 `DOMException` 拒绝。`intercept()` 与 `scroll()` 同步抛出。

| 异常 | 条件 |
|---|---|
| `SyntaxError` | `navigate()`：`url` 按文档 base URL 解析失败。 |
| `NotSupportedError` | `navigate()`：`url` 使用 `javascript:` 协议，或导航必须是替换却指定了 `history: "push"`。 |
| `DataCloneError` | `navigate()`、`reload()`、`updateCurrentEntry()`：`state` 不可结构化序列化（函数、DOM 节点、`WeakMap`）。 |
| `InvalidStateError` | `navigate()` 与 `reload()`：文档不是 fully active 或正在卸载。`traverseTo()`：没有条目匹配该 key。`back()` 与 `forward()`：`currentEntry.index` 已是 0 或最后一个。`updateCurrentEntry()`：`currentEntry` 为 null。`intercept()`：派发结束后调用、`preventDefault()` 之后调用，或在不可取消的事件上传了 `precommitHandler`。`scroll()`：提交前调用或调用了第二次。 |
| `SecurityError` | `intercept()`：`canIntercept` 为 false（目标跨源，或导航无法转为同文档），或事件不是可信事件。 |
| `AbortError` | `committed` 与 `finished`：导航被更新的导航取代、被用户停止加载，或被 `preventDefault()` 取消；`event.signal` 同一时刻触发 `abort`。 |

`handler()` 的 Promise 被拒绝不会产生上面任何名称：`finished` 以处理函数自己的错误拒绝，`navigation` 带着该错误触发 `navigateerror`。

:::observed
在 Chrome 中对另一个源的目标调用 `event.intercept()`，抛出 `SecurityError: A navigation with URL 'https://other.example/' cannot be intercepted by in a window with origin 'https://app.example' and URL 'https://app.example/'.`（重复的「by in」原样来自源码）。在监听器里 `await` 之后再调用，抛出 `InvalidStateError: intercept() may only be called while the navigate event is being dispatched.`；在第一个条目上调用 `navigation.back()`，两个 Promise 都以 `InvalidStateError: Cannot go back` 拒绝。字符串来自 Chromium 的 [`navigate_event.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/navigation_api/navigate_event.cc) 与 [`navigation_api.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/navigation_api/navigation_api.cc)（chromium.googlesource.com）。
:::

## 示例

三个示例共用一个前提：应用壳已在页面上，其中有一个供各路由填充的 `<main id="view">` 元素。

### 用单个 `navigate` 监听器渲染路由

一个监听器就能看到链接点击、表单提交、`navigation.navigate()` 调用以及前进后退遍历。路由不该接管的情况提前返回：跨源目标、仅片段变化、下载。`handler()` 运行时 URL 已经改变，所以先渲染占位，再把取回的内容换进去。

```js
navigation.addEventListener("navigate", (event) => {
  if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) {
    return;
  }

  const url = new URL(event.destination.url);

  event.intercept({
    async handler() {
      const view = document.querySelector("#view");
      view.setAttribute("aria-busy", "true");
      const response = await fetch(`/fragments${url.pathname}`, { signal: event.signal });
      view.innerHTML = await response.text();
      view.removeAttribute("aria-busy");
    },
  });
});
```

把 `event.signal` 传给 `fetch()`，用户在请求完成前再次导航时请求会被取消；否则迟到的响应会覆盖更新的路由。

### `intercept()` 缺失时回退到 History API

只检测 `window.navigation` 不足以判断实现可用：Chrome 102 到 104 暴露了 `navigation` 却没有 `intercept()`，为 `intercept()` 写的监听器在那里第一次点击就会抛 `TypeError`。检查原型上的方法，否则接上经典的 `click` 加 `popstate` 组合。

```js
const hasNavigationApi =
  "navigation" in window &&
  typeof NavigateEvent !== "undefined" &&
  typeof NavigateEvent.prototype.intercept === "function";

async function render(pathname) {
  const response = await fetch(`/fragments${pathname}`);
  document.querySelector("#view").innerHTML = await response.text();
}

if (hasNavigationApi) {
  navigation.addEventListener("navigate", (event) => {
    if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) return;
    const { pathname } = new URL(event.destination.url);
    event.intercept({ handler: () => render(pathname) });
  });
} else {
  document.addEventListener("click", (event) => {
    const link = event.target.closest("a[href]");
    if (!link || link.origin !== location.origin || event.button !== 0 || event.metaKey || event.ctrlKey) return;
    event.preventDefault();
    history.pushState(null, "", link.href);
    render(link.pathname);
  });
  window.addEventListener("popstate", () => render(location.pathname));
}
```

回退方案拿不到 Navigation API 白送的那部分：表单提交、`location.assign()` 调用，以及落到另一个文档上的遍历都绕过 `click` 监听器，直接整页重载。

### 用 `traverseTo()` 和存储的 state 回到已知条目

`navigation.entries()` 返回每个同源条目，其 `key` 在重载后依然有效，向导式流程因此可以回到第一步而不必知道中间推入了几步。导航时把步骤存进 `state`，目标页就能恢复表单。

```js
const startKey = navigation.currentEntry.key;

async function goToStep(step) {
  const { finished } = navigation.navigate(`/checkout/${step}`, {
    state: { step, startedAt: Date.now() },
  });
  await finished;
}

function restart() {
  if (!navigation.entries().some((entry) => entry.key === startKey)) {
    navigation.navigate("/checkout/1", { history: "replace" });
    return;
  }
  navigation.traverseTo(startKey);
}

navigation.addEventListener("currententrychange", () => {
  const state = navigation.currentEntry.getState();
  if (state?.step) document.querySelector("#step").textContent = String(state.step);
});
```

用户若中途去过别的源再回来，`startKey` 已不在 `entries()` 里，`traverseTo()` 会以 `InvalidStateError` 拒绝；`some()` 检查让 `restart()` 走替换分支。

## 另请参阅

- [View Transitions](/zh/reference/performance/view-transitions/)，多数 Navigation API 路由与 `intercept()` 搭配的动画层
- [Back/forward cache（bfcache）](/zh/reference/performance/bfcache/)，决定跨文档遍历是否恢复旧文档
- [Speculation Rules API](/zh/reference/performance/speculation-rules/)，预取 `navigate` 处理函数即将请求的路由
- [HTML Standard: the navigation API](https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigation-api)（html.spec.whatwg.org）
- [HTML Standard: navigate() method](https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-navigate)（html.spec.whatwg.org）
- [Modern client-side routing: the Navigation API](https://developer.chrome.com/docs/web-platform/navigation-api/)（developer.chrome.com）
- [WICG navigation-api explainer](https://github.com/WICG/navigation-api)（github.com）
- [Chromium bug 439994590: `javascript:` URLs accepted by navigate()](https://crbug.com/439994590)（crbug.com）