Skip to content

Vue Router 深入

路由原理、导航守卫、动态路由、权限控制、性能优化

一、路由基础

1.1 路由模式

Hash 模式

typescript
import { createRouter, createWebHashHistory } from 'vue-router'

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

// URL: http://example.com/#/user/123

原理

  • 使用 URL hash(#
  • 监听 hashchange 事件
  • 不会向服务器发送请求
  • 兼容性好

History 模式

typescript
import { createRouter, createWebHistory } from 'vue-router'

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

// URL: http://example.com/user/123

原理

  • 使用 HTML5 History API
  • pushStatereplaceState
  • 监听 popstate 事件
  • 需要服务器配置

服务器配置

nginx
# Nginx
location / {
  try_files $uri $uri/ /index.html;
}
apache
# Apache
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

1.2 路由配置

typescript
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/user/:id',
    name: 'User',
    component: () => import('@/views/User.vue'),
    props: true, // 将路由参数作为 props 传递
    meta: { requiresAuth: true }
  },
  {
    path: '/admin',
    component: () => import('@/layouts/AdminLayout.vue'),
    children: [
      {
        path: '',
        name: 'AdminDashboard',
        component: () => import('@/views/admin/Dashboard.vue')
      },
      {
        path: 'users',
        name: 'AdminUsers',
        component: () => import('@/views/admin/Users.vue')
      }
    ]
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/NotFound.vue')
  }
]

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

export default router

二、导航守卫

2.1 全局守卫

beforeEach

typescript
router.beforeEach((to, from, next) => {
  // 检查是否需要登录
  if (to.meta.requiresAuth && !isAuthenticated()) {
    next({ name: 'Login', query: { redirect: to.fullPath } })
  } else {
    next()
  }
})

beforeResolve

typescript
router.beforeResolve(async (to, from, next) => {
  // 在导航被确认之前,所有组件内守卫和异步路由组件被解析之后调用
  if (to.meta.requiresData) {
    try {
      await fetchData()
      next()
    } catch (error) {
      next(false)
    }
  } else {
    next()
  }
})

afterEach

typescript
router.afterEach((to, from) => {
  // 发送页面浏览统计
  analytics.track('pageview', {
    path: to.path,
    title: to.meta.title
  })
  
  // 更新页面标题
  document.title = to.meta.title || 'Default Title'
})

2.2 路由独享守卫

typescript
const routes = [
  {
    path: '/admin',
    component: AdminLayout,
    beforeEnter: (to, from, next) => {
      if (hasAdminPermission()) {
        next()
      } else {
        next({ name: 'Forbidden' })
      }
    }
  }
]

2.3 组件内守卫

vue
<script setup>
import { onBeforeRouteEnter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'

// 进入路由前
onBeforeRouteEnter((to, from) => {
  // 无法访问 this,因为组件实例还未创建
})

// 路由参数变化时
onBeforeRouteUpdate((to, from) => {
  // 可以访问组件实例
  console.log('路由更新')
})

// 离开路由前
onBeforeRouteLeave((to, from) => {
  const answer = window.confirm('确定要离开吗?未保存的更改将丢失。')
  if (!answer) return false
})
</script>

Options API

typescript
export default {
  beforeRouteEnter(to, from, next) {
    next(vm => {
      // 通过 vm 访问组件实例
    })
  },
  
  beforeRouteUpdate(to, from) {
    // 可以直接访问 this
  },
  
  beforeRouteLeave(to, from) {
    // 可以直接访问 this
  }
}

2.4 导航流程

1. 导航被触发
2. 在失活的组件里调用 beforeRouteLeave
3. 调用全局 beforeEach
4. 在重用的组件里调用 beforeRouteUpdate
5. 在路由配置里调用 beforeEnter
6. 解析异步路由组件
7. 在被激活的组件里调用 beforeRouteEnter
8. 调用全局 beforeResolve
9. 导航被确认
10. 调用全局 afterEach
11. 触发 DOM 更新
12. 调用 beforeRouteEnter 的 next 回调

三、动态路由

3.1 添加路由

typescript
// 动态添加路由
router.addRoute({
  path: '/new-route',
  name: 'NewRoute',
  component: () => import('@/views/NewRoute.vue')
})

// 添加嵌套路由
router.addRoute('ParentRoute', {
  path: 'child',
  name: 'ChildRoute',
  component: () => import('@/views/ChildRoute.vue')
})

3.2 删除路由

typescript
// 通过名称删除
router.removeRoute('RouteName')

// 通过添加同名路由覆盖
router.addRoute({ path: '/about', name: 'About', component: About })
router.addRoute({ path: '/other', name: 'About', component: Other })
// 原来的路由被删除

3.3 权限路由

typescript
// store/permission.ts
import { defineStore } from 'pinia'
import { RouteRecordRaw } from 'vue-router'

export const usePermissionStore = defineStore('permission', () => {
  const routes = ref<RouteRecordRaw[]>([])
  
  async function generateRoutes(roles: string[]) {
    // 根据角色过滤路由
    const accessedRoutes = filterRoutes(asyncRoutes, roles)
    routes.value = accessedRoutes
    
    // 动态添加路由
    accessedRoutes.forEach(route => {
      router.addRoute(route)
    })
    
    return accessedRoutes
  }
  
  function filterRoutes(routes: RouteRecordRaw[], roles: string[]) {
    const res: RouteRecordRaw[] = []
    
    routes.forEach(route => {
      const tmp = { ...route }
      
      if (hasPermission(roles, tmp)) {
        if (tmp.children) {
          tmp.children = filterRoutes(tmp.children, roles)
        }
        res.push(tmp)
      }
    })
    
    return res
  }
  
  function hasPermission(roles: string[], route: RouteRecordRaw) {
    if (route.meta?.roles) {
      return roles.some(role => route.meta!.roles!.includes(role))
    }
    return true
  }
  
  return { routes, generateRoutes }
})

使用

typescript
// router/index.ts
router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()
  
  if (userStore.token) {
    if (!userStore.roles.length) {
      try {
        // 获取用户信息
        const { roles } = await userStore.getInfo()
        
        // 生成可访问的路由
        await permissionStore.generateRoutes(roles)
        
        // 重新导航
        next({ ...to, replace: true })
      } catch (error) {
        next({ name: 'Login' })
      }
    } else {
      next()
    }
  } else {
    if (to.meta.requiresAuth) {
      next({ name: 'Login', query: { redirect: to.fullPath } })
    } else {
      next()
    }
  }
})

四、路由传参

4.1 params 传参

typescript
// 定义路由
{
  path: '/user/:id',
  name: 'User',
  component: User
}

// 跳转
router.push({ name: 'User', params: { id: '123' } })
router.push('/user/123')

// 获取参数
const route = useRoute()
console.log(route.params.id) // '123'

注意:params 不能与 path 一起使用。

typescript
// ❌ 错误
router.push({ path: '/user', params: { id: '123' } })

// ✅ 正确
router.push({ name: 'User', params: { id: '123' } })
router.push({ path: `/user/${id}` })

4.2 query 传参

typescript
// 跳转
router.push({ path: '/search', query: { keyword: 'vue' } })

// URL: /search?keyword=vue

// 获取参数
const route = useRoute()
console.log(route.query.keyword) // 'vue'

4.3 props 传参

typescript
// 定义路由
{
  path: '/user/:id',
  component: User,
  props: true // 将 params 作为 props 传递
}

// 组件
<script setup>
defineProps<{
  id: string
}>()
</script>

// 函数模式
{
  path: '/search',
  component: Search,
  props: (route) => ({ keyword: route.query.keyword })
}

// 对象模式
{
  path: '/promotion',
  component: Promotion,
  props: { newsletter: true }
}

五、路由懒加载

5.1 基础懒加载

typescript
const routes = [
  {
    path: '/about',
    component: () => import('@/views/About.vue')
  }
]

5.2 分组打包

typescript
const routes = [
  {
    path: '/admin',
    component: () => import(/* webpackChunkName: "admin" */ '@/views/Admin.vue')
  },
  {
    path: '/admin/users',
    component: () => import(/* webpackChunkName: "admin" */ '@/views/AdminUsers.vue')
  }
]

5.3 预加载

typescript
// 鼠标悬停时预加载
<RouterLink 
  to="/about" 
  @mouseenter="preloadAbout"
>
  About
</RouterLink>

<script setup>
function preloadAbout() {
  import('@/views/About.vue')
}
</script>

六、滚动行为

6.1 基础配置

typescript
const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // 返回期望的滚动位置
    if (savedPosition) {
      // 浏览器前进/后退时,恢复之前的滚动位置
      return savedPosition
    } else if (to.hash) {
      // 滚动到锚点
      return { el: to.hash, behavior: 'smooth' }
    } else {
      // 滚动到顶部
      return { top: 0 }
    }
  }
})

6.2 延迟滚动

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

6.3 平滑滚动

typescript
scrollBehavior(to, from, savedPosition) {
  if (to.hash) {
    return {
      el: to.hash,
      behavior: 'smooth',
      top: 100 // 偏移量
    }
  }
  return { top: 0, behavior: 'smooth' }
}

七、路由元信息

7.1 定义 meta

typescript
declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
    roles?: string[]
    icon?: string
    keepAlive?: boolean
  }
}

const routes = [
  {
    path: '/admin',
    component: Admin,
    meta: {
      title: '管理后台',
      requiresAuth: true,
      roles: ['admin'],
      icon: 'admin',
      keepAlive: true
    }
  }
]

7.2 使用 meta

typescript
// 设置页面标题
router.afterEach((to) => {
  document.title = to.meta.title || 'Default Title'
})

// 面包屑导航
const breadcrumbs = computed(() => {
  return route.matched
    .filter(r => r.meta.title)
    .map(r => ({
      title: r.meta.title,
      path: r.path
    }))
})

// KeepAlive 缓存
<KeepAlive>
  <component 
    :is="Component" 
    v-if="route.meta.keepAlive"
  />
</KeepAlive>
<component 
  :is="Component" 
  v-if="!route.meta.keepAlive"
/>

八、路由过渡动画

8.1 基础过渡

vue
<template>
  <RouterView v-slot="{ Component }">
    <Transition name="fade" mode="out-in">
      <component :is="Component" />
    </Transition>
  </RouterView>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

8.2 动态过渡

vue
<template>
  <RouterView v-slot="{ Component, route }">
    <Transition :name="route.meta.transition || 'fade'">
      <component :is="Component" />
    </Transition>
  </RouterView>
</template>

8.3 基于路由深度的过渡

vue
<script setup>
const route = useRoute()

const transitionName = computed(() => {
  const toDepth = route.matched.length
  const fromDepth = route.matched.length
  return toDepth < fromDepth ? 'slide-right' : 'slide-left'
})
</script>

<template>
  <RouterView v-slot="{ Component }">
    <Transition :name="transitionName">
      <component :is="Component" />
    </Transition>
  </RouterView>
</template>

九、性能优化

9.1 路由预加载

typescript
// 预加载下一个可能访问的路由
router.beforeEach((to, from, next) => {
  // 预加载相关路由
  if (to.name === 'Home') {
    import('@/views/About.vue')
  }
  next()
})

9.2 路由缓存策略

vue
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const cachedViews = ref<string[]>([])

const shouldCache = computed(() => {
  return route.meta.keepAlive && cachedViews.value.includes(route.name as string)
})

function addCachedView(name: string) {
  if (!cachedViews.value.includes(name)) {
    cachedViews.value.push(name)
  }
}

function removeCachedView(name: string) {
  const index = cachedViews.value.indexOf(name)
  if (index > -1) {
    cachedViews.value.splice(index, 1)
  }
}
</script>

<template>
  <KeepAlive :include="cachedViews">
    <RouterView />
  </KeepAlive>
</template>

面试高频题

1: Hash 模式和 History 模式有什么区别?

答案

Hash 模式

  • URL 带 #http://example.com/#/user
  • 监听 hashchange 事件
  • 不需要服务器配置
  • 兼容性好

History 模式

  • URL 正常:http://example.com/user
  • 使用 History API(pushStatereplaceState
  • 需要服务器配置(所有路由返回 index.html)
  • SEO 更友好

2: 导航守卫的执行顺序是什么?

答案

  1. 失活组件的 beforeRouteLeave
  2. 全局 beforeEach
  3. 重用组件的 beforeRouteUpdate
  4. 路由配置的 beforeEnter
  5. 解析异步组件
  6. 激活组件的 beforeRouteEnter
  7. 全局 beforeResolve
  8. 导航确认
  9. 全局 afterEach
  10. DOM 更新
  11. beforeRouteEnter 的 next 回调

3: params 和 query 有什么区别?

答案

params

  • 路径参数:/user/:id
  • URL:/user/123
  • 必须使用 name 跳转
  • 刷新页面参数不丢失(如果在路由定义中)

query

  • 查询参数:?keyword=vue
  • URL:/search?keyword=vue
  • 可以使用 path 或 name 跳转
  • 刷新页面参数不丢失

4: 如何实现路由懒加载?

答案

typescript
// 基础懒加载
{
  path: '/about',
  component: () => import('@/views/About.vue')
}

// 分组打包
{
  path: '/admin',
  component: () => import(/* webpackChunkName: "admin" */ '@/views/Admin.vue')
}

好处

  • 减小首屏加载体积
  • 按需加载
  • 提升首屏速度

5: 如何实现动态路由权限控制?

答案

typescript
router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  
  if (userStore.token) {
    if (!userStore.roles.length) {
      // 获取用户角色
      const { roles } = await userStore.getInfo()
      
      // 根据角色生成路由
      const accessRoutes = await permissionStore.generateRoutes(roles)
      
      // 动态添加路由
      accessRoutes.forEach(route => router.addRoute(route))
      
      // 重新导航
      next({ ...to, replace: true })
    } else {
      next()
    }
  } else {
    next({ name: 'Login' })
  }
})

6: beforeRouteEnter 为什么不能访问 this?

答案

因为守卫在导航确认前被调用,此时组件实例还未创建。

解决方案

typescript
beforeRouteEnter(to, from, next) {
  next(vm => {
    // 通过 vm 访问组件实例
    vm.setData()
  })
}

7: 如何实现路由缓存?

答案

vue
<template>
  <KeepAlive :include="cachedViews">
    <RouterView />
  </KeepAlive>
</template>

<script setup>
const cachedViews = ref(['Home', 'About'])
</script>

配合路由 meta

typescript
{
  path: '/home',
  component: Home,
  meta: { keepAlive: true }
}

8: 如何监听路由变化?

答案

typescript
// 方法 1: watch
import { watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

watch(() => route.path, (newPath, oldPath) => {
  console.log('路由变化', newPath)
})

// 方法 2: beforeRouteUpdate
onBeforeRouteUpdate((to, from) => {
  console.log('路由更新')
})

// 方法 3: 全局守卫
router.afterEach((to, from) => {
  console.log('导航完成')
})

9: 如何实现路由过渡动画?

答案

vue
<template>
  <RouterView v-slot="{ Component }">
    <Transition name="fade" mode="out-in">
      <component :is="Component" />
    </Transition>
  </RouterView>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

10: History 模式需要服务器配置什么?

答案

原因:刷新页面时,浏览器会向服务器请求当前 URL,如果服务器没有对应的资源,会返回 404。

解决:配置服务器,将所有路由请求都返回 index.html。

Nginx

nginx
location / {
  try_files $uri $uri/ /index.html;
}

Apache

apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]