Skip to content

03|编程式导航与失败结果 ​

第六章概览 / 03。目标:把 router.push() 当作异步导航事务的入口,等待其结果后再提交 UI 后续动作。

三类 history 操作 ​

ts
const router = useRouter()

await router.push({ name: 'order', params: { id: '42' } }) // 新增记录
await router.replace({ name: 'login' })                    // 替换当前记录
router.go(-1)                                              // 在现有栈移动
router.back()
router.forward()

push 类似新增浏览器 history entry,用户按返回可回到旧页。replace 不增加 entry,适合登录回跳后的临时页、规范化无效 query 等“不应回到”的位置。go/back/forward 的结果取决于已有栈;它们不返回可等待的导航 Promise。

await router.push() 的分支 ​

ts
import { isNavigationFailure, NavigationFailureType } from 'vue-router'

const failure = await router.push({ name: 'settings' })

if (!failure) {
  closeMenu() // 已到达目标,重定向也已完成
} else if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
  showMessage('导航被守卫阻止')
} else if (isNavigationFailure(failure, NavigationFailureType.cancelled)) {
  // 在本次完成前出现了更新的导航,通常无需打扰用户
} else if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
  // 已在目标位置;按产品需要当作无操作
}

成功(包括最终完成的重定向)通常 resolve 为 falsy;守卫返回 false、被更新导航抢先或重复导航会 resolve 为 NavigationFailure。守卫抛错、懒加载 import 拒绝等意外错误会 reject,因此关键流程还要 try/catch/finally。

ts
async function saveAndLeave() {
  saving.value = true
  try {
    await saveDraft()
    const failure = await router.push({ name: 'documents' })
    if (failure) return { ok: false, reason: 'navigation' as const }
    return { ok: true as const }
  } catch (error) {
    report(error)
    return { ok: false, reason: 'exception' as const }
  } finally {
    saving.value = false
  }
}

只有导航确认成功才关闭依赖当前页面的 modal/store 状态;否则 leave guard 阻止时,页面仍需要这些状态。

实验:正在等待不等于已经到达 ​

下面程序使用已安装的 Vue Router,在 Node 中执行 node navigation-race.mjs。它没有浏览器窗口,只验证导航确认与当前位置;beforeEach 在这里只是一个可暂停的前置检查,完整守卫职责在第07篇展开。

js
import assert from 'node:assert/strict'
import {
  createRouter, createMemoryHistory,
  isNavigationFailure, NavigationFailureType
} from 'vue-router'

const Empty = { render: () => null }
const router = createRouter({
  history: createMemoryHistory(),
  routes: ['/', '/slow', '/fast', '/blocked'].map(path => ({ path, component: Empty }))
})
let releaseSlow
let markEntered
const entered = new Promise(resolve => { markEntered = resolve })
const gate = new Promise(resolve => { releaseSlow = resolve })
const trace = []

router.beforeEach(async to => {
  if (to.path === '/blocked') return false
  if (to.path === '/slow') {
    markEntered()
    await gate
    trace.push('slow-check-finished')
  }
})

await router.push('/')
const slow = router.push('/slow')
await entered // 确认检查已开始,不依赖猜测延迟
assert.equal(router.currentRoute.value.path, '/')
assert.equal(await router.push('/fast'), undefined)
assert.equal(router.currentRoute.value.path, '/fast')
releaseSlow()
assert.equal(isNavigationFailure(await slow, NavigationFailureType.cancelled), true)
assert.deepEqual(trace, ['slow-check-finished'])
assert.equal(router.currentRoute.value.path, '/fast')

const blocked = await router.push('/blocked')
assert.equal(isNavigationFailure(blocked, NavigationFailureType.aborted), true)
const duplicate = await router.push('/fast')
assert.equal(isNavigationFailure(duplicate, NavigationFailureType.duplicated), true)
console.log('navigation decisions passed')

时间线上有三个位置:已确认的 /、待定的 /slow、后来确认的 /fast。新导航确认后,slow的检查仍能运行完,说明取消导航没有替你取消任意外部工作;Router只阻止旧目标成为当前页面。若旧检查里直接改store,写入仍可能发生,所以外部副作用也要使用第五章的会话/请求身份保护。

将最后一次 /fast 改成 /fast?page=2,它就不再是同一个规范位置;query也是位置的一部分。再将blocked分支的 return false 改为 throw new Error('check failed'),调用方必须catch拒绝,不能继续只检查failure对象。两种失败都让页面留在原处,但它们代表用户决策与意外故障,反馈策略不同。

位置对象比字符串拼接可靠 ​

ts
await router.push({
  name: 'search',
  query: { q: keyword, page: '1' },
  hash: '#results',
})

命名位置由 Router 编码 params 并生成 href。若同时给 path 和 params,params 会被忽略;要么使用 name+params,要么自己把参数编码进 path。

在执行前可用 router.resolve(location) 预览 href,但 resolve 不运行守卫,也不保证懒加载组件能成功下载。

全局观察结果 ​

ts
router.afterEach((to, from, failure) => {
  if (failure) recordNavigationFailure(to, from, failure)
  else recordPageView(to.fullPath)
})

router.onError((error, to) => {
  reportNavigationException(error, to)
})

afterEach 不能改变导航,只适合埋点、标题和失败记录;第三个参数区分失败。抛出的错误走 onError/Promise rejection,不应与“用户取消”混成错误告警。

失败路径 ​

  • 调用 router.push() 后立即清表单:守卫取消时用户数据已经丢失。
  • 到处 catch 并吞掉异常:chunk 加载失败变成无提示白屏。
  • 登录页每次都 push:返回按钮又回到受保护页,再次重定向;登录重定向通常用 replace 或明确回跳策略。
  • 用 window.history.replaceState({}, ...) 覆盖 Router state:会丢失 Router 保存的滚动和导航信息;需要自定义 state 时保留现有 history.state,优先用 Router API。
  • 把 duplicated 当严重错误:很多按钮重复点击只需要无操作,但应由产品合同决定。

练习:提交后导航 ​

实现 submitAndOpen(id):先调用 save(),成功后 push 到命名路由 detail;导航成功才清草稿。aborted 时保留草稿并返回 blocked,cancelled/duplicated 分别返回明确结果,异常返回 error;loading 必须在所有分支恢复为 false。

变化题:登录成功从 /login?redirect=/orders/42 回跳时使用 replace,并拒绝跨 origin 的 redirect 字符串。

参考答案:第 03 题。

本页结论 ​

push/replace 决定 history,守卫决定是否提交,Promise 告诉调用方结果。先等待导航,再清理当前页面状态。

用心记录每一步成长