路由守卫
路由守卫允许你在路由变化前后执行逻辑,与中间件类似但使用方式不同。理解守卫和中间件的区别并选择合适的方案,是写出清晰代码的关键。
为什么还需要路由守卫?
路由中间件适合"全局性"的检查(认证、权限),而路由守卫适合"组件级"的逻辑:
| 场景 | 用中间件 | 用守卫 |
|---|---|---|
| 检查用户是否登录 | ✅ 全局复用 | ❌ 分散到每个页面 |
| 表单未保存时阻止离开 | ❌ 中间件不知道表单状态 | ✅ 组件内部逻辑 |
| 路由参数变化时刷新数据 | ❌ 中间件不应该处理数据 | ✅ 组件知道如何刷新 |
| 记录页面访问日志 | ✅ 全局统一处理 | 也能做,但不集中 |
简单判断
逻辑是"全局性"的还是"组件级"的?全局性 → 中间件;组件级 → 守卫。
onBeforeRouteLeave
在离开当前页面前触发,最常用的场景是防止用户意外离开未保存的表单:
<script setup>
const formChanged = ref(false)
onBeforeRouteLeave((to, from) => {
if (formChanged.value) {
const answer = confirm('你有未保存的更改,确定要离开吗?')
if (!answer) return false // 阻止导航
}
})
</script>返回值说明
| 返回值 | 效果 | 用途 |
|---|---|---|
false | 阻止导航,用户留在当前页面 | 表单保护 |
true 或不返回 | 允许导航 | — |
| 路由路径字符串 | 重定向 | return '/login' |
| 路由对象 | 重定向 | return { path: '/login', query: { redirect: to.fullPath } } |
关键理解
onBeforeRouteLeave 只在客户端执行,不会在 SSR 中运行。因为"离开页面"本身就是客户端行为——SSR 只处理首次进入,不处理页面跳转。
与浏览器 beforeunload 的区别
onBeforeRouteLeave 只能拦截应用内部的导航(NuxtLink、navigateTo 等)。如果用户直接关闭浏览器标签页或输入新 URL,它是拦截不到的:
<script setup>
const formChanged = ref(false)
// ① 拦截应用内部导航(NuxtLink、navigateTo 等)
onBeforeRouteLeave((to, from) => {
if (formChanged.value) {
const answer = confirm('你有未保存的更改,确定要离开吗?')
if (!answer) return false
}
})
// ② 拦截浏览器关闭/刷新/输入新 URL
onMounted(() => {
window.addEventListener('beforeunload', (e) => {
if (formChanged.value) {
e.preventDefault() // 浏览器会显示默认的确认对话框
// 注意:现代浏览器忽略自定义消息,只显示浏览器默认文案
}
})
})
</script>两个守卫缺一不可
onBeforeRouteLeave:拦截应用内跳转(如点击导航链接)beforeunload:拦截浏览器级别的离开(关闭标签、刷新、输入新 URL)
如果只想写一个
用 onBeforeRouteLeave。大多数用户通过应用内导航,很少有人直接改 URL。
实战:完整的表单保护方案
<script setup>
const form = reactive({ title: '', content: '' })
const originalForm = ref('')
const saved = ref(false)
// 判断表单是否有修改
const formChanged = computed(() => {
return JSON.stringify(form) !== originalForm.value && !saved.value
})
// 保存原始值(在数据加载后)
onMounted(() => {
originalForm.value = JSON.stringify(form)
})
// 应用内导航保护
onBeforeRouteLeave((to, from) => {
if (formChanged.value) {
const answer = confirm('你有未保存的更改,确定要离开吗?')
if (!answer) return false
}
})
// 浏览器级别保护
onMounted(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (formChanged.value) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
})
// 保存成功后取消保护
async function save() {
await $fetch('/api/posts', { method: 'POST', body: form })
saved.value = true
originalForm.value = JSON.stringify(form)
}
</script>onBeforeRouteUpdate
在同一组件复用时(路由参数变化但组件不变)触发:
<script setup>
const route = useRoute()
const { data, refresh } = await useFetch(`/api/users/${route.params.id}`)
onBeforeRouteUpdate(async (to) => {
// 从 /users/1 导航到 /users/2 时触发
if (to.params.id !== route.params.id) {
await refresh()
}
})
</script>为什么需要 onBeforeRouteUpdate?
当从 /users/1 导航到 /users/2 时:
用户点击 <NuxtLink to="/users/2">
│
▼
Vue Router 发现两个路由使用同一个组件([id].vue)
│
▼
为了性能,Vue Router 复用现有组件实例(不销毁、不重建)
│
▼
onMounted、setup 等不会重新执行 ← 问题:数据不更新!
│
▼
onBeforeRouteUpdate 触发 ← 在这里处理参数变化什么时候会组件复用?
当两个路由使用同一个 Vue 组件时。这在动态路由中最常见(如 /users/[id].vue)。
更好的替代方案:useFetch 的 watch
const route = useRoute()
const { data } = await useFetch(`/api/users/${route.params.id}`, {
watch: [() => route.params.id], // id 变化时自动刷新
})推荐用 watch 方案
- 代码更简洁,不需要手动调用
refresh - 响应式追踪更精确,不会遗漏变化
onBeforeRouteUpdate还需要判断参数是否真的变了,watch自动处理
INFO
️ onBeforeRouteUpdate 的适用场景:当你需要在参数变化时做一些非数据获取的操作(如重置表单、关闭弹窗、清理状态) 这时 watch 不够灵活,守卫更合适
路由守卫 vs 路由中间件
| 特性 | 路由守卫 | 路由中间件 |
|---|---|---|
| 定义位置 | 组件内 | app/middleware/ 目录 |
| 作用范围 | 当前组件 | 可复用、可全局 |
| SSR | 仅客户端 | SSR + 客户端 |
| 可维护性 | 逻辑分散在各组件 | 集中管理 |
| 能访问组件状态 | ✅ 可以 | ❌ 不能 |
| 适用场景 | 组件级逻辑(表单保护) | 通用逻辑(认证、权限) |
选择原则
- 需要在 SSR 中执行?→ 中间件
- 需要在多个页面复用?→ 中间件
- 只在某个组件内使用?→ 路由守卫
- 需要访问组件内的状态?→ 路由守卫
- 涉及浏览器交互(如
confirm)?→ 路由守卫
编程式导航守卫
在插件中设置全局导航守卫:
// app/plugins/router-guards.ts
export default defineNuxtPlugin(() => {
const router = useRouter()
// 全局前置守卫
router.beforeEach((to, from) => {
console.log(`导航:${from.path} → ${to.path}`)
})
// 全局解析守卫
router.beforeResolve(async (to) => {
// 在中间件和组件内守卫之后执行
// 适合确保所有异步操作完成
})
// 全局后置钩子
router.afterEach((to) => {
// 导航完成后执行,不能阻止
// 适合日志、统计、页面标题更新
})
})三种全局守卫的执行顺序
1. beforeEach(全局前置守卫)
↓
2. 路由中间件 + 组件内守卫
↓
3. beforeResolve(全局解析守卫)
↓
4. 导航完成(DOM 更新)
↓
5. afterEach(全局后置钩子)什么时候用哪种全局守卫?
| 守卫 | 用途 | 典型场景 |
|---|---|---|
beforeEach | 最早拦截 | 全局认证检查、权限控制 |
beforeResolve | 确保异步完成 | 等待所有数据加载后再确认导航 |
afterEach | 导航后处理 | 页面统计、标题更新、滚动重置 |
afterEach 的典型用法
// 页面标题自动更新
router.afterEach((to) => {
const title = to.meta.title as string
if (title) {
useHead({ title: `${title} - 我的网站` })
}
})// 页面访问统计
router.afterEach((to) => {
if (process.client) {
// 发送页面浏览事件到统计服务
$fetch('/api/analytics/pageview', {
method: 'POST',
body: { path: to.path, title: to.meta.title },
})
}
})INFO
️ 全局守卫 vs 路由中间件:大多数情况下 路由中间件比全局守卫更好用——中间件可以按路由配置(definePageMeta),而全局守卫对所有路由生效。只有在需要"最早/最晚执行"或"全局统一逻辑"时才用全局守卫
异步守卫
守卫可以返回 Promise,支持异步操作:
// 异步 onBeforeRouteLeave
onBeforeRouteLeave(async (to, from) => {
const hasUnsavedChanges = await checkUnsavedChanges()
if (hasUnsavedChanges) {
const answer = confirm('你有未保存的更改,确定要离开吗?')
if (!answer) return false
}
})
// 异步全局守卫
router.beforeEach(async (to, from) => {
const allowed = await $fetch('/api/check-access', {
query: { path: to.path },
})
if (!allowed) {
return navigateTo('/forbidden')
}
})INFO
️ 异步守卫的注意事项
- 导航会被阻塞,直到 Promise resolve
- 如果 Promise reject,导航也会被阻止
- 避免在守卫中做耗时操作(如大量计算、大文件读写),会影响导航体验
- 如果守卫中有网络请求,添加超时处理
常见问题
守卫不触发
onBeforeRouteLeave:确认是通过 NuxtLink/navigateTo 导航,不是直接改浏览器 URLonBeforeRouteUpdate:确认是同一个组件复用的场景(路由参数变化)- 全局守卫:确认插件正确注册,且没有语法错误
守卫中用 confirm() 在 SSR 报错
confirm() 是浏览器 API,只在客户端可用。onBeforeRouteLeave 本身只在客户端执行,不会有问题。但如果在 beforeEach 等全局守卫中使用,需要判断环境:
router.beforeEach((to) => {
if (process.client) {
// 只在客户端使用浏览器 API
}
})中间件和守卫同时存在时的执行顺序
1. beforeEach(全局前置守卫)
2. 页面中间件(definePageMeta 中定义的)
3. onBeforeRouteLeave / onBeforeRouteUpdate(组件内守卫)
4. beforeResolve(全局解析守卫)
5. 导航完成
6. afterEach(全局后置钩子)知识脉络
路由中间件 → 你在这里:路由守卫
│
├─→ 下一步:路由规则
│
├─→ 相关:页面元信息(definePageMeta 中注册中间件)
│
└─→ 相关:路由导航(navigateTo 的使用)