系统设计 / 路由设计 · 稳定入口与可变目标 / Navigation API · 一个事件接住所有导航 待审核 11 / 23

Navigation API · 一个事件接住所有导航

用 History API 搭 client router(见客户端路由)时留下三处不便:pushState 改了栈却不发 popstate,得自己补渲染;站内 <a> 点击要手动拦截;读不到完整的历史条目列表,只有一个 history.length 数字。Navigation API 是为路由量身重做的一代——链接点击、表单提交、navigate()、前进后退,所有导航统一汇成一个 navigate 事件,用 e.intercept({ handler }) 一处接管,还能直接读 navigation.entries()

它已进入各大浏览器:Chrome 与 Edge 102+(2022-05)、Firefox 147+(2026-01)、Safari 26.2+(2025-12),BCD 里也已不再标记为实验特性(兼容性据 MDN BCD,核对于 2026-08)。本页几个 lab 都在真实调用浏览器里的 window.navigation;浏览器偏旧时 demo 会给出提示,代码面板与对照表照常可读。

图 0-1 · 按钮、普通链接与前进后退全部流经同一个 navigate 监听器。可看右侧事件日志的 navigationType 如何分类,以及左侧 navigation.entries() 实时增长。注意那条链接只是个 <a href>、没绑任何 click 监听,照样被接住。

那条链接是这套 API 相对 History API 最直接的简化:不必再到处 e.preventDefault()<a>,一个事件就把链接、表单与前进后退统一接住。

1 · 它解决了 History API 的什么

History API 是操作历史栈的低层动作,Navigation API 是为客户端路由设计的高层模型。
这件事 History API Navigation API
感知一次导航 pushState 不发 popstate,得在调用处自己补 统一的 navigate 事件,push / 前进后退都发
拦截 <a> 点击 手动 click + preventDefault + 判同源 同一个 navigate 事件就接住了
异步导航 / loading 自己拼 pending、自己防竞态 intercept({ handler }) 收一个 async 函数,内建 pending / 提交时机
读历史条目 只有 history.length 一个数字 navigation.entries() 拿到完整条目数组 + currentEntry
能不能后退 没有可靠 API,只能猜 navigation.canGoBackcanGoForward

核心是一个 navigate 监听器接住一切,intercept 收一个 async handler。

// 一个监听器, 接住链接点击 / navigate() / 前进后退 —— 全都来这儿
navigation.addEventListener('navigate', (e) => {
  if (!e.canIntercept || e.downloadRequest !== null) return;   // 外链/下载: 放浏览器自己处理
  e.intercept({                       // ← 软处理: URL 更新, 但不整页刷新
    async handler() {
      const url = e.destination.url;
      const data = await loadData(url);   // 异步取数据, pending 状态浏览器替你管
      render(url, data);                  // 数据到位才提交渲染
    },
  });
  // e.navigationType: 'push' | 'replace' | 'traverse' | 'reload'
});

// 主动导航 / 前进后退, 也都会触发上面那个 navigate 事件
navigation.navigate('/products/42');     // push (默认)
navigation.navigate('/login', { history: 'replace' });
navigation.back();  navigation.forward();

// 完整历史 + 能力查询, History API 都给不了
navigation.entries();      // → 全部条目数组
navigation.currentEntry;   // → 当前条目 (含 .index / .key / .url)
navigation.canGoBack;      // → true / false

建议 ·客户端路由对照着看:那台 History API router 要写两个入口(popstate 加手动拦 <a>)、还要自己防异步竞态,Navigation API 则收敛成一个 navigate 监听器,intercept 同时把 async handler、pending 与提交时机都管了。心智模型没变,还是那套「条目加指针」的 history 栈,变的是浏览器终于给了路由一套专门设计的原生接口。

2 · 异步导航的两段时序

navigate()reload() 都返回 { committed, finished } 两个 Promise:committed 在 URL 与新条目就绪时 resolve,finishedintercept 的 handler 跑完时 resolve。handler 收到的 e.signal 是一个 AbortSignal,下一次导航会自动 abort 上一次未完成的 handler,把它透传给 fetch 即可天然防竞态。

图 2-1 · 异步导航的两段时序。可点快速连点,看前一次如何被中断、finished 如何 reject 出 AbortError

两者各管一段:想在 URL 一变就更新地址栏或高亮当前导航项,等 committed;想在数据真正到位后再收尾(埋点、解除 loading),等 finished。配套的全局事件 navigatesuccessnavigateerror 是同一时机的文档级版本,任意一次软导航成功或失败都会派发,适合挂全局 loading 指示与错误提示。

图 0-1 里那几个控件演示的都是 History API 给不了的能力。navigate(url, { state }) 给每条历史条目挂一份 structured clone 的 state,后退与前进时 currentEntry.getState() 跟着条目回来,它不进 URL、刷新也不丢。updateCurrentEntry({ state }) 原地改当前条目的 state,栈长度不变、不新增条目,只触发 currententrychange。而那个 <form method="post"> 没绑 submit,提交照样进 navigate 事件并带着 e.formData——表单和链接是同一套入口。

3 · scroll 与 focusReset

intercept() 除了 handler 还收两个与无障碍相关的选项,默认都是 'auto',由浏览器把 SPA 软导航做得像真导航:push 与 replace 后滚到锚点或页首、traverse 与 reload 后恢复离开时的滚动位置,焦点则移到首个 [autofocus] 元素或 <body>。需要自己掌控时机(如等数据到位再滚)就设 'manual'

这两项是相比 History API 的隐性收益:软导航默认就有正确的滚动与焦点行为,无障碍体验贴近真导航。
选项 'auto'(默认) 'manual'
scroll push 与 replace 滚到锚点或页首;traverse 与 reload 恢复原滚动位置 浏览器不自动滚,由 handler 内择机调 e.scroll()
focusReset 导航后焦点移到首个 [autofocus],没有则 <body> 焦点保持不动,自行管理,如保留在搜索框

intercept 的完整形态如下。

navigation.addEventListener('navigate', (e) => {
  if (!e.canIntercept) return;
  e.intercept({
    scroll: 'manual',          // 默认 'auto': 自动滚到锚点/页首、traverse 时恢复位置
    focusReset: 'manual',      // 默认 'auto': 焦点移到 [autofocus] 或 <body>
    async handler() {
      const res = await fetch(e.destination.url, { signal: e.signal });  // 下次导航自动 abort
      render(await res.json());
      e.scroll();              // manual 模式下, 数据到位后再自己触发滚动
    },
  });
});

4 · 事件家族

前三个是一次导航的生命周期(开始、成功、失败);currententrychange 是当前指针变了的事后通知;dispose 则是某个条目的谢幕。
事件 挂在哪 何时派发
navigate navigation 任意导航发生前——唯一可 intercept / preventDefault 的入口
navigatesuccess navigation intercept 的 handler 完成后 (软导航成功)
navigateerror navigation handler 抛错 / 被 abort——事件带 .error
currententrychange navigation 当前条目变化后 (含 updateCurrentEntry);导航已完成,不能再拦
dispose 单个 NavigationHistoryEntry 该条目被移出历史时,如 push 截断了前进历史

5 · API 速查

window.navigation 的方法、属性与条目,本页 lab 用到的都在下面。

// 方法
navigation.navigate(url, { state, history: 'push' | 'replace', info });
navigation.reload({ state, info });
navigation.traverseTo(key);               // 跳到指定 key 的条目, 比 go(n) 更稳
navigation.back();  navigation.forward();
navigation.updateCurrentEntry({ state }); // 原地改 state, 不导航

// 属性
navigation.currentEntry;                  // 当前 NavigationHistoryEntry
navigation.entries();                     // 全部条目快照数组
navigation.canGoBack / canGoForward;      // 能否前进 / 后退
navigation.transition;                    // 进行中的导航 (.finished); 空闲为 null

// NavigationHistoryEntry
entry.url / entry.key / entry.index;      // key 跨 state 变化不变, index 可能随截断改变
entry.getState();                         // 取该条目的 structured-clone state
entry.addEventListener('dispose', fn);    // 条目被移出历史时

6 · 剩下的边界

警示 · Navigation API 收掉了 History API 的大半痛点,但仍有四处边界。首屏不触发:navigate 事件不会为初始页面加载派发,首屏渲染仍要在脚本里手动跑一次。只管单个 frame:一个 navigation 对应一个 frame,顶层或某个 iframe,不跨 frame。不能重排条目:只能 push、replace、traverse,无法程序化插入、删除或重排历史条目。URL 先变、内容后到:handler 跑完前 URL 已切换,异步加载期间应立即给出占位而非空白。

守卫的取消与改道、异步导航的 pending,在 Navigation API 里分别对应 e.preventDefault()intercept 的 async handler,见软导航