07|导航守卫
第六章概览 / 07。目标:守卫只决定“这次导航能否继续、改去哪里”,不承担页面全部数据加载和真正的服务端授权。
现代守卫返回值
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 表示允许
})2
3
4
5
6
7
8
9
10
守卫可返回/产生:
| 结果 | 含义 |
|---|---|
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 或专门的数据加载层。
未保存草稿由组件负责
<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>2
3
4
5
6
7
8
9
10
11
草稿 owner 是当前页面,所以 leave guard 靠近页面。全局 guard 不应猜每个表单是否 dirty。被 KeepAlive 停用的路由组件仍可持有 leave guard;登出等强制清理流程要另有明确策略。
执行顺序用“离开—全局—更新—进入—解析”记
完整细节由 Router 维护,排错时关注这些阶段:
- 离开组件的 leave guards(由深到浅)。
- 全局
beforeEach。 - 复用组件的 update guards。
- route record 的
beforeEnter。 - 解析异步路由组件。
- 进入组件的 guards。
- 全局
beforeResolve。 - 确认导航,随后
afterEach。
后发导航会取消仍在等待的旧导航,但你在 guard 内启动的任意外部请求仍要设计 request id/AbortController。不要让旧认证恢复结果覆盖新会话。
把“等同一个恢复”与“开启新的恢复”分开
第五章auth的 restore() 会取消上一次恢复。若导航A与导航B各调一次,两个本来想等同一个会话结果的消费者反而互相抢占。把正在进行的Promise留在单一协调者里,公开页面无需等它;受保护页面共享它。
以下函数放在客户端应用创建处,每个app创建一次;SSR则每请求创建一次。auth就是第五章的store,restore() 在401时返回false,网络故障时抛错。
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
})
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
调用 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自己的轮次应使旧恢复失效。不要用“导航取消”代替会话失效,也不要让某个页面离开时中止其他页面正在共享的恢复。
避免重定向循环
if (to.meta.requiresAuth && !auth.isAuthenticated && to.name !== 'login') {
return { name: 'login', query: { redirect: to.fullPath } }
}2
3
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,把草稿判断留给组件,并让服务端承担最终授权。