Navigation API 如何重塑前端路由
前阵子给公司的 B 端管理系统做路由层重构,用的还是 Vue Router 4.x,底层跑的 history.pushState。改到一半突然意识到一个问题------我们花了大量代码在做浏览器本该帮我们做的事情:拦截导航、恢复滚动位置、处理用户点击后退按钮时的确认弹窗。
这些东西,History API 一个都不管。
直到认真看了 Navigation API 的规范和 Chrome 的实现,才发现这套新 API 的设计思路完全不同。它不是在 History API 上打补丁,而是从头定义了"浏览器导航"在 Web 应用里的运作方式。
History API 到底哪里不行
popstate 的信息黑洞
popstate 事件是我们拦截用户前进/后退的唯一手段,但它给出的信息少得可怜:
ts
window.addEventListener('popstate', (event) => {
// event.state → 之前 pushState 塞进去的数据(如果有的话)
// 仅此而已。
// 用户是点了"后退"还是"前进"?不知道
// 要去的 URL 是什么?得自己读 location.href,但这时候 URL 已经变了
// 能不能取消这次导航?不能
// 导航是跨域的还是同源的?不知道
})
注意那个关键问题:URL 已经变了 。当 popstate 触发的时候,浏览器地址栏已经更新完毕。想弹个"确认离开?"的对话框?来不及了,地址栏显示的已经是目标页面的 URL。
所有 SPA 路由库处理这种情况的方式都很 hack------先让导航发生,用户如果取消,再偷偷 history.go(1) 或 history.go(-1) 跳回去。用户会看到地址栏闪一下。体验很糟。
pushState 和 replaceState 没有事件
这是另一个让人头疼的设计缺陷。pushState 和 replaceState 调用的时候,不会触发任何事件。
你自己的代码调 pushState 没问题,因为你知道自己在做什么。但如果页面嵌了第三方脚本,或者微前端场景下子应用自己调了 pushState,主框架对此完全无感知。Vue Router、React Router 这些库的解决办法是 monkey-patch:
ts
const originalPushState = history.pushState.bind(history)
history.pushState = function (...args) {
originalPushState(...args)
window.dispatchEvent(new Event('pushstate'))
}
全局 monkey-patch 浏览器原生 API,在微前端场景下多个框架抢着 patch 同一个方法------一旦出问题,排查难度极高。
滚动恢复的半成品
浏览器有个 history.scrollRestoration 属性,设成 'manual' 可以自己管理滚动位置。但"自己管理"意味着什么?你得在离开页面前手动记录滚动位置,把它存到 sessionStorage(因为 history.state 有大小限制),在导航完成后、DOM 渲染完成后、图片加载完成后恢复滚动。对,图片加载完成后------因为懒加载图片会撑开页面高度,恢复时机太早的话位置就不对。你还得区分"新页面导航"和"后退到旧页面"这两种场景。
Vue Router 的 scrollBehavior 和 React Router 的 ScrollRestoration 组件都在做这件事,每个框架自己实现一遍,每个实现都有各自的边缘 case。
NavigateEvent:路由库最想要的那个 API
拦截一切导航
navigate 事件是整个 API 的核心。
ts
navigation.addEventListener('navigate', (event) => {
// event.navigationType → 'push' | 'replace' | 'reload' | 'traverse'
// event.destination.url → 目标 URL(导航还没发生)
// event.canIntercept → 是否可以拦截(跨域导航不行)
// event.userInitiated → 是不是用户主动触发的
const url = new URL(event.destination.url)
if (url.pathname.startsWith('/app')) {
event.intercept({
async handler() {
const content = await fetchPageContent(url.pathname)
document.querySelector('#app').innerHTML = content
}
})
}
})
event.intercept() 做的事情相当于告诉浏览器:"这个导航我接管了,别真的去加载新页面,URL 你帮我更新就行。"这就是 SPA 路由的本质------以前得通过 preventDefault + pushState + 手动更新视图三步走,现在一个 intercept 搞定。
导航守卫的原生方案
前面提到的"用户点后退时弹确认框"这个场景,是 B 端系统的高频需求。先看我们项目中 Vue Router 的现有写法:
ts
// Vue Router 的 beforeRouteLeave 守卫
// B 端表单页的实际代码
onBeforeRouteLeave((to, from, next) => {
if (!hasUnsavedChanges.value) return next()
showConfirmDialog('有未保存的修改,确认离开?')
.then(() => next())
.catch(() => next(false))
// next(false) 底层会调用 history.go(delta) 跳回来
// 用户可以看到地址栏先变成目标 URL,再闪回当前 URL
})
这段代码的核心问题在于 next(false) 的实现机制:Vue Router 无法真正"阻止"浏览器导航,只能在导航已经发生后,用 history.go() 静默跳回。地址栏的闪烁在低端设备上尤为明显,用户会以为页面出了 bug。
Navigation API 给出了干净利落的替代方案:
ts
navigation.addEventListener('navigate', (event) => {
if (hasUnsavedChanges && event.navigationType === 'traverse') {
event.preventDefault() // 直接阻止导航,URL 不会变
showConfirmDialog({
onConfirm: () => navigation.traverseTo(event.destination.key)
})
}
})
event.preventDefault() 在 navigate 事件里终于能正常工作了------它会阻止导航发生,URL 不会改变 ,不需要再用 history.go(-1) 来"假装没导航过"。两段代码做的是同一件事,但底层机制完全不同:一个是"事后回滚",一个是"事前拦截"。
有个限制需要留意:对于 traverse 类型的导航(前进/后退),只有 userInitiated 为 true 时才能 preventDefault。浏览器不允许页面默默劫持用户的后退操作,这是合理的安全约束。
异步处理与状态追踪
intercept 的 handler 是异步函数,这带来一个以前不可能实现的能力------在浏览器层面追踪导航状态。看一下 navigation.navigate() 的返回值就明白了:
ts
const result = navigation.navigate('/dashboard')
// committed: URL 已更新,但 handler 可能还在执行
await result.committed
console.log('URL 已切换,页面正在加载...')
// finished: handler 执行完毕,页面完全就绪
await result.finished
console.log('导航彻底完成')
committed 和 finished 这两个 Promise 的分离设计相当精妙。在 History API 的世界里,pushState 是同步的、瞬间完成的,你没有任何原生手段知道"页面是否加载完了"。每个路由库都得自己实现 router.isReady() 或 router.afterEach() 这类机制,Navigation API 在浏览器层面直接提供了这个能力,路由库可以基于它简化大量内部代码。
边界情况与迁移风险
浏览器兼容性现状
这是做技术选型时绕不开的问题。截至目前,Navigation API 的浏览器支持范围仍然有限:Chrome 105+ (2022 年 8 月发布)和 Edge 105+ 已完整支持全部特性,但 Firefox 和 Safari 仍未实现。
这意味着:
- 如果你的产品面向企业内部(B 端系统、管理后台),且能限定 Chromium 内核浏览器,Navigation API 已经可以投入生产
- 如果需要覆盖 C 端用户的多浏览器环境,目前不能将 Navigation API 作为唯一路由方案,必须保留 History API 作为 fallback 或继续使用现有路由框架
- 可以通过
'navigation' in window做特性检测,在支持的浏览器上启用增强体验,不支持时回退到传统方案
这也是我们 B 端项目最终选择"等 Vue Router 适配后再升级"而非自行封装的原因之一------即便我们的用户全部使用 Chrome,团队也不想长期维护一套兼容层代码。
不能拦截的导航
有几类导航是 Navigation API 管不了的:跨域导航(canIntercept 为 false,只能观察)、window.open() 打开的新窗口、用户在地址栏手动输入 URL、<meta http-equiv="refresh"> 触发的刷新。
这些限制是合理的安全边界,但在方案设计时必须考虑到:你的"离开前保存草稿"逻辑不能只依赖 Navigation API,beforeunload 事件在跨页面导航场景下仍然要作为兜底。
状态管理的大小限制
navigation.navigate(url, { state }) 的 state 使用结构化克隆算法,比 history.state 的 JSON 序列化灵活得多------Date、RegExp、Map、Set、ArrayBuffer 都能存。但仍然有大小限制(各浏览器实现不同),别把整个页面的业务数据塞进去。state 应该只存导航相关的元数据:滚动位置、筛选条件、分页页码。业务数据还是走状态管理库或缓存。
从 History API 到 Navigation API 的架构演进
History API 诞生于 2011 年前后,那时候 SPA 还不是主流,设计目标是"让 Ajax 页面也能更新 URL"------一个相当有限的场景。所以它只给了 pushState 和 popstate,够用就行。但前端路由在过去十几年的发展远远超出了这个设计预期,导致每个路由框架都得用各种 hack 弥补 API 的不足:monkey-patch 原生方法、手动管理滚动位置、用 history.go 模拟取消导航。
Navigation API 的设计完全基于 SPA 路由的实际需求:拦截导航要在事前而不是事后,方向判断需要历史栈的可见性,导航过程天然是异步的,滚动恢复的时机应该由开发者控制。它不是在旧 API 上叠功能,而是重新设计了导航的抽象模型。
这种"在 userland 积累足够经验后,把通用模式下沉到平台层"的演进路径,在前端领域反复出现。jQuery 的选择器下沉成了 querySelector,Lodash 的工具函数部分下沉成了 ES6+ 原生方法,各路由库的导航拦截下沉成了 Navigation API。每一次下沉都让应用层代码变得更薄,同时也让不同框架之间的行为更一致。
下面这张表是我给团队做技术选型时整理的,比较直观地展示了两代 API 的差异:
| 能力 | History API | Navigation API |
|---|---|---|
| 拦截导航 | popstate(事后通知) |
navigate(事前拦截) |
| 取消导航 | 不支持原生取消,需 hack | preventDefault() 直接取消 |
| 导航方向 | 无法判断 | destination.index 对比 |
| 历史栈访问 | 仅 history.length |
entries() 完整列表 |
| 异步导航 | 不支持 | intercept({ handler }) |
| 导航完成追踪 | 无原生机制 | committed / finished Promise |
| 滚动恢复 | scrollRestoration: manual + 自行实现 |
scroll: 'after-transition' / event.scroll() |
| 状态存储 | JSON 序列化 | 结构化克隆(支持 Date/Map/Set 等) |
| View Transitions 集成 | 手动协调,方向判断困难 | handler 内自然集成,方向可知 |
| 浏览器支持 | 所有现代浏览器 | Chrome/Edge 105+,Firefox/Safari 未支持 |
回到我们最初那个 B 端管理系统的路由重构。目前的方案是先不动 Vue Router,等它的下个大版本在底层切换到 Navigation API 后平滑升级。但这次调研让团队对路由的底层机制有了更清晰的认知------以前 debug 滚动恢复不准或者后退拦截闪烁的问题,总觉得是路由库的 bug,现在才明白是 History API 本身的局限。如果你在起一个新项目,目标浏览器都是 Chromium 内核,又不想引入重型路由框架,直接用 Navigation API 搭路由层完全可行。如果是存量项目,等框架适配是风险最低的迁移路径。