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

Vue Router 完整指南:路由匹配、守卫、懒加载和滚动行为

Vue Router 是 Vue 3 单页应用中负责“URL 与界面状态同步”的路由系统。它至少解决四件事:

  1. 根据当前 URL 匹配一个或多个路由记录;
  2. 将动态参数、查询字符串和哈希片段提供给组件;
  3. 在导航发生前后执行校验、数据准备或清理逻辑;
  4. 在不同页面之间切换时控制代码加载和滚动位置。

本文示例基于 Vue 3、Composition API、TypeScript 和 Vite,使用 Vue Router 4。示例中的 API 均属于 Vue Router 4 的稳定能力;如果项目使用 Vue Router 3,创建路由器、守卫返回值、组合式 API 等写法不能直接照搬。


一、先建立最小可运行路由

1. 安装和目录结构

Vite 项目中通常安装:

npm install vue-router

一个最小目录可以是:

src/
├── main.ts
├── App.vue
├── router/
│   └── index.ts
└── views/
    ├── HomeView.vue
    ├── UserView.vue
    └── NotFoundView.vue

创建路由器:

// src/router/index.ts
import {
  createRouter,
  createWebHistory,
  type RouteRecordRaw,
} from 'vue-router'

import HomeView from '@/views/HomeView.vue'
import UserView from '@/views/UserView.vue'
import NotFoundView from '@/views/NotFoundView.vue'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: HomeView,
  },
  {
    path: '/users/:id',
    name: 'user',
    component: UserView,
    props: true,
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: NotFoundView,
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router

在应用入口挂载:

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App)
  .use(router)
  .mount('#app')

根组件需要渲染当前匹配到的页面:

<!-- src/App.vue -->
<template>
  <nav>
    <RouterLink to="/">首页</RouterLink>
    <RouterLink :to="{ name: 'user', params: { id: '42' } }">
      用户 42
    </RouterLink>
  </nav>

  <main>
    <RouterView />
  </main>
</template>

RouterLink 负责生成导航链接,默认使用客户端导航而不是让浏览器完整刷新页面;RouterView 根据当前路由渲染匹配到的组件。

createWebHistory() 使用 HTML5 History API,URL 看起来最自然,例如 /users/42。它要求生产服务器将未知路径回退到应用入口,否则用户直接访问 /users/42 时,服务器可能返回 404,而不是交给 Vue Router 处理。

如果服务器无法配置回退,也可以使用:

import { createWebHashHistory } from 'vue-router'

const router = createRouter({
  history: createWebHashHistory(),
  routes,
})

此时 URL 类似 /#/users/42# 后面的部分不会作为 HTTP 请求路径发送给服务器,因此部署简单,但 URL 形式不同。


二、路由匹配的基本模型

2.1 路由记录不是组件本身

一个路由记录至少包含:

type RouteRecordRaw = {
  path: string
  name?: string | symbol
  component?: Component
  components?: Record<string, Component>
  children?: RouteRecordRaw[]
  redirect?: RouteLocationRaw | string | Function
  alias?: string | string[]
  meta?: RouteMeta
  beforeEnter?: NavigationGuard | NavigationGuard[]
}

可以把路由匹配抽象为一个函数:

M(U,R)LM(U, R) \rightarrow L

其中:

  • UU 是浏览器当前 URL;
  • RR 是路由记录集合;
  • LL 是归一化后的路由位置 RouteLocationNormalized

L 不只是一个字符串,它通常包含:

{
  fullPath: '/users/42?tab=posts#comments',
  path: '/users/42',
  name: 'user',
  params: { id: '42' },
  query: { tab: 'posts' },
  hash: '#comments',
  matched: [/* 匹配到的路由记录 */],
  meta: { /* 合并后的 meta */ }
}

之后,Vue Router 根据 matched 中的组件树渲染嵌套的 RouterView

2.2 静态路径、动态参数和通配路径

静态路径:

{ path: '/about', component: AboutView }

只匹配 /about,不匹配 /about/team

动态参数:

{ path: '/users/:id', component: UserView }

它可以匹配:

/users/42
/users/alice

但不会匹配:

/users/42/profile

在组件中读取参数:

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

const route = useRoute()

console.log(route.params.id)
</script>

注意:路径参数默认是字符串。即使 URL 中是 /users/42route.params.id 也是 '42',不是数字 42。如果业务需要数字,应显式转换并校验:

const userId = Number(route.params.id)

if (!Number.isInteger(userId) || userId <= 0) {
  // 显示参数错误或跳转到 404
}

限制参数格式:

{
  path: '/users/:id(\\d+)',
  component: UserView,
}

在 JavaScript 或 TypeScript 字符串中,反斜杠需要再次转义,因此源码里写成 \\d+。该路径只匹配数字 ID。

Vue Router 4 使用自己的路径解析规则。常见重复参数写法包括:

{
  path: '/files/:pathMatch(.*)*',
  component: FileView,
}

这个写法常用于 404 路由,能够匹配任意剩余路径。重复参数可能以数组形式出现在 params 中,因此不能假设所有参数始终是普通字符串。

推荐的 404 写法是:

{
  path: '/:pathMatch(.*)*',
  name: 'not-found',
  component: NotFoundView,
}

应将它放在路由表中作为兜底记录。Vue Router 会根据路径得分选择更具体的匹配;静态段通常比普通动态参数更具体,普通动态参数通常比通配参数更具体。不过当多个路由具有相同匹配优先级时,不应依赖不明显的声明顺序,最好让路径本身具有清晰区分度。

2.3 参数、查询字符串和哈希不是同一个概念

考虑 URL:

/products/10?sort=price#reviews

三部分的语义不同:

部分 示例 是否决定路径记录匹配 典型用途
路径参数 /products/:id 中的 10 资源标识
查询字符串 ?sort=price 通常不决定记录匹配 筛选、分页、排序
哈希片段 #reviews 不决定记录匹配 页内定位

读取方式:

const route = useRoute()

route.params.id       // '10'
route.query.sort      // 'price'
route.hash            // '#reviews'

查询参数可能是字符串、字符串数组或 undefined

const page = Number(route.query.page ?? 1)
const tags = route.query.tag

不要直接断言:

const page = route.query.page as string

因为 URL 可能是:

/products?page=2&page=3

此时查询参数可能是数组。生产代码应对输入进行规范化和校验。

查询参数不会像路径参数那样用于选择不同的路由记录:

{ path: '/search', component: SearchView }

以下 URL 通常都匹配同一个记录:

/search
/search?q=vue
/search?q=vue&page=2

但查询变化仍然会导致 route.query 变化,组件可以据此重新请求数据。

2.4 命名路由比拼接字符串更稳定

可以通过路径跳转:

router.push('/users/42')

也可以通过名称和参数跳转:

router.push({
  name: 'user',
  params: { id: '42' },
})

在模板中:

<RouterLink
  :to="{ name: 'user', params: { id: user.id } }"
>
  查看用户
</RouterLink>

命名路由的好处是组件不需要知道完整路径。如果未来把 /users/:id 改成 /members/:id,使用名称的调用方通常不需要修改。

对动态参数而言,名称导航还能让 Vue Router 负责参数编码。不要手工拼接未编码的用户输入:

// 不推荐:可能产生空格、斜杠等编码问题
router.push(`/search/${keyword}`)

更稳妥的方式是:

router.push({
  name: 'search',
  params: { keyword },
})

如果参数本身可能包含 /,应重新考虑它应该是一个路径参数,还是应该放入查询字符串,因为 / 会影响路径分段。

2.5 嵌套路由和 RouterView

后台页面常见布局:

const routes: RouteRecordRaw[] = [
  {
    path: '/admin',
    component: () => import('@/layouts/AdminLayout.vue'),
    children: [
      {
        path: '',
        name: 'admin-home',
        component: () => import('@/views/admin/AdminHomeView.vue'),
      },
      {
        path: 'users',
        name: 'admin-users',
        component: () => import('@/views/admin/AdminUsersView.vue'),
      },
    ],
  },
]

这里的 children 使用相对路径:

  • 父路径是 /admin
  • 子路径 '' 形成 /admin
  • 子路径 'users' 形成 /admin/users

AdminLayout.vue 必须包含子级出口:

<template>
  <aside>后台菜单</aside>
  <section>
    <RouterView />
  </section>
</template>

访问 /admin/users 时,匹配链大致是:

/admin
└── /admin/users

父组件渲染外层布局,子组件渲染到父组件内部的 RouterView

如果直接写:

{
  path: '/admin/users',
  component: AdminUsersView,
}

它是一个独立的顶层路由,不会自动渲染进 AdminLayoutRouterView。嵌套关系必须在路由记录中显式表达。

2.6 props: true 可以隔离组件与路由

默认情况下,组件需要通过 useRoute() 读取路由:

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

const route = useRoute()
const id = route.params.id
</script>

启用 props: true 后,路径参数会作为组件属性传入:

{
  path: '/users/:id',
  component: UserView,
  props: true,
}

组件可以写成:

<script setup lang="ts">
const props = defineProps<{
  id: string
}>()

console.log(props.id)
</script>

查询参数不会因为 props: true 自动作为 props 传入。若需要把查询参数也映射为 props,可以使用函数:

{
  path: '/search',
  component: SearchView,
  props: (route) => ({
    q: typeof route.query.q === 'string' ? route.query.q : '',
    page: Number(route.query.page ?? 1),
  }),
}

这使组件更容易独立测试,但映射函数必须处理恶意或异常 URL,因为用户可以直接修改地址栏。


三、重定向、别名和匹配结果

3.1 重定向会改变目标位置

{
  path: '/home',
  redirect: '/',
}

访问 /home 时,路由器会继续导航到 //home 不是最终渲染页面。

也可以使用函数:

{
  path: '/legacy-user/:id',
  redirect: (to) => ({
    name: 'user',
    params: { id: to.params.id },
  }),
}

重定向记录本身不会渲染组件,也不会执行该记录上的组件内守卫。目标路由的导航流程仍然会执行。

3.2 别名保留原始 URL

{
  path: '/users/:id',
  alias: ['/u/:id'],
  component: UserView,
}

/users/42/u/42 都渲染同一个组件,但 URL 可以保留用户实际访问的形式。重定向则会改变最终 URL。

选择方式:

  • 旧 URL 需要迁移到新 URL:使用 redirect
  • 多个 URL 都是合法入口,并希望保留入口 URL:使用 alias

四、导航 API 和导航结果

4.1 pushreplace 和浏览器历史

await router.push({ name: 'user', params: { id: '42' } })

push 会创建一条浏览器历史记录,用户点击后退可以返回上一页。

await router.replace({ name: 'user', params: { id: '42' } })

replace 修改当前历史项,不新增记录。登录成功后跳转到首页、筛选条件同步 URL 等场景通常适合 replace

router.go(-1)
router.back()
router.forward()

这些 API 最终依赖浏览器历史。router.push() 返回 Promise,因此不要把“调用了 push”误认为“导航已经成功”。

import {
  isNavigationFailure,
  NavigationFailureType,
} from 'vue-router'

const failure = await router.push({ name: 'user', params: { id: '42' } })

if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
  console.log('导航被守卫取消')
}

常见导航失败包括:

  • aborted:守卫返回 false
  • cancelled:当前导航尚未完成,就开始了另一次导航;
  • duplicated:已经处于目标位置,再次导航到同一位置。

守卫中抛出的异常、异步组件加载错误等属于异常路径,不应简单当作普通取消处理。可以通过 router.onError() 统一监听:

router.onError((error, to, from) => {
  console.error('路由错误', {
    error,
    to: to.fullPath,
    from: from.fullPath,
  })
})

4.2 不要把 paramspath 混用

以下写法容易造成误解:

router.push({
  path: '/users/42',
  params: { id: '42' },
})

当使用 path 时,路径已经由调用方给出,params 不会按预期替换路径占位符。应二选一:

router.push({
  name: 'user',
  params: { id: '42' },
})

或:

router.push('/users/42')

查询字符串则可以和名称或路径一起使用:

router.push({
  name: 'user',
  params: { id: '42' },
  query: { tab: 'posts' },
  hash: '#comments',
})

五、路由守卫:导航为什么能被暂停、取消或重定向

5.1 守卫的形式化行为

导航守卫可以看作函数:

G(to,from){继续,取消,重定向,错误}G(to, from) \rightarrow \{\text{继续}, \text{取消}, \text{重定向}, \text{错误}\}

在 Vue Router 4 中,推荐直接返回结果:

router.beforeEach((to, from) => {
  if (to.meta.requiresAuth && !isLoggedIn()) {
    return {
      name: 'login',
      query: { redirect: to.fullPath },
    }
  }

  return true
})

返回值语义:

返回值 行为
undefinedtrue 允许继续
false 取消当前导航
路由位置对象或字符串 取消当前导航,并开始新的导航
抛出异常 导航失败,交给错误处理流程

异步守卫会暂停导航:

router.beforeEach(async (to) => {
  const session = await loadSession()

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

导航不会在 loadSession() 完成前确认,因此守卫中的请求必须考虑超时、异常和重复调用。

5.2 全局前置守卫

常见认证守卫:

// router/index.ts
router.beforeEach((to) => {
  const auth = useAuthStore()

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

如果使用 Pinia,必须确保 Pinia 已安装后再在守卫中调用 store。最简单的入口顺序是:

const app = createApp(App)

app.use(pinia)
app.use(router)
app.mount('#app')

更严格的做法是在创建路由器时显式传入 Pinia 实例,避免模块初始化顺序造成问题:

// router/index.ts
import { useAuthStore } from '@/stores/auth'
import pinia from '@/stores'

router.beforeEach((to) => {
  const auth = useAuthStore(pinia)

  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { name: 'login' }
  }
})

守卫只负责导航决策,不应该被当作后端授权机制。用户可以绕过前端代码直接调用 API,因此 API 服务端仍然必须验证身份和权限。

5.3 meta 的类型扩展

路由记录可以携带元信息:

const routes: RouteRecordRaw[] = [
  {
    path: '/admin',
    component: AdminLayout,
    meta: {
      requiresAuth: true,
      requiresAdmin: true,
    },
  },
]

TypeScript 中可以扩展 RouteMeta

// src/types/router.d.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    requiresAdmin?: boolean
    title?: string
  }
}

to.meta 是匹配链中各级 meta 的合并结果,子记录覆盖同名字段。它适合表达路由配置,例如是否需要认证、页面标题或布局信息;不适合放入会频繁变化的大型运行时状态。

5.4 全局解析守卫:适合导航前数据准备

beforeResolve 在异步组件解析和其他前置守卫之后执行,适合执行“确认所有条件满足后才继续”的逻辑:

router.beforeResolve(async (to) => {
  if (to.meta.requiresAdmin) {
    const auth = useAuthStore()

    const allowed = await auth.checkAdminPermission()

    if (!allowed) {
      return { name: 'forbidden' }
    }
  }
})

但不要把所有页面数据请求都塞进全局守卫。这样会让整个导航等待所有数据完成,导致:

  • 页面切换延迟变长;
  • 一个非关键请求失败,整个导航失败;
  • 守卫难以区分取消、重试和缓存。

如果页面可以先显示骨架,再加载内容,通常更适合在组件或 store 中请求数据。

5.5 组件内组合式守卫

Composition API 提供:

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

const hasUnsavedChanges = ref(false)

onBeforeRouteLeave(() => {
  if (hasUnsavedChanges.value) {
    return window.confirm('当前内容尚未保存,确定离开吗?')
  }
})

onBeforeRouteUpdate((to, from) => {
  console.log('同一组件实例下路由参数变化', {
    from: from.fullPath,
    to: to.fullPath,
  })
})
</script>

onBeforeRouteLeave 适合阻止编辑页面离开。

onBeforeRouteUpdate 处理“组件仍然复用,但路由位置变化”的场景。例如当前组件从 /users/1 切换到 /users/2,Vue Router 可能复用同一个 UserView 实例。此时不能只依赖组件挂载钩子重新加载用户数据。

一种完整写法:

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'

const route = useRoute()
const user = ref<User | null>(null)

async function loadUser(id: string) {
  user.value = await fetchUser(id)
}

await loadUser(String(route.params.id))

onBeforeRouteUpdate(async (to) => {
  await loadUser(String(to.params.id))
})
</script>

如果数据请求不需要阻塞导航,可以改用 watch

watch(
  () => route.params.id,
  (id) => {
    if (typeof id === 'string') {
      void loadUser(id)
    }
  },
  { immediate: true },
)

两者的差别是:守卫可以阻止或延迟导航;watch 通常让导航先完成,再更新页面内容。

5.6 守卫执行顺序

一次完整导航不是单个回调,而是一条有顺序的流程:

sequenceDiagram
    participant U as 用户或代码
    participant R as Router
    participant G as 守卫
    participant C as 异步组件
    participant V as Vue

    U->>R: router.push(to)
    R->>G: beforeRouteLeave
    R->>G: beforeEach
    R->>G: beforeRouteUpdate
    R->>G: beforeEnter
    R->>C: 解析异步路由组件
    R->>G: beforeRouteEnter
    R->>G: beforeResolve
    R->>R: 确认导航
    R->>G: afterEach
    R->>V: 更新组件和 DOM
    R->>R: 执行滚动行为

常见顺序可以概括为:

  1. 被离开的组件执行 beforeRouteLeave
  2. 执行全局 beforeEach
  3. 被复用组件执行 beforeRouteUpdate
  4. 执行目标记录上的 beforeEnter
  5. 解析异步路由组件;
  6. 执行组件式 beforeRouteEnter
  7. 执行全局 beforeResolve
  8. 确认导航;
  9. 执行 afterEach
  10. Vue 更新 DOM;
  11. 执行滚动行为。

具体组件树、重定向和取消情况会改变实际经过的步骤。守卫返回重定向后,当前导航不会先确认,而是启动一轮新的导航。

5.7 beforeEnter 与全局守卫的选择

路由级守卫:

{
  path: '/admin',
  component: AdminLayout,
  beforeEnter: (to) => {
    return hasAdminPermission()
  },
}

它只针对某个路由记录及其进入行为,适合局部规则。

全局守卫:

router.beforeEach((to) => {
  // 所有导航都会经过
})

它适合认证、全局维护模式、统一埋点等跨页面规则。

如果只是从 /users/1 变成 /users/2,并且仍然命中同一个路由记录,beforeEnter 不会因为参数变化而重新执行。此时应使用 onBeforeRouteUpdate 或监听参数。

5.8 next 的兼容写法风险

Vue Router 4 仍支持第三个参数 next

router.beforeEach((to, from, next) => {
  if (isLoggedIn()) {
    next()
  } else {
    next({ name: 'login' })
  }
})

它的问题是控制流容易重复调用:

// 错误示例:某些路径下会调用两次 next
router.beforeEach((to, from, next) => {
  if (!isLoggedIn()) {
    next({ name: 'login' })
  }

  next()
})

推荐使用返回值写法:

router.beforeEach((to) => {
  if (!isLoggedIn()) {
    return { name: 'login' }
  }

  return true
})

5.9 防止重定向循环

以下守卫会无限重定向:

router.beforeEach((to) => {
  if (!isLoggedIn()) {
    return { name: 'login' }
  }
})

因为访问 /login 时仍然满足未登录条件,于是 /login 又重定向到 /login

正确写法:

router.beforeEach((to) => {
  const requiresAuth = to.meta.requiresAuth === true

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

登录成功后恢复原目标:

const route = useRoute()
const router = useRouter()

async function submit() {
  await login()

  const redirect =
    typeof route.query.redirect === 'string'
      ? route.query.redirect
      : '/'

  await router.replace(redirect)
}

这里仍应限制可接受的跳转目标,防止把外部 URL 当成站内回跳地址。更安全的做法是只允许以 / 开头且不以 // 开头的相对路径,或只保存经过验证的路由名称。


六、懒加载:路由组件如何按需下载

6.1 静态导入和动态导入的差别

静态导入:

import ReportsView from '@/views/ReportsView.vue'

{
  path: '/reports',
  component: ReportsView,
}

应用初始加载时,构建工具通常会把它放入初始代码或相关初始依赖中。

懒加载:

{
  path: '/reports',
  component: () => import('@/views/ReportsView.vue'),
}

这里的函数返回一个 Promise。Vite 和 Rollup 会将该组件拆分为独立 chunk,首次进入 /reports 时再请求该 chunk。

懒加载的真实流程是:

flowchart LR
    A[访问 /reports] --> B[匹配路由记录]
    B --> C[执行 import()]
    C --> D{chunk 加载成功?}
    D -->|是| E[解析组件]
    E --> F[确认导航并渲染]
    D -->|否| G[路由错误处理]

懒加载减少了首次下载量,但不会消除代码体积。用户进入该页面时仍然需要下载对应 chunk,因此它是在“首次加载时间”和“后续页面切换延迟”之间做分配。

6.2 路由组件应直接返回 import()

推荐:

{
  path: '/settings',
  component: () => import('@/views/SettingsView.vue'),
}

不要在路由配置中额外包一层异步组件:

// 通常不需要
{
  path: '/settings',
  component: defineAsyncComponent(
    () => import('@/views/SettingsView.vue'),
  ),
}

Vue Router 已经会处理路由组件的异步解析。额外使用 defineAsyncComponent 会引入另一层加载和错误处理语义,除非有明确的组件级异步需求。

6.3 同一 chunk 的分组

可以通过 Vite/Rollup 的注释将多个相关页面放入同一 chunk:

const routes: RouteRecordRaw[] = [
  {
    path: '/admin/users',
    component: () =>
      import(/* @vite-ignore */ '@/views/admin/UsersView.vue'),
  },
]

不过 @vite-ignore 会关闭 Vite 对该表达式的静态分析,通常不应随意使用。对于固定路径,直接写普通的动态导入即可。更可靠的分组策略应通过构建工具配置或明确的静态导入表达式实现,而不是依赖未经验证的注释。

Vite/Rollup 的 chunk 命名和拆分结果属于构建工具实现,不是 Vue Router API 的规范保证。不要在业务逻辑中依赖某个自动生成的文件名。

6.4 预加载不是懒加载本身

可以在用户悬停链接时预取页面模块:

<script setup lang="ts">
const loadReports = () => import('@/views/ReportsView.vue')
</script>

<template>
  <RouterLink
    to="/reports"
    @mouseenter="loadReports"
    @focus="loadReports"
  >
    报表
  </RouterLink>
</template>

这会提前触发模块下载,但不代表路由已经导航,也不代表页面数据已经加载。它还可能浪费带宽,尤其是用户只是短暂悬停或移动端网络昂贵时。

模块懒加载和数据懒加载是两个层次:

代码 chunk 加载
        ↓
组件创建
        ↓
组件请求 API
        ↓
页面数据可用

只优化 chunk,可能仍然在页面进入后等待很久的 API;只预取数据,又可能仍然等待页面代码。两者应根据真实访问概率、网络和数据新鲜度分别设计。

6.5 懒加载失败的原因与处理

生产环境中可能出现:

  • 新版本发布后旧页面引用了已经删除的 chunk;
  • CDN 网络错误;
  • 用户离线;
  • 服务端错误配置 MIME 类型或缓存;
  • 部署过程中 HTML 与静态资源版本不一致。

监听错误:

router.onError((error, to) => {
  console.error('路由组件加载失败', error)

  // 可以显示全局错误提示。
  // 是否自动刷新必须谨慎,避免刷新死循环。
  if (to.name === 'reports') {
    // 记录监控信息或引导用户重试
  }
})

自动刷新不是无条件的修复方案。应限制刷新次数,例如使用 sessionStorage 记录当前版本是否已经重试过;否则 chunk 持续不可用时会形成刷新循环。


七、滚动行为:导航后页面应该滚到哪里

7.1 默认行为和显式配置

创建路由器时传入 scrollBehavior

const router = createRouter({
  history: createWebHistory(),
  routes,

  scrollBehavior(to, from, savedPosition) {
    return { top: 0 }
  },
})

返回的对象会被传给浏览器滚动 API。最常见的字段是:

{
  top: 0,
  left: 0,
}

这表示导航后滚动到页面左上角。

scrollBehavior 的参数:

  • to:目标路由;
  • from:来源路由;
  • savedPosition:浏览器前进后退时保存的位置;普通 push 导航通常没有该值。

一个实用配置:

const router = createRouter({
  history: createWebHistory(),
  routes,

  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    }

    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth',
      }
    }

    return {
      top: 0,
      left: 0,
    }
  },
})

行为推导如下:

  1. 用户点击后退或前进;
  2. 浏览器和路由器提供 savedPosition
  3. 返回该位置,尽量恢复用户离开页面时的滚动状态;
  4. 如果是普通导航且 URL 有哈希,滚动到目标元素;
  5. 其他普通页面从顶部开始。

7.2 savedPosition 为什么只适合历史导航

用户从列表页进入详情页:

/products  →  /products/1

如果详情页加载完成后按“返回”,用户通常希望回到列表原来的滚动位置,而不是列表顶部。savedPosition 用于这个场景。

但用户点击新的菜单项进入另一个页面时,通常希望从顶部开始,因此不能无条件返回旧位置:

scrollBehavior(to, from, savedPosition) {
  return savedPosition ?? { top: 0 }
}

这个写法虽然简洁,但应理解其含义:只有存在历史保存位置时恢复,否则回顶部。

7.3 哈希滚动需要真实 DOM 元素

若 URL 是:

/docs#installation

页面中应存在:

<section id="installation">
  ...
</section>

然后:

scrollBehavior(to) {
  if (to.hash) {
    return {
      el: to.hash,
    }
  }

  return { top: 0 }
}

如果目标元素尚未渲染,滚动可能失败。常见原因包括:

  • 目标内容由异步请求决定;
  • 目标位于折叠面板中,当前没有布局位置;
  • 使用了虚拟列表;
  • 哈希包含需要特殊 CSS 选择器处理的字符;
  • 页面有固定顶部导航,元素被遮挡。

固定头部可以使用 CSS:

:target {
  scroll-margin-top: 72px;
}

也可以返回偏移量:

scrollBehavior(to) {
  if (to.hash) {
    return {
      el: to.hash,
      top: -72,
    }
  }

  return { top: 0 }
}

behavior: 'smooth' 依赖浏览器滚动行为支持。它不会自动解决异步内容尚未出现的问题。

7.4 异步滚动行为

scrollBehavior 可以返回 Promise:

scrollBehavior(to, from, savedPosition) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(savedPosition ?? { top: 0 })
    }, 200)
  })
}

更合理的用途是等待页面过渡动画或必要的 DOM 更新:

import { nextTick } from 'vue'

scrollBehavior(to, from, savedPosition) {
  return nextTick().then(() => {
    if (savedPosition) {
      return savedPosition
    }

    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth',
      }
    }

    return { top: 0 }
  })
}

但应避免把任意 API 请求放进 scrollBehavior。滚动定位需要等待布局,而不是等待所有业务数据;如果请求长时间未完成,用户会看到导航已经变化但页面迟迟不滚动。

7.5 嵌套滚动容器不是窗口滚动

scrollBehavior 默认针对浏览器页面滚动。若应用使用:

.content {
  height: 100vh;
  overflow-y: auto;
}

那么滚动发生在 .content,而不是 window。此时返回 { top: 0 } 可能不会达到预期,因为窗口本身没有滚动。需要对具体容器调用 scrollTo(),或设计统一的滚动容器管理机制。

这不是 Vue Router 能自动推断的布局信息。路由器知道目标位置,但不知道哪个 DOM 元素是业务页面的滚动容器。


八、路由参数变化、组件复用和数据请求竞态

8.1 为什么页面没有重新挂载

从:

/users/1

导航到:

/users/2

两者都匹配:

{ path: '/users/:id', component: UserView }

Vue Router 通常复用同一个 UserView 组件实例。这样做避免了不必要的销毁和创建,但也意味着:

onMounted(() => {
  loadUser()
})

只会在第一次挂载时执行,参数变化时不一定执行。

可以使用 watch

const route = useRoute()

watch(
  () => route.params.id,
  (id) => {
    if (typeof id === 'string') {
      void loadUser(id)
    }
  },
  { immediate: true },
)

也可以使用 onBeforeRouteUpdate,差别在于是否让导航等待新的数据。

8.2 只取消旧请求还不够:还要防止旧结果覆盖新结果

假设用户快速访问:

/users/1 → /users/2

请求时序可能是:

t0: 发出请求 A,获取用户 1
t1: 发出请求 B,获取用户 2
t2: B 返回,显示用户 2
t3: A 返回,错误地覆盖成用户 1

这称为竞态。使用 AbortController 可以取消旧请求,同时使用请求序号保证结果顺序:

const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<unknown>(null)

let requestId = 0
let controller: AbortController | null = null

async function loadUser(id: string) {
  const currentId = ++requestId

  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}`)
    }

    const data = (await response.json()) as User

    if (currentId === requestId) {
      user.value = data
    }
  } catch (err) {
    if (err instanceof DOMException && err.name === 'AbortError') {
      return
    }

    if (currentId === requestId) {
      error.value = err
    }
  } finally {
    if (currentId === requestId) {
      loading.value = false
    }
  }
}

这里有两层保护:

  1. abort() 减少旧请求继续消耗网络和服务器资源;
  2. requestId 防止即使旧请求已经完成、无法真正取消,旧结果仍覆盖新状态。

如果请求状态放在 Pinia store 中,store 应明确区分:

idle → loading → success
              ↘ error

并为每个资源或参数维护请求标识,否则多个页面共享同一 store 时更容易互相覆盖。Pinia 适合保存跨组件共享的路由数据、缓存和请求状态,但不改变 Vue Router 的导航生命周期。


九、导航守卫与数据加载的取舍

9.1 阻塞式加载

router.beforeResolve(async (to) => {
  if (to.name === 'user') {
    const id = String(to.params.id)
    await userStore.ensureUser(id)
  }
})

优点:

  • 路由确认时数据已经准备好;
  • 适合必须存在的数据,例如进入编辑页前必须取得权限和资源版本。

缺点:

  • 页面切换会等待网络;
  • 错误可能直接让导航失败;
  • 需要设计加载中的全局反馈。

9.2 非阻塞式加载

<script setup lang="ts">
const route = useRoute()
const store = useUserStore()

watch(
  () => String(route.params.id),
  (id) => {
    void store.fetchUser(id)
  },
  { immediate: true },
)
</script>

<template>
  <UserSkeleton v-if="store.loading" />
  <ErrorPanel v-else-if="store.error" @retry="store.fetchUser(String(route.params.id))" />
  <UserContent v-else-if="store.user" :user="store.user" />
</template>

优点:

  • 页面能快速显示骨架;
  • 单个请求失败不会必然阻塞整个导航;
  • 更容易提供重试、缓存和部分渲染。

缺点:

  • URL 已经改变,但内容可能尚未准备好;
  • 必须处理旧数据、错误和竞态;
  • 需要决定加载期间是否保留上一条数据。

选择标准不是“守卫一定更好”或“组件请求一定更好”,而是看数据是否是导航成立的前提:

  • 权限、资源是否存在、编辑锁:倾向于守卫或 beforeResolve
  • 列表内容、推荐数据、非关键统计:倾向于组件或 store 内异步加载。

十、页面标题、埋点和 afterEach

导航确认后可以根据 meta 设置标题:

router.afterEach((to) => {
  document.title = typeof to.meta.title === 'string'
    ? `${to.meta.title} - Example`
    : 'Example'
})

afterEach 不能取消导航,因为此时导航已经确认。它适合:

  • 设置页面标题;
  • 发送页面访问埋点;
  • 记录导航耗时;
  • 清理不再需要的全局状态。

不要在 afterEach 中执行必须阻止页面进入的权限判断,因为判断发生得太晚。


十一、服务端渲染和部署边界

11.1 浏览器历史模式需要服务器回退

使用 createWebHistory() 时,访问:

https://example.com/users/42

浏览器会直接向服务器请求 /users/42。服务器必须将该路径回退到应用入口,例如 index.html。否则:

  • 客户端通过站内链接导航可能正常;
  • 用户刷新当前页可能 404;
  • 用户从外部链接直接打开深层 URL 可能 404。

这不是 Vue Router 匹配失败,而是请求根本没有到达客户端路由器。

11.2 SSR 使用内存历史

服务端没有浏览器 History API。SSR 场景通常使用:

import { createMemoryHistory, createRouter } from 'vue-router'

const router = createRouter({
  history: createMemoryHistory(),
  routes,
})

每个服务端请求必须创建独立的路由器实例,不能在所有请求之间共享可变路由状态。服务端还需要根据当前请求 URL 执行初始导航,并等待异步组件和必要的数据准备;客户端 hydration 时必须使用一致的初始路由,否则可能出现服务端与客户端渲染结果不一致。

滚动行为通常只在浏览器端有意义。服务端没有 window 或真实页面滚动位置,不能在 SSR 阶段直接访问这些对象。


十二、常见失败表现与诊断路径

12.1 刷新深层 URL 返回 404

检查顺序:

  1. 是否使用了 createWebHistory()
  2. 服务器是否配置了 SPA fallback;
  3. index.html 是否能正确加载;
  4. 静态资源路径是否与部署子目录一致;
  5. 应用是否设置了正确的 Vite base

如果应用部署在 /console/,路由器通常应配置:

const router = createRouter({
  history: createWebHistory('/console/'),
  routes,
})

同时构建工具的基础路径也要匹配部署位置。具体配置取决于部署方式,不能只修改路由器而忽略静态资源 URL。

12.2 路由命中了,但页面没有显示

检查:

  • 根组件是否包含 <RouterView />
  • 嵌套路由的父组件是否包含子级 <RouterView />
  • 路由记录是否使用了正确的组件;
  • 动态导入是否报错;
  • router.onError() 是否记录了 chunk 加载异常;
  • 是否被守卫返回 false 或重定向;
  • 是否使用了命名路由但名称拼写错误。

12.3 参数变了,数据没变

表现:

URL 已从 /users/1 变成 /users/2
页面仍显示用户 1

原因通常是组件被复用,onMounted() 没有再次执行。诊断:

watch(
  () => route.params.id,
  (id, oldId) => {
    console.log({ id, oldId })
  },
  { immediate: true },
)

然后检查加载函数是否支持取消旧请求和防止竞态。

如果确实需要强制重建组件,可以:

<RouterView v-slot="{ Component, route }">
  <component :is="Component" :key="route.fullPath" />
</RouterView>

但这会让查询参数、哈希或任意路径变化都可能触发销毁和重建,导致本地表单状态丢失、请求重复执行。它是明确的生命周期选择,不应作为修复所有参数问题的默认方案。

12.4 守卫无限跳转

记录每次守卫的目标:

router.beforeEach((to, from) => {
  console.debug('[guard]', {
    from: from.fullPath,
    to: to.fullPath,
    loggedIn: isLoggedIn(),
  })
})

重点检查:

  • 未登录时是否也拦截登录页;
  • 权限不足页是否又被权限守卫拦截;
  • 重定向目标是否再次满足原重定向条件;
  • next() 是否调用了两次;
  • 是否在守卫中无条件执行 router.push()

守卫中优先返回路由位置,不要通过副作用调用另一个 router.push()

// 推荐
return { name: 'login' }

// 不推荐
router.push({ name: 'login' })
return false

后者会同时管理当前导航和新导航,控制流更复杂。

12.5 滚动没有恢复

检查:

  • 是否真的在使用浏览器历史前进后退;
  • savedPosition 是否存在;
  • 页面是否由内部滚动容器控制;
  • 哈希对应元素是否已经渲染;
  • scrollBehavior 是否返回了正确的 el
  • 是否有固定头部遮挡目标;
  • CSS 是否覆盖了滚动行为。

可以临时记录:

scrollBehavior(to, from, savedPosition) {
  console.debug('scroll', {
    to: to.fullPath,
    from: from.fullPath,
    savedPosition,
  })

  return savedPosition ?? { top: 0 }
}

十三、一个综合路由配置

下面的配置把前面的能力组合起来:

// src/router/index.ts
import {
  createRouter,
  createWebHistory,
  type RouteRecordRaw,
} from 'vue-router'
import pinia from '@/stores'
import { useAuthStore } from '@/stores/auth'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/HomeView.vue'),
    meta: {
      title: '首页',
    },
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/LoginView.vue'),
    meta: {
      title: '登录',
    },
  },
  {
    path: '/users/:id(\\d+)',
    name: 'user',
    component: () => import('@/views/UserView.vue'),
    props: true,
    meta: {
      title: '用户详情',
      requiresAuth: true,
    },
  },
  {
    path: '/admin',
    component: () => import('@/layouts/AdminLayout.vue'),
    meta: {
      requiresAuth: true,
      requiresAdmin: true,
    },
    children: [
      {
        path: '',
        name: 'admin-home',
        component: () => import('@/views/admin/AdminHomeView.vue'),
        meta: {
          title: '管理后台',
        },
      },
      {
        path: 'users',
        name: 'admin-users',
        component: () => import('@/views/admin/AdminUsersView.vue'),
        meta: {
          title: '用户管理',
        },
      },
    ],
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('@/views/NotFoundView.vue'),
    meta: {
      title: '页面不存在',
    },
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,

  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    }

    if (to.hash) {
      return {
        el: to.hash,
        top: -72,
      }
    }

    return {
      top: 0,
      left: 0,
    }
  },
})

router.beforeEach((to) => {
  const auth = useAuthStore(pinia)

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

  if (to.meta.requiresAdmin && !auth.isAdmin) {
    return {
      name: 'forbidden',
    }
  }
})

router.afterEach((to) => {
  document.title = typeof to.meta.title === 'string'
    ? `${to.meta.title} - Example`
    : 'Example'
})

router.onError((error, to, from) => {
  console.error('router error', {
    error,
    to: to.fullPath,
    from: from.fullPath,
  })
})

export default router

这个示例体现了几个关键事实:

  • 路径参数格式可以限制为数字;
  • 通过 nameparams 生成稳定导航;
  • 管理后台使用嵌套路由;
  • 路由组件通过动态导入懒加载;
  • meta 为全局守卫提供声明式条件;
  • 认证失败返回登录路由,而不是直接调用嵌套的 router.push()
  • 后退时恢复滚动位置,普通导航回到顶部;
  • 路由组件加载失败由 onError 进入统一诊断路径。

其中 forbidden 路由必须在实际项目中补充,否则权限不足时会因为目标名称不存在而产生新的导航错误:

{
  path: '/forbidden',
  name: 'forbidden',
  component: () => import('@/views/ForbiddenView.vue'),
  meta: {
    title: '无权访问',
  },
}

十四、几个需要明确区分的边界

路由匹配不等于业务校验

/:id(\d+) 只能保证参数形式是数字,不能保证用户存在,也不能保证当前用户有权访问。业务校验仍需通过 API 或 store 完成。

导航成功不等于数据成功

路由器可以成功进入 /users/42,但用户数据请求可能返回 404、403 或 500。页面必须分别呈现:

导航状态:成功
数据状态:loading / success / error

不要把所有数据错误都转化为路由错误,否则普通网络波动会让用户无法进入页面。

懒加载不等于性能自动变好

懒加载减少初始 JavaScript,但增加了首次进入目标页面时的请求。是否收益取决于:

  • 页面是否很少访问;
  • chunk 是否足够大;
  • 首屏是否依赖该页面;
  • 网络缓存和 CDN 是否可靠;
  • 是否需要预取。

滚动行为不等于布局管理

Vue Router 可以决定“滚动到哪个位置”,但不会自动知道固定头部、内部滚动容器、虚拟列表或异步折叠内容的布局规则。这些必须通过 CSS、组件状态和应用自己的滚动管理配合完成。

前端守卫不等于安全边界

守卫只能改善用户体验和前端流程,不能保护接口、文件或管理权限。真正的权限控制必须在服务端验证。


Vue Router 的核心可以归纳为一条数据流:

URL
  ↓
路由记录匹配
  ↓
解析 params / query / hash / meta
  ↓
执行守卫
  ↓
加载异步组件
  ↓
确认导航
  ↓
渲染 RouterView
  ↓
执行滚动行为

当页面出现异常时,应沿这条链路逐层定位:是 URL 没有命中记录,是参数解析错误,是守卫取消或重定向,是异步 chunk 加载失败,是组件复用后没有响应参数变化,还是滚动容器与路由器预期不一致。掌握这些因果关系后,路由表、守卫、懒加载和滚动配置就不再是互相独立的选项,而是一套完整的导航状态转换系统。


系列导航与关联阅读

官方资料

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