Vue 基础体系 · 第 43/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue Router 深入:动态路由、元数据、数据预取和失败处理
Vue Router 4 是 Vue 3 应用中负责“URL 与组件状态同步”的路由器。它不仅决定当前渲染哪个页面,还参与以下流程:
- 将 URL 解析为路由位置;
- 根据路由记录匹配组件;
- 执行导航守卫;
- 加载异步组件;
- 处理页面数据获取;
- 提交或取消这次导航;
- 将导航失败、组件加载失败和业务错误传递给应用。
本文中的示例基于 Vue 3、Composition API、TypeScript、Vite 和 Vue Router 4。
一、先建立路由模型:URL、路由记录与匹配结果
1. 路由记录不是当前路由
路由记录(route record)是配置:
{
path: '/users/:id',
name: 'user-detail',
component: UserDetailView
}
它描述了:
- 哪些 URL 可以匹配;
- 匹配后使用哪个组件;
- 该路由携带哪些元数据;
- 是否有子路由;
- 是否需要懒加载组件。
当前路由(route location)则是某一次导航后的运行时结果,例如:
{
fullPath: '/users/42?tab=activity#top',
path: '/users/42',
name: 'user-detail',
params: { id: '42' },
query: { tab: 'activity' },
hash: '#top',
meta: { requiresAuth: true },
matched: [...]
}
路由记录是规则,当前路由是规则对某个 URL 的匹配结果。
2. Vue Router 如何匹配动态参数
考虑以下路由:
{
path: '/users/:id',
name: 'user-detail',
component: UserDetailView
}
其中:
/users/42可以匹配;/users/alice也可以匹配;/users不能匹配,因为缺少id;/users/42/edit不能匹配,除非另有更具体的路由。
: 后面的 id 是路径参数名。它不是固定字符串,而是一个占位符。
匹配成功后:
const route = useRoute()
console.log(route.params.id) // '42'
需要注意:路径参数默认是字符串。即使 URL 是 /products/42,也应当显式转换:
const productId = Number(route.params.id)
if (!Number.isInteger(productId)) {
throw new Error('无效的商品 ID')
}
不要直接假设 route.params.id 是数字。
3. params、query 和 hash 的职责不同
/users/42?tab=activity#comments
└──────┘ └──────────┘ └────────
path query hash
它们的语义通常不同:
| 部分 | 示例 | 常见用途 |
|---|---|---|
params |
/users/:id |
资源身份、页面层级 |
query |
?page=2&sort=name |
筛选、分页、排序、临时视图状态 |
hash |
#comments |
当前文档内锚点 |
例如:
router.push({
name: 'user-detail',
params: { id: '42' },
query: { tab: 'activity' },
hash: '#comments'
})
使用命名路由和 params,通常比手写字符串更安全,因为路由器会根据路由记录生成 URL。
二、动态路由:参数动态与路由记录动态是两件事
“动态路由”有两个容易混淆的含义。
1. 动态路径参数
这是静态配置中的动态片段:
{
path: '/posts/:slug',
name: 'post-detail',
component: () => import('@/views/PostDetailView.vue')
}
路由记录始终存在,只是 :slug 的值随 URL 改变。
2. 运行时添加或删除路由记录
这是根据权限、租户、插件或后端配置,在应用运行过程中改变路由表:
router.addRoute({
path: '/reports',
name: 'reports',
component: () => import('@/views/ReportsView.vue'),
meta: {
requiresAuth: true,
permission: 'report:read'
}
})
这里增加的是一条新的路由记录,而不是给已有路由的参数赋值。
两种机制的生命周期不同:
flowchart TD
A[用户访问 URL] --> B[解析 path/query/hash]
B --> C[使用当前路由表匹配]
C --> D{是否匹配成功}
D -- 否 --> E[进入 404 或重定向]
D -- 是 --> F[执行导航守卫]
F --> G[加载组件和数据]
G --> H[提交导航]
如果是在守卫中动态添加路由,第一次匹配可能已经失败。因此添加完成后通常还需要重新返回当前 URL。
三、创建一个具备类型和元数据的路由器
1. 基础路由配置
// src/router/index.ts
import {
createRouter,
createWebHistory,
type RouteRecordRaw
} from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue')
},
{
path: '/users/:id',
name: 'user-detail',
component: () => import('@/views/UserDetailView.vue'),
props: route => ({
id: String(route.params.id)
}),
meta: {
requiresAuth: true,
title: '用户详情'
}
},
{
path: '/admin',
name: 'admin',
component: () => import('@/views/AdminLayout.vue'),
meta: {
requiresAuth: true,
permission: 'admin:access'
},
children: [
{
path: 'dashboard',
name: 'admin-dashboard',
component: () => import('@/views/AdminDashboardView.vue')
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue')
}
]
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
if (to.hash) {
return {
el: to.hash,
behavior: 'smooth'
}
}
return { top: 0 }
}
})
createWebHistory 使用浏览器 History API,生成的 URL 比 Hash 模式更自然,例如:
/users/42
但它要求生产服务器将未知路径回退到 index.html。如果服务器没有配置 fallback,用户直接刷新 /users/42 时,服务器可能返回 404,即使 Vue Router 本身配置正确。
对于不便配置服务器的静态托管环境,可以使用:
import { createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(),
routes
})
此时 URL 类似:
/#/users/42
服务器只需要处理根页面,路由部分由浏览器端处理。
2. 使用 props 解耦组件与路由
如果组件直接使用 useRoute(),组件就依赖了路由上下文:
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const id = String(route.params.id)
</script>
也可以通过 props 将参数传入:
<script setup lang="ts">
const props = defineProps<{
id: string
}>()
console.log(props.id)
</script>
路由配置:
{
path: '/users/:id',
name: 'user-detail',
component: UserDetailView,
props: route => ({
id: String(route.params.id)
})
}
这样组件可以在路由环境之外测试,也不需要知道 URL 参数的具体名称。
对于仅需要把路径参数原样传入的情况,可以写:
{
path: '/users/:id',
component: UserDetailView,
props: true
}
但 props: true 不会自动把 query 传入组件。若需要传入查询参数,应使用函数:
props: route => ({
id: String(route.params.id),
tab: typeof route.query.tab === 'string'
? route.query.tab
: 'overview'
})
四、嵌套路由和 matched:元数据从哪里来
嵌套路由通常用于布局:
{
path: '/admin',
component: AdminLayout,
meta: {
requiresAuth: true
},
children: [
{
path: 'dashboard',
component: AdminDashboard,
meta: {
title: '管理面板'
}
}
]
}
访问 /admin/dashboard 时,matched 大致包含:
/admin
/admin/dashboard
外层组件渲染 <RouterView /> 后,内层组件才会显示:
<!-- AdminLayout.vue -->
<template>
<section class="admin-layout">
<aside>管理菜单</aside>
<main>
<RouterView />
</main>
</section>
</template>
子路由的 path 写成 dashboard,而不是 /dashboard。前者表示相对于父路由的路径,后者通常会成为根路径,破坏预期的层级关系。
五、元数据:给路由附加声明式信息
1. meta 的定义和用途
路由元数据(route meta)是附着在路由记录上的任意业务信息:
{
path: '/settings',
component: SettingsView,
meta: {
requiresAuth: true,
title: '设置',
permission: 'settings:read',
keepAlive: true
}
}
Vue Router 不会自动执行这些字段。requiresAuth、title 和 permission 都只是应用约定,必须由守卫、布局组件或其他代码读取并解释。
常见用途包括:
- 判断是否要求登录;
- 判断是否需要某项权限;
- 设置页面标题;
- 指定布局;
- 控制面包屑;
- 指定是否缓存页面;
- 标记导航菜单是否可见。
元数据不是安全机制。浏览器端的 JavaScript、路由配置和 meta 都可以被用户查看和修改。真正的数据访问控制必须在服务端执行。
2. 父子路由元数据的合并规则
当前路由的 meta 是对 matched 中各层路由元数据的浅合并结果。概念上类似:
Object.assign({}, ...route.matched.map(record => record.meta))
因此子路由同名字段会覆盖父路由字段:
{
path: '/admin',
meta: {
requiresAuth: true,
layout: 'admin'
},
children: [
{
path: 'audit',
meta: {
layout: 'audit'
}
}
]
}
访问 /admin/audit 时,得到的有效元数据近似于:
{
requiresAuth: true,
layout: 'audit'
}
这不是深度合并。假设父子路由分别有:
parent.meta = {
permissions: {
all: ['admin'],
any: ['audit']
}
}
child.meta = {
permissions: {
all: ['auditor']
}
}
合并后整个 permissions 对象会被子路由替换,而不是递归合并成:
{
all: ['auditor'],
any: ['audit']
}
如果业务需要逐层检查权限,应遍历 route.matched,不要只依赖最终的 route.meta。
3. 为 RouteMeta 扩展 TypeScript 类型
可以通过模块扩展让 meta 获得类型检查:
// src/types/router.d.ts
import 'vue-router'
export {}
declare module 'vue-router' {
interface RouteMeta {
requiresAuth: boolean
title?: string
permission?: string
layout?: 'default' | 'admin'
}
}
如果将 requiresAuth 声明为必填,那么每一条路由记录都必须提供它。实际项目中可以根据配置规模选择:
interface RouteMeta {
requiresAuth?: boolean
}
类型声明只检查 TypeScript 代码,不会在运行时自动补齐字段,也不会阻止后端下发错误的动态路由配置。
4. 一个完整的认证与权限守卫
// src/router/guards.ts
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
export function installRouterGuards(router: Router) {
router.beforeEach(async to => {
const auth = useAuthStore()
if (!auth.initialized) {
await auth.initialize()
}
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return {
name: 'login',
query: {
redirect: to.fullPath
}
}
}
if (
to.meta.permission &&
!auth.hasPermission(to.meta.permission)
) {
return { name: 'forbidden' }
}
return true
})
}
在入口文件中安装:
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import { installRouterGuards } from './router/guards'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
installRouterGuards(router)
app.use(router)
router
.isReady()
.then(() => {
app.mount('#app')
})
.catch(error => {
console.error('初始导航失败', error)
app.mount('#app')
})
这里有两个重要的因果关系:
useAuthStore()依赖 Pinia,因此必须先app.use(pinia);router.isReady()等待初始导航完成,可以避免应用已经挂载但首屏路由仍在切换的中间状态。
beforeEach 可以返回:
true或不返回:继续;false:取消;- 路由位置对象:重定向;
Promise:等待异步判断;- 抛出异常:导航失败并进入错误处理流程。
Vue Router 4 仍支持 next,但新代码通常使用返回值风格,避免多条控制路径重复调用 next。
六、动态添加路由:权限、插件与多租户场景
1. addRoute 的基本行为
const removeReportsRoute = router.addRoute({
path: '/reports',
name: 'reports',
component: () => import('@/views/ReportsView.vue'),
meta: {
requiresAuth: true,
permission: 'report:read'
}
})
addRoute 返回一个删除函数:
removeReportsRoute()
也可以按名称删除:
router.removeRoute('reports')
使用名称删除时,名称必须唯一。添加同名路由时,Vue Router 会处理已有记录,但应用不应依赖模糊的覆盖行为;动态路由生成器应保证名称稳定且唯一。
可以查看当前路由表:
console.table(
router.getRoutes().map(record => ({
name: String(record.name),
path: record.path
}))
)
getRoutes() 适合调试和诊断,不应被当作权限校验的唯一依据。
2. 为什么添加路由后还要重新导航
假设用户直接访问:
/reports
守卫第一次运行时,路由表中还没有 /reports:
URL /reports
↓
当前路由表无法匹配
↓
进入 404 或通配路由
如果守卫此时异步获取权限并添加 /reports,当前这次匹配结果不会自动变成新路由。需要返回同一个目标位置,让 Vue Router 重新匹配:
return to.fullPath
完整示例:
import type { Router } from 'vue-router'
import { usePermissionStore } from '@/stores/permission'
export function installDynamicRouteGuard(router: Router) {
router.beforeEach(async to => {
const permission = usePermissionStore()
if (!permission.loaded) {
await permission.load()
const routes = permission.buildRoutes()
for (const route of routes) {
router.addRoute(route)
}
permission.markLoaded()
// 重新以当前 URL 匹配刚添加的路由
return to.fullPath
}
return true
})
}
如果不加 permission.loaded 判断,重新导航后守卫会再次加载并添加路由,形成无限循环。
更稳妥的做法是使用一个“本次应用实例是否已经注入”的状态,并在用户退出登录或切换租户时清理:
const removeDynamicRoutes: Array<() => void> = []
function addDynamicRoutes(router: Router, routes: RouteRecordRaw[]) {
for (const route of routes) {
const remove = router.addRoute(route)
removeDynamicRoutes.push(remove)
}
}
function clearDynamicRoutes() {
for (const remove of removeDynamicRoutes.splice(0)) {
remove()
}
}
3. 动态路由不能替代服务端权限
动态路由的作用是控制前端导航和页面可见性。例如没有 report:read 权限的用户不显示报表页面。
但攻击者可以:
- 修改前端状态;
- 直接请求 API;
- 手工构造前端路由;
- 读取打包后的 JavaScript。
因此 API 必须独立验证身份和权限:
浏览器路由守卫:改善用户体验和前端流程
服务端授权:真正保护数据和操作
二者缺一不可。
七、数据预取:导航前获取还是进入页面后获取
“数据预取”是指在页面需要展示数据之前发起数据请求。Vue Router 不规定唯一的数据获取方式,常见方案有两类。
1. 进入组件后获取
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
interface User {
id: string
name: string
}
const route = useRoute()
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
async function loadUser(id: string) {
loading.value = true
error.value = null
try {
const response = await fetch(`/api/users/${encodeURIComponent(id)}`)
if (!response.ok) {
throw new Error(`请求失败:HTTP ${response.status}`)
}
user.value = await response.json()
} catch (cause) {
error.value = cause instanceof Error
? cause
: new Error('未知错误')
} finally {
loading.value = false
}
}
watch(
() => String(route.params.id),
id => {
void loadUser(id)
},
{ immediate: true }
)
</script>
这种方式的特点是:
导航成功
↓
组件创建
↓
组件发起请求
↓
显示 loading
↓
显示数据或错误
优点是页面能立即显示骨架屏,路由不会因为数据慢而阻塞。缺点是组件必须处理加载和错误状态。
2. 导航守卫中预取
{
path: '/users/:id',
name: 'user-detail',
component: () => import('@/views/UserDetailView.vue'),
beforeEnter: async to => {
const id = String(to.params.id)
const response = await fetch(`/api/users/${encodeURIComponent(id)}`)
if (response.status === 404) {
return { name: 'not-found' }
}
if (!response.ok) {
throw new Error(`用户加载失败:HTTP ${response.status}`)
}
return true
}
}
其流程是:
开始导航
↓
执行守卫
↓
获取数据
↓
数据成功后提交导航
↓
渲染组件
优点是页面进入时数据已经准备好,适合“没有数据就无法展示”的页面。缺点是请求期间路由不会提交,用户可能停留在旧页面,需要全局 loading 或进度条。
3. 预取必须解决数据传递问题
仅在守卫中请求数据,但不保存结果,组件仍然需要再次请求:
守卫请求一次
↓
组件再次请求一次
这会造成重复请求。更合理的方式是将数据放入 Pinia、缓存层或专门的数据仓库。
一个简单的 Pinia store:
// src/stores/users.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface User {
id: string
name: string
}
export const useUsersStore = defineStore('users', () => {
const byId = ref<Record<string, User>>({})
async function ensureUser(id: string) {
if (byId.value[id]) {
return byId.value[id]
}
const response = await fetch(
`/api/users/${encodeURIComponent(id)}`
)
if (!response.ok) {
if (response.status === 404) {
throw new Error('USER_NOT_FOUND')
}
throw new Error(`用户请求失败:HTTP ${response.status}`)
}
const user = await response.json() as User
byId.value[id] = user
return user
}
return {
byId,
ensureUser
}
})
路由守卫中预取:
{
path: '/users/:id',
name: 'user-detail',
component: () => import('@/views/UserDetailView.vue'),
beforeEnter: async to => {
const users = useUsersStore()
const id = String(to.params.id)
try {
await users.ensureUser(id)
return true
} catch (error) {
if (
error instanceof Error &&
error.message === 'USER_NOT_FOUND'
) {
return { name: 'not-found' }
}
throw error
}
}
}
组件只读取缓存:
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useUsersStore } from '@/stores/users'
const route = useRoute()
const users = useUsersStore()
const user = computed(() => {
const id = String(route.params.id)
return users.byId[id]
})
</script>
<template>
<p v-if="user">{{ user.name }}</p>
<p v-else>用户数据不可用</p>
</template>
这里的关键不是“使用 Pinia 就一定正确”,而是守卫和组件共享同一份状态,避免了两套请求结果彼此不一致。
八、路由参数变化时,组件可能不会重新创建
从 /users/1 导航到 /users/2 时,路由记录和组件类型相同。Vue 通常会复用当前组件实例,而不是销毁后重新创建。
因此以下代码只会在首次创建时执行:
onMounted(() => {
loadUser(String(route.params.id))
})
当 id 改变时,它可能不会再次执行。
应监听参数:
watch(
() => String(route.params.id),
id => {
void loadUser(id)
},
{ immediate: true }
)
或者在组件内使用路由更新守卫:
import { onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteUpdate(async (to, from) => {
const nextId = String(to.params.id)
if (nextId !== String(from.params.id)) {
await loadUser(nextId)
}
})
二者的语义略有不同:
watch适合组件进入后维护响应式数据;onBeforeRouteUpdate可以阻止或延迟组件内的路由更新。
如果回调抛出异常,导航会失败;如果只想在组件内部显示错误,也可以捕获异常并设置组件状态。
九、并发请求和过期响应:失败处理不能只写 try/catch
当用户快速从 /users/1 切换到 /users/2,可能出现:
请求 A:用户 1,先发出,较慢
请求 B:用户 2,后发出,较快
B 返回,显示用户 2
A 返回,又覆盖成用户 1
这不是网络请求失败,而是“过期响应覆盖新状态”。
1. 使用 AbortController 取消旧请求
import { onBeforeUnmount, ref } from 'vue'
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
let controller: AbortController | null = null
async function loadUser(id: string) {
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = null
try {
const response = await fetch(
`/api/users/${encodeURIComponent(id)}`,
{ signal: controller.signal }
)
if (!response.ok) {
throw new Error(`请求失败:HTTP ${response.status}`)
}
user.value = await response.json()
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') {
return
}
error.value = cause instanceof Error
? cause
: new Error('未知错误')
} finally {
loading.value = false
}
}
onBeforeUnmount(() => {
controller?.abort()
})
AbortController 只能取消支持 AbortSignal 的请求。对于不支持取消的异步任务,还需要使用请求序号:
let requestVersion = 0
async function load(id: string) {
const version = ++requestVersion
const result = await fetchUser(id)
if (version !== requestVersion) {
return
}
user.value = result
}
这段判断保证只有最后一次请求能提交结果。
十、导航失败:失败不等于异常
router.push() 和 router.replace() 返回 Promise:
const failure = await router.push('/dashboard')
导航成功时,通常得到 undefined;导航被取消或拒绝时,可能得到一个 Navigation Failure 对象。
import {
isNavigationFailure,
NavigationFailureType
} from 'vue-router'
const failure = await router.push('/dashboard')
if (isNavigationFailure(failure)) {
if (failure.type === NavigationFailureType.duplicated) {
console.log('已经在目标页面')
} else if (failure.type === NavigationFailureType.aborted) {
console.log('导航守卫拒绝了本次导航')
} else if (failure.type === NavigationFailureType.cancelled) {
console.log('导航被后续导航取消')
}
}
几种常见类型:
1. duplicated
当前已经在目标位置,又导航到同一个位置:
await router.push('/dashboard')
await router.push('/dashboard')
第二次通常属于重复导航。它不是系统故障,按钮层可以忽略。
2. aborted
导航守卫返回 false:
router.beforeEach(() => {
if (hasUnsavedChanges()) {
return false
}
return true
})
常用于:
- 未保存表单;
- 权限不足;
- 用户取消操作。
3. cancelled
一次导航尚未完成时,又开始了另一条导航:
导航 A:等待数据
导航 B:用户点击了另一个链接
导航 A 被取消,导航 B 继续
它通常表示竞态,而不是目标页面不存在。
4. 守卫抛出的异常
如果守卫抛出普通异常:
router.beforeEach(async () => {
throw new Error('权限服务不可用')
})
这不是简单的 aborted。应通过 router.onError() 统一捕获:
router.onError(error => {
console.error('路由系统错误', error)
})
也可以在调用处捕获:
try {
const failure = await router.push('/dashboard')
if (failure && isNavigationFailure(failure)) {
console.warn('导航未完成', failure)
}
} catch (error) {
console.error('导航过程中发生异常', error)
}
不要把所有失败都当作“用户没有权限”。重复导航、用户取消、后续导航覆盖和服务器错误,需要不同的处理策略。
十一、完整的导航生命周期
一次典型导航会经过多个阶段。具体细节受是否存在组件更新、异步组件和不同守卫影响,但可以用下面的顺序理解:
sequenceDiagram
participant U as 用户
participant R as Router
participant G as Guards
participant C as Async Component
participant D as Data Store
participant V as View
U->>R: push('/users/42')
R->>R: 解析 URL 并匹配路由
R->>G: beforeEach
G->>D: 预取用户数据
D-->>G: 成功或失败
R->>G: beforeEnter / 组件内守卫
R->>C: 加载异步组件
C-->>R: 组件模块
R->>V: 提交 currentRoute
V->>V: 渲染页面
R-->>U: push Promise 完成
如果某一步:
- 返回
false:导航被中止; - 返回新位置:开始重定向;
- 抛出错误:导航失败;
- 后续导航抢先完成:当前导航被取消;
- 异步组件加载失败:进入错误处理流程。
路由守卫不是普通的“回调通知”,而是导航提交前的控制点。守卫中执行的异步任务越慢,用户看到旧页面的时间越长。
十二、异步组件加载失败与数据请求失败要分开处理
路由组件通常使用动态导入:
component: () => import('@/views/ReportsView.vue')
这会产生独立的代码分块。失败可能来自:
- 网络暂时不可用;
- CDN 资源不存在;
- 部署后旧页面引用了已经删除的旧 chunk;
- Service Worker 缓存了不一致的版本;
- 用户在加载过程中断网。
这与 /api/reports 请求失败不是同一类问题。
可以在全局错误处理器中记录:
router.onError(error => {
console.error('Router error:', error)
// 生产环境可根据 error.message 或错误类型上报监控
})
应用层还可以根据错误信息显示“刷新页面”或“重新加载资源”的界面,但不应无条件自动刷新,否则可能形成刷新循环。
一个简单的资源加载错误处理策略是:
let hasRetried = false
router.onError(error => {
const message = String(error)
const looksLikeChunkError =
message.includes('Failed to fetch dynamically imported module') ||
message.includes('Loading chunk')
if (looksLikeChunkError && !hasRetried) {
hasRetried = true
window.location.reload()
}
})
这只是经验性兜底,不是 Vue Router 对所有构建工具和浏览器的规范保证。生产环境应记录版本号、资源 URL 和是否已经重试,避免用户被反复刷新。
十三、用 beforeResolve 统一处理导航前数据
Vue Router 4 提供 beforeResolve,它在所有组件内守卫和异步路由组件解析之后、导航确认之前执行。它适合放置“只有确定要进入页面后才需要做”的检查或数据加载。
router.beforeResolve(async to => {
if (to.name !== 'user-detail') {
return true
}
const users = useUsersStore()
const id = String(to.params.id)
try {
await users.ensureUser(id)
return true
} catch (error) {
if (
error instanceof Error &&
error.message === 'USER_NOT_FOUND'
) {
return { name: 'not-found' }
}
throw error
}
})
与 beforeEach 的区别不是“哪个更高级”,而是执行时机不同:
beforeEach适合认证、全局权限和动态路由注入;beforeEnter适合某一条路由的进入逻辑;beforeResolve适合在组件和其他进入条件确认后执行的最终数据准备;- 组件内
onBeforeRouteUpdate适合处理当前组件实例复用时的参数变化。
如果一个数据请求与当前页面完全无关,不应放入导航守卫,否则会让所有页面跳转都受它影响。
十四、错误页面应区分三种状态
一个可靠的页面至少要区分:
loading:正在请求
success:请求成功
error:请求失败
还应区分资源不存在和服务不可用:
type LoadState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'not-found' }
| { status: 'error'; error: Error }
示例:
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useUsersStore } from '@/stores/users'
const route = useRoute()
const users = useUsersStore()
const status = ref<'loading' | 'success' | 'not-found' | 'error'>('loading')
const error = ref<Error | null>(null)
const user = computed(() => {
const id = String(route.params.id)
return users.byId[id]
})
watch(
() => String(route.params.id),
async id => {
status.value = 'loading'
error.value = null
try {
await users.ensureUser(id)
status.value = 'success'
} catch (cause) {
if (
cause instanceof Error &&
cause.message === 'USER_NOT_FOUND'
) {
status.value = 'not-found'
} else {
status.value = 'error'
error.value = cause instanceof Error
? cause
: new Error('未知错误')
}
}
},
{ immediate: true }
)
</script>
<template>
<p v-if="status === 'loading'">加载中……</p>
<p v-else-if="status === 'not-found'">
用户不存在。
</p>
<p v-else-if="status === 'error'">
加载失败:{{ error?.message }}
</p>
<article v-else-if="status === 'success' && user">
<h1>{{ user.name }}</h1>
</article>
</template>
HTTP 404 通常表示资源不存在,应导航到资源级 404 或显示“未找到”;HTTP 500、网络断开或超时通常表示可重试的服务错误,不应伪装成 404。
十五、afterEach 适合观测,不适合阻止导航
afterEach 在导航完成后执行:
router.afterEach((to, from, failure) => {
if (failure) {
console.warn('导航未成功', {
to: to.fullPath,
from: from.fullPath,
failure
})
return
}
document.title = to.meta.title
? `${to.meta.title} - WR BLOG`
: 'WR BLOG'
})
它可以用于:
- 页面标题;
- 埋点;
- 进度条结束;
- 记录导航结果。
它不能用于阻止已经提交的导航。如果需要阻止或重定向,应使用前置守卫。
afterEach 的第三个参数可以观察导航失败,但错误异常仍应通过 router.onError() 处理。
十六、常见误解与诊断方法
误解一:设置了 meta.requiresAuth 就自动完成鉴权
错误原因:meta 只是数据,Vue Router 不会理解业务字段。
诊断方式:
console.log(router.currentRoute.value.meta)
如果字段存在但页面仍可访问,说明缺少或错误安装了守卫。
误解二:动态添加路由后当前 URL 会自动重新匹配
通常不会。应在添加完成后:
return to.fullPath
同时设置初始化标志,避免无限重定向。
误解三:onMounted 能处理所有路由参数变化
不能。相同路由组件被复用时,onMounted 不会再次执行。应监听:
watch(() => route.params.id, ...)
或者使用:
onBeforeRouteUpdate(...)
误解四:导航 Promise rejected 就代表权限不足
不准确。重复导航、导航被取消、守卫中止和守卫异常是不同情况。应使用:
isNavigationFailure(failure)
并检查 failure.type。
误解五:前端隐藏菜单就是安全控制
不成立。菜单、路由表和 Pinia 状态都在客户端。服务端 API 必须重新验证身份和权限。
误解六:所有数据都应放在路由守卫中获取
不成立。守卫请求会阻塞导航。对于可渐进加载的数据,组件进入后获取通常能更快显示页面骨架;对于页面不可用前必须存在的数据,才适合导航前预取。
误解七:取消请求就不会有竞态
不一定。并非所有异步任务都支持取消,取消也可能发生在响应即将完成时。仍可使用请求版本号或目标 ID 校验,确保过期结果不能覆盖当前状态。
十七、一个可执行的最小组合
下面是一个简化的应用结构:
src/
├── main.ts
├── App.vue
├── router/
│ ├── index.ts
│ └── guards.ts
├── stores/
│ ├── auth.ts
│ ├── permission.ts
│ └── users.ts
├── types/
│ └── router.d.ts
└── views/
├── HomeView.vue
├── LoginView.vue
├── UserDetailView.vue
└── NotFoundView.vue
入口:
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
installRouterGuards(router)
app.use(router)
router.isReady().then(() => {
app.mount('#app')
})
应用根组件:
<template>
<RouterView />
</template>
路由组件:
{
path: '/users/:id',
name: 'user-detail',
component: () => import('@/views/UserDetailView.vue'),
meta: {
requiresAuth: true,
title: '用户详情'
}
}
守卫:
router.beforeEach(async to => {
const auth = useAuthStore()
if (!auth.initialized) {
await auth.initialize()
}
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return {
name: 'login',
query: { redirect: to.fullPath }
}
}
return true
})
导航调用:
async function openDashboard() {
const failure = await router.push({ name: 'dashboard' })
if (!failure) {
console.log('导航成功')
return
}
if (isNavigationFailure(failure)) {
console.log('导航未完成', failure.type)
}
}
这几个部分共同构成一个完整闭环:
URL
↓
路由匹配
↓
读取 meta
↓
认证与权限判断
↓
动态路由或异步组件加载
↓
数据预取或组件内请求
↓
提交导航
↓
渲染数据、处理错误、记录失败
十八、如何选择实现方式
可以根据页面的数据依赖关系选择策略:
| 场景 | 更适合的方式 |
|---|---|
| 登录、全局权限 | beforeEach |
| 单条路由的进入条件 | beforeEnter |
| 确定进入页面后才执行的数据预取 | beforeResolve |
| 页面允许先展示骨架屏 | 组件内 watch / onMounted |
| 同一路由参数变化 | watch 或 onBeforeRouteUpdate |
| 权限菜单或插件页面 | addRoute |
| 注销、切换租户后的路由清理 | removeRoute 或保存删除函数 |
| 页面标题和埋点 | afterEach |
| 守卫异常、异步组件加载异常 | router.onError |
| 导航是否重复、取消或中止 | isNavigationFailure |
核心判断标准不是 API 数量,而是失败时页面应该处于什么状态:
- 如果没有数据就不能进入页面,导航前预取更合适;
- 如果可以先显示页面结构,组件内获取更合适;
- 如果权限配置尚未到达,动态路由需要“注入后重新匹配”;
- 如果请求可能并发,必须处理取消和过期响应;
- 如果导航失败是正常用户行为,就不要把它当作系统异常上报。
Vue Router 的路由记录、元数据、守卫、数据层和错误处理最终应形成清晰的数据流,而不是让每个页面自行猜测当前导航发生了什么。只要区分“匹配规则”“当前位置”“导航控制”“页面数据”和“系统异常”这几个层次,动态路由和预取逻辑就能在复杂应用中保持可验证、可诊断的行为。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 样式工程:Scoped CSS、CSS Modules、变量、主题和覆盖边界
- 下一篇:Vue 登录与权限:路由、按钮、Token 刷新、403 和状态恢复
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论