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 没有事件

这是另一个让人头疼的设计缺陷。pushStatereplaceState 调用的时候,不会触发任何事件

你自己的代码调 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。

拦截一切导航

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 类型的导航(前进/后退),只有 userInitiatedtrue 时才能 preventDefault。浏览器不允许页面默默劫持用户的后退操作,这是合理的安全约束。

异步处理与状态追踪

intercepthandler 是异步函数,这带来一个以前不可能实现的能力------在浏览器层面追踪导航状态。看一下 navigation.navigate() 的返回值就明白了:

ts 复制代码
const result = navigation.navigate('/dashboard')

// committed: URL 已更新,但 handler 可能还在执行
await result.committed
console.log('URL 已切换,页面正在加载...')

// finished: handler 执行完毕,页面完全就绪
await result.finished
console.log('导航彻底完成')

committedfinished 这两个 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 管不了的:跨域导航(canInterceptfalse,只能观察)、window.open() 打开的新窗口、用户在地址栏手动输入 URL、<meta http-equiv="refresh"> 触发的刷新。

这些限制是合理的安全边界,但在方案设计时必须考虑到:你的"离开前保存草稿"逻辑不能只依赖 Navigation API,beforeunload 事件在跨页面导航场景下仍然要作为兜底。

状态管理的大小限制

navigation.navigate(url, { state })state 使用结构化克隆算法,比 history.state 的 JSON 序列化灵活得多------DateRegExpMapSetArrayBuffer 都能存。但仍然有大小限制(各浏览器实现不同),别把整个页面的业务数据塞进去。state 应该只存导航相关的元数据:滚动位置、筛选条件、分页页码。业务数据还是走状态管理库或缓存。

History API 诞生于 2011 年前后,那时候 SPA 还不是主流,设计目标是"让 Ajax 页面也能更新 URL"------一个相当有限的场景。所以它只给了 pushStatepopstate,够用就行。但前端路由在过去十几年的发展远远超出了这个设计预期,导致每个路由框架都得用各种 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 搭路由层完全可行。如果是存量项目,等框架适配是风险最低的迁移路径。

相关推荐
早點睡3902 小时前
ReactNative项目OpenHarmony三方库集成实战:react-native-orientation-locker
javascript·react native·react.js
早點睡3902 小时前
ReactNative项目OpenHarmony三方库集成实战:react-native-localize
javascript·react native·react.js
棋鬼王3 小时前
Cesium(十) 动态修改白模颜色、白模渐变色、白模光圈特效、白模动态扫描光效、白模着色器
前端·javascript·vue.js·智慧城市·数字孪生·cesium
酉鬼女又兒3 小时前
零基础快速入门前端蓝桥杯Web备考:BOM与定时器核心知识点详解(可用于备赛蓝桥杯Web应用开发)
开发语言·前端·javascript·职场和发展·蓝桥杯
ThridTianFuStreet小貂蝉3 小时前
面试题1:请系统讲讲 Vue2 与 Vue3 的核心差异(响应式、API 设计、性能与编译器)。
前端·javascript·vue.js
竹林8183 小时前
在NFT项目中集成IPFS:从Pinata上传到前端展示的完整实战与踩坑
前端·javascript
我命由我123453 小时前
Vite - Vite 最小项目
服务器·前端·javascript·react.js·ecmascript·html5·js
布局呆星3 小时前
Vue3 | 事件绑定与双向数据绑定
前端·javascript·vue.js
Hilaku3 小时前
前端资质越高,越来越不敢随便升级框架?
前端·javascript·架构