Skip to content

07|导航守卫 ​

第六章概览 / 07。目标:守卫只决定“这次导航能否继续、改去哪里”,不承担页面全部数据加载和真正的服务端授权。

现代守卫返回值 ​

ts
router.beforeEach(async to => {
  const auth = useAuthStore(pinia)
  if (auth.status === 'unknown') await auth.restore()

  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }

  return true // 也可省略,undefined 表示允许
})

守卫可返回/产生:

结果含义
undefined / true允许继续
false取消并恢复当前 URL(若已变化)
route location放弃当前目标,发起到新目标的导航
throw error / rejected Promise导航异常,交给 router.onError 与调用者 catch

旧式第三个参数 next 在 Router 4 仍兼容,但一次路径必须恰好调用一次;分支漏调/重复调很难审查。新代码优先 return,不混用 return 和 next。

守卫放在哪个 owner ​

守卫合适责任
beforeEach全局登录恢复、站点级开关、统一重定向
route beforeEnter进入某个 record 才需要的前置条件
onBeforeRouteUpdate当前组件复用时,参数变化的确认/刷新
onBeforeRouteLeave当前组件拥有的未保存草稿、离开确认
beforeResolve所有进入组件和异步组件已解析后,提交前最后检查
afterEach只读埋点、标题、失败记录;不能阻止导航

beforeEnter 在同一 record 的 params/query/hash 变化时不会重跑。依赖 id 的数据刷新属于组件 update/watch 或专门的数据加载层。

未保存草稿由组件负责 ​

vue
<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

const dirty = ref(false)

onBeforeRouteLeave(() => {
  if (!dirty.value) return true
  return window.confirm('有未保存内容,仍要离开吗?')
})
</script>

草稿 owner 是当前页面,所以 leave guard 靠近页面。全局 guard 不应猜每个表单是否 dirty。被 KeepAlive 停用的路由组件仍可持有 leave guard;登出等强制清理流程要另有明确策略。

执行顺序用“离开—全局—更新—进入—解析”记 ​

完整细节由 Router 维护,排错时关注这些阶段:

  1. 离开组件的 leave guards(由深到浅)。
  2. 全局 beforeEach。
  3. 复用组件的 update guards。
  4. route record 的 beforeEnter。
  5. 解析异步路由组件。
  6. 进入组件的 guards。
  7. 全局 beforeResolve。
  8. 确认导航,随后 afterEach。

后发导航会取消仍在等待的旧导航,但你在 guard 内启动的任意外部请求仍要设计 request id/AbortController。不要让旧认证恢复结果覆盖新会话。

把“等同一个恢复”与“开启新的恢复”分开 ​

第五章auth的 restore() 会取消上一次恢复。若导航A与导航B各调一次,两个本来想等同一个会话结果的消费者反而互相抢占。把正在进行的Promise留在单一协调者里,公开页面无需等它;受保护页面共享它。

以下函数放在客户端应用创建处,每个app创建一次;SSR则每请求创建一次。auth就是第五章的store,restore() 在401时返回false,网络故障时抛错。

ts
import type { Router } from 'vue-router'

interface Auth {
  status: 'unknown' | 'authenticated' | 'anonymous'
  isAuthenticated: boolean
  restore(): Promise<boolean>
}

export function installAuthGuard(router: Router, auth: Auth) {
  let restoring: Promise<boolean> | null = null

  function ensureRestored() {
    if (auth.status !== 'unknown') return Promise.resolve(auth.isAuthenticated)
    if (!restoring) {
      restoring = Promise.resolve().then(() => auth.restore()).finally(() => {
        restoring = null // 失败后允许用户发起下一次恢复
      })
    }
    return restoring
  }

  return router.beforeEach(async to => {
    if (!to.meta.requiresAuth) return true
    try {
      await ensureRestored()
    } catch {
      return { name: 'service-unavailable' }
    }
    if (!auth.isAuthenticated) {
      return { name: 'login', query: { redirect: to.fullPath } }
    }
    return true
  })
}

调用 installAuthGuard(router, auth) 得到的是移除守卫的函数;应用销毁或替换安装时调用它。login与service-unavailable两条记录应是公开页。若初次恢复失败,auth仍是unknown,错误页可提示重试;不能把断网擅自解释成匿名会话。认证成功/401以及晚到结果防护仍由auth负责,这个函数只协调等待和导航决策。

推演A、B、公开页C:A进入受保护页开始restore,B随后进入另一受保护页拿到同一个Promise;C为公开页,直接确认导航。restore最终完成后,A/B的导航已经过时,Router不会让它们覆盖C,但restore仍可能更新应用会话。这个结果可以成立,因为会话属于应用而非某次导航;如果发生登出,auth自己的轮次应使旧恢复失效。不要用“导航取消”代替会话失效,也不要让某个页面离开时中止其他页面正在共享的恢复。

避免重定向循环 ​

ts
if (to.meta.requiresAuth && !auth.isAuthenticated && to.name !== 'login') {
  return { name: 'login', query: { redirect: to.fullPath } }
}

login route 不应也要求登录。明确401收敛到anonymous,网络或服务故障保留unknown并显示可重试入口,而不是每个守卫各自restore。回跳参数是用户输入,只允许站内路径或已知 route name,拒绝 https://evil.example 和 //evil.example。

前端守卫不是授权 ​

守卫可避免用户先看到受保护页面,但攻击者能绕过它直接调用 API。服务端必须对每次请求验证 session、动作和资源。401 时清会话并回登录;403 时通常保留身份、展示无权限。守卫里的旧权限快照不能否定服务端结果。

练习:三种结果的认证守卫 ​

实现全局 guard:公开页直接通过;受保护页先等待一次 auth.restore();匿名用户重定向 login 并保存站内 redirect;恢复抛错时导航到 service-unavailable;login 自身永不循环。

再给编辑页加 leave guard。用 memory history 验证允许、false 取消、重定向、异常四条路径,并检查 router.push() 返回/抛出的区别。

参考答案:第 07 题。

本页结论 ​

守卫是一条异步决策链。用 return 表达结果,把状态恢复集中在单一 owner,把草稿判断留给组件,并让服务端承担最终授权。

用心记录每一步成长