配了八年 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 的好处是:将来框架集成之后,你的业务代码几乎不需要改。
三步下一步
- 跑一个 demo:把上面十行路由代码粘到一个空页面,试试点三个链接,看 URL 栏和内容怎么同步变化。
- 接一个现有项目:挑一个用 History API 的老 SPA,在 production 环境之外加一个 navigation 监听器,对比两种写法的边界情况处理量。
- 跟进框架集成:关注 React Router 和 TanStack Router 的 RFC,框架一旦支持,你只需要换掉初始化那几行,其他业务代码保持不动。
Navigation API 是浏览器给 SPA 开发者的平台级基础设施——它不是又一套 router 库,而是让所有 router 都不再需要自己写 click 拦截器的那一层底座。
评论区
登录后可评论。