配了八年 SPA,今天才发现路由从来不是自己写的——Navigation API 把这件事彻底原生化了

写过 SPA 的人都踩过这个坑——每次点一个链接,都要写一段 preventDefault + pushState + 手动处理边界情况的代码。而且这段代码永远写不完:新需求来了继续加,第三方库接进来继续改,到最后光一个点击拦截器就塞了两百行。

这不是你的问题,是 History API 本身的设计缺陷——它只给你提供了 pushState 和 popstate 两个原始工具,剩下的全靠你自己拼。

2026 年 1 月,Navigation API 正式进入 Baseline Widely Available,Chrome/Edge/Firefox/Safari 全部支持。SPA 路由这件事,终于可以彻底交给浏览器了。

一个事件,替代两百行点击拦截器

History API 最大的坑在于:它只告诉你「用户按了返回键」,不告诉你「用户点了一个链接」。所以每个框架的 router 都要自己监听所有链接的点击,自己判断是否要拦截,自己处理各种边界情况。

Navigation API 的核心是一个 navigate 事件,它会告诉你所有类型的导航——链接点击、表单提交、back/forward 按钮、编程式导航——全部在一个事件里统一处理:

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 () => {
      // URL 已经变了,先显示 loading 态
      renderPlaceholder();
      // 异步加载内容
      const html = await fetch(url, { signal: event.signal }).then(r => r.text());
      document.querySelector("main").innerHTML = html;
    }
  });
});

十行代码,一个完整的客户端路由。对比原来的写法:

// 原来的两百行点击拦截器
window.addEventListener("popstate", handlePopState);
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;
  // ... 二十个更多边界情况
  e.preventDefault();
  history.pushState(null, "", link.href);
  handleRoute(link.href);
});

每个框架的 router 都有这段代码,只是长短不同。Navigation API 直接把这个问题从平台层面解决了。

三个特性解决 History API 的历史遗留问题

1. 自动 abortsignal — 用户跑了就跑了

用户点击了一个链接开始加载,你正在 fetch 数据——这时候用户又点了一个新链接。在原来,这是最难处理的竞态问题之一。现在 Navigation API 给你一个 event.signal,加载会自动取消:

event.intercept({
  handler: async () => {
    const data = await fetch(url, { signal: event.signal }); // 自动取消
  }
});

2. navigation.entries() — 终于能看到完整的历史栈

History API 最大的遗憾:pushState 之后你根本不知道历史栈里有什么。现在你可以遍历所有历史条目,还能跳到任意一个:

const entries = navigation.entries();
const currentIdx = navigation.currentEntry.index;
const prev = entries[currentIdx - 1];
if (prev) {
  navigation.traverseTo(prev.key); // 跳到上一个历史节点
}

3. event.intercept() 的 focus 和 scroll 控制

Navigation API 在拦截导航时自动帮你处理两件事:焦点管理(键盘焦点自动重置到新页面)和滚动恢复(back/forward 时回到之前的滚动位置)。这两件事原来也是每个 router 要自己写的:

event.intercept({
  handler: async () => { await renderRoute(event.destination.url); },
  focusReset: "after-transition", // 焦点在动画后重置
  scroll: "after-transition"      // 滚动位置在动画后恢复
});

和 View Transitions 配合:SPA 也有整页动画了

Navigation API 和 View Transitions API 是天生一对。拦截导航后,在 DOM 更新时用 startViewTransition 包裹,浏览器自动给你做 SPA 内的页面过渡动画,不需要任何框架:

navigation.addEventListener("navigate", (event) => {
  if (!event.canIntercept) return;
  event.intercept({
    handler: async () => {
      const content = await fetchPage(event.destination.url);
      if (document.startViewTransition) {
        document.startViewTransition(() => {
          document.getElementById("app").innerHTML = content;
        });
      } else {
        document.getElementById("app").innerHTML = content;
      }
    }
  });
});

渐进增强写法

Safari 26.2+ 才加入,Firefox 147+ 支持,还有 16% 的全球用户不在支持范围内。用 @supports 做渐进增强:

if ("navigation" in window) {
  // 使用 Navigation API
  initNavigationRouter();
} else {
  // 降级到 History API
  initLegacyRouter();
}

React Router 和 TanStack Router 已经在讨论将 Navigation API 作为底层实现,目前还没正式集成。提前用原生 API 的好处是:将来框架集成之后,你的业务代码几乎不需要改。

三步下一步

  1. 跑一个 demo:把上面十行路由代码粘到一个空页面,试试点三个链接,看 URL 栏和内容怎么同步变化。
  2. 接一个现有项目:挑一个用 History API 的老 SPA,在 production 环境之外加一个 navigation 监听器,对比两种写法的边界情况处理量。
  3. 跟进框架集成:关注 React Router 和 TanStack Router 的 RFC,框架一旦支持,你只需要换掉初始化那几行,其他业务代码保持不动。

Navigation API 是浏览器给 SPA 开发者的平台级基础设施——它不是又一套 router 库,而是让所有 router 都不再需要自己写 click 拦截器的那一层底座。

评论区

0 条评论

登录后可评论。

阿柯·前端架构 11 阅读