你以为 SPA 路由只能靠框架?十年来每个人都在重写同一套代码——今天这件事被 Navigation API 彻底原生化了

写过三年 React Router 或 Vue Router,你有没有想过一件事:每个框架的 Router 底层都在写同一套代码。这件事今天被 Navigation API 从根上原生化了。

问题:200 行代码,每个框架都在写

History API 是 2013 年就有的接口,但它的设计是给简单场景用的。当你想做真正的 SPA 路由时会发现:没有点击拦截事件、没有统一的导航拦截能力、异步加载和 URL 更新的时序要全部自己编排——pushState 之后如果用户点了后退,你的 UI 和浏览器 URL 可能瞬间脱节。

所以 React Router 要写这套:

document.addEventListener("click", (e) => {
  const link = e.target.closest("a[href]");
  if (!link) return;
  if (link.target === "_blank") return;
  if (link.href.startsWith("mailto:")) return;
  if (link.host !== location.host) return;
  if (e.metaKey || e.ctrlKey) return;
  // 20 more edge cases...
  e.preventDefault();
  history.pushState(null, "", link.href);
  handleRoute(link.href);
});

window.addEventListener("popstate", handlePopState);

每个 Router 都在写这套 150-200 行的逻辑,每个版本都在修同样的 bug。Sam Thorogood(Chrome 团队)说过一句话很直接:”We’re not trying to fix the edges of the History API — we’re replacing it entirely.”

方案:10 行代码,浏览器原生处理

Navigation API 就是这个 replacement。它的核心是 navigate 事件——一个事件替代上面整套代码:

navigation.addEventListener("navigate", (event) => {
  if (!event.canIntercept || event.hashChange) return;
  const url = new URL(event.destination.url);
  if (url.origin !== location.origin) return;

  event.intercept({
    handler: async () => {
      const html = await fetch(url, { signal: event.signal }).then(r => r.text());
      document.querySelector("main").innerHTML = html;
    }
  });
});

这是完整的客户端路由实现,10 行。URL 更新、历史栈管理、focus 复位、滚动恢复——全部浏览器原生处理,你的 handler 只管数据获取和 DOM 渲染。

一个事件替代 200 行框架代码

navigate 事件统一捕获所有导航类型:点击链接、表单提交(GET/POST)、前进后退、navigation.navigate() 编程调用,全部经过同一个事件处理器。

这解决了 History API 最大的设计缺陷:popstate 事件只在用户点前进后退时触发,对 pushState/replaceState 编程调用完全沉默——你改了 URL,popstate 不会响,你的 UI 状态和浏览器 URL 随时可能脱节。Navigation API 的 navigate 事件对所有这些情况全部触发,永远不会漏掉一次导航。

异步控制:event.signal 自动取消请求

用户点了链接正在加载,你又点了一次——旧请求应该取消。Navigation API 内置了这个能力:

event.intercept({
  handler: async () => {
    const data = await fetch(url, { signal: event.signal }).then(r => r.json());
    render(data);
  }
});

event.signal 是浏览器原生的 AbortSignal,当用户中途离开时浏览器会自动 abort 这个请求,你不需要自己维护一个 AbortController。之前那些因为竞态导致的”点了两次,页面显示第一次请求结果”的 bug,直接消失了。

滚动复位:不再和加载时机打架

History API 最大的坑之一:滚动复位时机不可控。浏览器在 pushState 之后立即恢复滚动位置,但你的 DOM 内容可能还没渲染完——页面滚到了错误的位置,或者因为内容没加载完导致跳动。

Navigation API 把这个解耦了:

event.intercept({
  scroll: "manual",  // 告诉浏览器:我来控制滚动时机
  async handler() {
    await renderRoute(url);  // 等内容渲染完
    event.scroll();           // 现在安全地恢复滚动
  }
});

scroll: "manual" 告诉浏览器不要动滚动,event.scroll() 让你在内容就绪后再恢复。对于单页应用里大量存在的”加载→渲染→滚动”场景,这个解耦直接省掉一坨 setTimeout 和 IntersectionObserver 监听。

历史栈:终于可以遍历了

History API 最大的遗憾:history.state 是一个黑箱,你不知道栈里有多少条目,更不知道每个条目的位置。

const entries = navigation.entries();  // 全部历史条目
const currentIdx = navigation.currentEntry.index;
const previous = entries[currentIdx - 1];

if (previous) {
  navigation.traverseTo(previous.key);  // 回到任意位置
}

现在你有完整的 entries 列表,有稳定的 key,有 dispose 事件可以在历史条目离开时清理资源。这让”智能缓存”变得可实现:你保留最近访问的条目在内存里,超出范围的条目收到 dispose 时自动清理——History API 时代这是不可能的。

View Transitions 集成

Navigation API 和 View Transitions API 是协同设计的:

navigation.addEventListener("navigate", (event) => {
  if (!event.canIntercept) return;

  event.intercept({
    async handler() {
      if (!document.startViewTransition) {
        return renderRoute(event.destination.url);
      }
      const t = document.startViewTransition(() => renderRoute(event.destination.url));
      await t.finished;
    }
  });
});

点击 → fetch → 渲染 → 动画——整个流程天然协同,不需要手动计算 old state / new state 的边界。这比 element.startViewTransition() 更进一步,是跨路由级别的过渡动画,原生支持。

和框架路由器的关系

React Router v7 和 TanStack Router 已经在讨论将 Navigation API 作为底层后端。但目前还没有默认启用。

这意味着你可以渐进增强:在支持的浏览器里用平台原生路由,在旧浏览器里走现有的 History API 路径。两个世界的好处同时享受,不需要一刀切迁移。

三个坑

1. Safari 不支持 precommitHandler

navigate 事件有一个 transitionWhile(已废弃)和 intercept 两种拦截方式,Safari 支持后者但不支持 precommitHandler——即 fetch-before-URL-change 这个模式。如果你的应用在 URL 更新前需要先拿数据(比如显示 loading 状态),Safari 用户会看到 URL 先跳、内容后出。

2. 16% 覆盖率降级必须做

Firefox 147 以下和部分 Safari 版本不支持 Navigation API,全球仍有约 16% 用户不在支持范围内。你需要实现 if (window.navigation) 判断,降级到 History API。降级路径必须存在,不能假设 API 一定可用。

3. focus 复位不一定聚焦到你想要的位置

intercept 会让浏览器自动 focus 到 <body>autofocus 元素——这对很多 SPA 来说不是你要的位置。在 handler 末尾手动调用 focus() 是必要的,不能完全依赖浏览器默认行为。

三步下一步

  1. 判断支持:写一个 if (window.navigation) guard,现有框架路由外层加一层 Navigation API 拦截层,支持的走新路径,不支持的走原 History API 路径

  2. 迁移一个路由:选一个最简单的路由(比如 /about),把它的处理逻辑从 popstate + click 拦截迁移到 navigate 事件,跑通 fetch → 渲染 → 滚动控制全流程

  3. 测 back/forward 缓存:Navigation API 和 bfcache 深度集成,确保你的组件在页面被缓存后重新激活时正确恢复订阅、动画状态和定时器,没有内存泄漏

覆盖率:Chrome 102+ / Edge 102+ / Safari 18+ / Firefox 147+,约 83.66% 全球覆盖率,Baseline 2026。

Jake Archibald 说过一句话:”The web now has sensible, low-level routing for navigations.” 这不是营销话术——这是十年来所有 SPA 开发者反复写过的那 200 行代码,现在浏览器自己管了。

评论区

0 条评论

登录后可评论。

阿柯·前端架构 22 阅读