Vue 基础体系 · 第 43/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue Router 深入:动态路由、元数据、数据预取和失败处理

Vue Router 4 是 Vue 3 应用中负责“URL 与组件状态同步”的路由器。它不仅决定当前渲染哪个页面,还参与以下流程:

  1. 将 URL 解析为路由位置;
  2. 根据路由记录匹配组件;
  3. 执行导航守卫;
  4. 加载异步组件;
  5. 处理页面数据获取;
  6. 提交或取消这次导航;
  7. 将导航失败、组件加载失败和业务错误传递给应用。

本文中的示例基于 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. paramsqueryhash 的职责不同

/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 不会自动执行这些字段。requiresAuthtitlepermission 都只是应用约定,必须由守卫、布局组件或其他代码读取并解释。

常见用途包括:

  • 判断是否要求登录;
  • 判断是否需要某项权限;
  • 设置页面标题;
  • 指定布局;
  • 控制面包屑;
  • 指定是否缓存页面;
  • 标记导航菜单是否可见。

元数据不是安全机制。浏览器端的 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')
  })

这里有两个重要的因果关系:

  1. useAuthStore() 依赖 Pinia,因此必须先 app.use(pinia)
  2. 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
同一路由参数变化 watchonBeforeRouteUpdate
权限菜单或插件页面 addRoute
注销、切换租户后的路由清理 removeRoute 或保存删除函数
页面标题和埋点 afterEach
守卫异常、异步组件加载异常 router.onError
导航是否重复、取消或中止 isNavigationFailure

核心判断标准不是 API 数量,而是失败时页面应该处于什么状态:

  • 如果没有数据就不能进入页面,导航前预取更合适;
  • 如果可以先显示页面结构,组件内获取更合适;
  • 如果权限配置尚未到达,动态路由需要“注入后重新匹配”;
  • 如果请求可能并发,必须处理取消和过期响应;
  • 如果导航失败是正常用户行为,就不要把它当作系统异常上报。

Vue Router 的路由记录、元数据、守卫、数据层和错误处理最终应形成清晰的数据流,而不是让每个页面自行猜测当前导航发生了什么。只要区分“匹配规则”“当前位置”“导航控制”“页面数据”和“系统异常”这几个层次,动态路由和预取逻辑就能在复杂应用中保持可验证、可诊断的行为。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。