写过 SPA 的人都踩过这个坑——路由配了三层拦截两套事件,今天浏览器用一个 API 把这件事彻底原生化了
写 SPA 的人大概都踩过这个坑——每次配路由都要装 @reach/router 或者 react-router-dom,然后配三层拦截、两套 popstate 监听、还要自己处理 scroll restoration。不装行不行?不行,因为 History API 本身就不是给路由设计的。
Ian Hickson,前 HTML 规范编辑,当年负责设计 pushState(),后来自己说那是他的 “favourite mistake”(最爱的错误)。这件事终于翻篇了——Navigation API 2026 年 1 月正式 Baseline,Chrome、Edge、Firefox 147、Safari 26.2 全部支持。路由第一次成为平台原生能力。
History API 那个烂摊子
在 Navigation API 出现之前,写 SPA 路由是这样的:
// 第一步:拦截页面上所有链接点击
document.addEventListener("click", (e) => {
const link = e.target.closest("a");
if (!link) return;
if (link.origin !== location.origin) return; // 外部链接
if (link.target === "_blank") return; // 新标签页
if (e.metaKey || e.ctrlKey) return; // 命令键点击
e.preventDefault();
history.pushState({}, "", link.href);
renderRoute(link.pathname);
window.scrollTo(0, 0);
});
// 第二步:一套完全独立的 popstate 处理 back/forward
window.addEventListener("popstate", () => {
renderRoute(location.pathname);
// scroll restoration 呢?自己写
// focus 管理呢?自己写
// 页面可见性呢?自己写
});
// 第三步:初始渲染自己调
renderRoute(location.pathname);
这段代码里埋了多少坑:
Guard list 永远不完整——metaKey/ctrlKey/shiftKey/按钮值/下载属性/外部链接…每一个 edge case 都是生产环境的 bug accumulation。
前进和后退是两个独立代码路径——click handler 和 popstate listener 必须保持一致,但没有任何机制强制它们一致。
History API 本身就不完整——它检测不到所有触发导航的事件,拿不到完整 history stack(只能访问当前 entry),popstate 在你自己调用 pushState 时根本不触发。
这三个问题,十几年了,每个做 SPA 的团队都在自己的 router 里默默填着。
Navigation API 怎么收拾残局
Navigation API 的核心设计就是一个词:统一。
// 一个事件,处理所有导航
window.navigation.addEventListener("navigate", (event) => {
// 浏览器告诉你哪些不能拦截(跨域、下载、hash 跳转)
if (!event.canIntercept) return;
if (event.downloadRequest !== null) return;
if (event.hashChange) return; // hash 变化,让浏览器自己处理
const url = new URL(event.destination.url);
event.intercept({
async handler() {
await renderRoute(url.pathname);
},
});
});
三件事同时发生:URL 自动更新、history stack 自动写入、scroll 自动恢复到正确位置。
这还没完。Navigation API 提供了一系列 History API 做梦都不敢想的 API:
// 访问完整 history stack(History API 完全做不到这件事)
navigation.entries().forEach(entry => {
console.log(entry.key, entry.url, entry.getState());
});
// 跳到任意历史 entry,而不是只能 back/forward
navigation.traverseTo(entry.key);
// structured state,任何 entry 上都能挂数据
navigation.current.getState(); // 读当前 state
navigation.current.setState({ page: 2 }); // 写当前 state
// 集中式成功/失败回调
navigation.addEventListener("navigatesuccess", () => {
console.log("路由切换成功");
});
navigation.addEventListener("navigateerror", (event) => {
console.error("路由切换失败", event.error);
});
scroll 恢复也原生支持:
window.navigation.addEventListener("navigate", (event) => {
const url = new URL(event.destination.url);
event.intercept({
async handler() {
await renderRoute(url.pathname);
// scroll 恢复不用自己写,浏览器自动处理
// 而且可以精确控制时机
event.scroll({ top: 0 }); // 或者 event.scroll({ left: 0 })
},
});
});
Safari 26.2 暂时不支持 precommitHandler,限制了部分预加载拦截场景,但核心路由功能已经完全可用。
路由依赖还能留着吗
能,但原因变了。
React Router 和 TanStack Router 都在讨论把 Navigation API 当作底层引擎,这意味着嵌套路由、data loading、error boundary 这类框架层能力还是有价值的。但如果你的需求只是「点链接不刷新、back/forward 正常、工作」,今天正确的依赖数量是零。
// 一个博客,一个文档站,一个内部工具
// 需要装 react-router-dom 吗?不需要
window.navigation.addEventListener("navigate", (event) => {
if (!event.canIntercept) return;
event.intercept({ handler: renderPage });
});
三行,零依赖,SPA 路由的全部基础能力。
下一步
- 查你的项目路由依赖:Router v4/v5/v6 分别什么时候引入的,换 Navigation API 需要多少改动
- 看 WICG Navigation API 官方迁移指南(GitHub 有完整对照表)
- 测一下:去掉 router 包之后,bundle 大小少了多少,INP 有改善吗
Navigation API 不是一个新框架,是浏览器终于把路由这件事接过去了。装不装 router,现在可以认真问一句为什么。
评论区
登录后可评论。