Vue Router 基础:前端的 @RequestMapping

前端路由是什么

后端路由(Spring MVC)的流程:

浏览器发 GET /users/123
     ↓
Tomcat 收到请求
     ↓
DispatcherServlet 匹配到 UserController.getUser(123)
     ↓
返回 HTML / JSON

前端路由(SPA 模式)完全不同:

浏览器加载一次 index.html
     ↓
用户点"用户详情"链接(URL 变成 /users/123)
     ↓
浏览器并没有发请求(被 Vue Router 拦截)
     ↓
Vue Router 匹配到 UserDetailPage 组件
     ↓
渲染到 <RouterView />

关键点:URL 变了,但浏览器没往服务器发请求。整个过程在前端完成。用户感觉像"跳页",实际上是"换组件"。

这靠的是 history.pushState() API——JS 能改 URL 而不触发页面刷新。

Java 对照

Spring MVC Vue Router
@GetMapping("/users/{id}") { path: '/users/:id', component: ... }
Controller 方法 路由对应的组件(页面)
@PathVariable 路由参数 route.params
@RequestParam 查询参数 route.query
HandlerInterceptor 导航守卫 beforeEach / beforeEnter
RedirectView router.push(...) / redirect

概念几乎一一对应,但执行环境不同——Vue Router 跑在浏览器,没有 Servlet 容器这种东西。


1. 最小路由配置

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

const routes: RouteRecordRaw[] = [
  { path: '/', name: 'Home', component: () => import('@/pages/HomePage.vue') },
  { path: '/about', name: 'About', component: () => import('@/pages/AboutPage.vue') },
]

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

export default router

挂到应用:

// src/main.ts
import router from './router'
app.use(router)

根组件留一个"内容位":

<!-- App.vue -->
<template>
  <DefaultLayout>
    <RouterView />   <!-- 匹配的路由组件渲染在这里 -->
  </DefaultLayout>
</template>

Java 对照<RouterView /> ≈ Thymeleaf 的 th:fragment 占位符,但内容由路由决定。


2. history 两种模式

createWebHistory():HTML5 模式(推荐)

URL 长这样:https://example.com/notes/01-基础/01-es6

部署坑:刷新 /notes/xxx 时,浏览器向服务器请求这个路径。服务器没有这个文件,返回 404。

解法:让服务器对所有未知路径都返回 index.html(SPA fallback)。具体配置:

in4vue 目前部署在 Cloudflare Pages,这步自动搞定了。

createWebHashHistory():hash 模式

URL 长这样:https://example.com/#/notes/01-基础/01-es6

# 后面的内容浏览器不会发给服务器,天然兼容任何静态托管。缺点是 URL 丑,SEO 不友好。

选择建议能用 history 模式就用 history 模式。现在几乎所有静态托管都支持 SPA fallback。


3. 在模板里跳转:<RouterLink>

<template>
  <!-- 基础用法 -->
  <RouterLink to="/about">关于</RouterLink>

  <!-- 对象形式(可携带 params / query) -->
  <RouterLink :to="{ name: 'NoteDetail', params: { slug: note.slug } }">
    {{ note.title }}
  </RouterLink>

  <!-- 带查询参数 -->
  <RouterLink :to="{ path: '/search', query: { q: 'vue' } }">搜索</RouterLink>

  <!-- 激活状态:匹配时自动加 class router-link-active -->
  <RouterLink to="/" active-class="is-active">首页</RouterLink>
</template>

<RouterLink> vs <a href>

规则:站内跳转全用 <RouterLink>,站外跳转用 <a> + target="_blank"


4. 在脚本里跳转:useRouter

<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()

function goToLogin() {
  router.push('/login')
}

function goToNote(slug: string) {
  router.push({ name: 'NoteDetail', params: { slug } })
}

function goBack() {
  router.back()        // 等同 history.back()
}

function replaceCurrent() {
  router.replace('/home')   // 替换当前历史记录(不新增)
}
</script>

push vs replace


5. 读取路由信息:useRoute

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

// 路径参数:/notes/:slug
console.log(route.params.slug)

// 查询参数:/search?q=vue&page=2
console.log(route.query.q, route.query.page)

// 完整路径
console.log(route.fullPath)
</script>

千万别搞混

一个是动词,一个是名词。

route 对象是响应式的

<script setup>
import { useRoute, watch } from 'vue-router'
const route = useRoute()

// 监听 slug 变化:同一组件复用时要触发重新加载
watch(() => route.params.slug, (newSlug) => {
  fetchNote(newSlug)
})
</script>

为什么要监听?/notes/a 跳到 /notes/b 时,组件不会重新创建(路由路径模式相同),onMounted 不会重跑。只能监听 params 变化。这是前端路由特有的"坑"。


6. 动态路由匹配

基础参数

{ path: '/notes/:slug', component: NoteDetail }
// 匹配 /notes/hello → route.params.slug = 'hello'

多段参数(含斜杠)

in4vue 的笔记路径是 01-基础/01-es6,含斜杠。普通 :slug 不匹配,需要通配符:

{ path: '/notes/:slug(.*)*', component: NoteDetail }
// 匹配 /notes/01-基础/01-es6 → route.params.slug = ['01-基础', '01-es6']

Java 对照@GetMapping("/notes/{slug:.+}")——Spring 也要显式写正则才能匹配含 / 的路径变量。

多参数

{ path: '/user/:userId/post/:postId', component: ... }
// 匹配 /user/1/post/42 → { userId: '1', postId: '42' }

可选参数

{ path: '/search/:q?', component: ... }   // ? 表示可选

7. 导航守卫:前端的 HandlerInterceptor

全局前置守卫 beforeEach

router.beforeEach((to, from) => {
  // to: 要去哪
  // from: 从哪来

  if (to.meta.requiresAuth && !isLoggedIn()) {
    return '/login'        // 重定向到登录页
  }

  return true              // 放行
})

返回值(Vue Router 4 新写法):

旧写法(用 next()):

router.beforeEach((to, from, next) => {
  if (需要登录) next('/login')
  else next()
})

两种都支持。推荐新写法(返回值),少一个容易出 bug 的 next。

路由级守卫 beforeEnter

{
  path: '/admin',
  component: Admin,
  beforeEnter: (to, from) => {
    if (!isAdmin()) return '/403'
  },
}

只在进入这个路由时触发,比全局守卫更精准。

组件内守卫 onBeforeRouteLeave

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

const hasUnsavedChanges = ref(false)

onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    return window.confirm('有未保存的更改,确定离开?')
  }
})
</script>

典型用途:表单未保存提示。

Java 对照

Vue Router Spring
beforeEach(全局) HandlerInterceptor.preHandle(全局)
beforeEnter(路由级) 单独的 @RequestMapping 级别拦截器
onBeforeRouteLeave(组件级) (无直接对应,接近 Controller 的 @ModelAttribute 前置)

8. 路由元信息 meta

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

在任何地方读:

router.beforeEach((to) => {
  document.title = to.meta.title as string || 'in4vue'
  if (to.meta.requiresAuth && !isLoggedIn()) return '/login'
})

用途:把"这个路由有什么特性"的元数据挂在路由上,避免写大量 if-else。

Java 对照:像 Spring 的 @PreAuthorize("hasRole('admin')")——注解写在路由上声明权限,守卫统一读。


9. 重定向 & 别名

重定向(URL 真的变了)

{ path: '/home', redirect: '/' }
// 访问 /home 会变成 /,地址栏显示 /

别名(URL 不变,内容相同)

{ path: '/', alias: '/index', component: HomePage }
// 访问 /index 显示首页,地址栏还是 /index

日常基本只用 redirectalias 留给"老 URL 兼容"这种特殊场景。


10. in4vue 的路由现状

src/router/index.ts

后续会加的路由

可以放心先不加的:


11. 新手常见误区

误区 1:从 /notes/a 跳到 /notes/b 时组件没重载

路径参数变了但组件实例复用,onMounted 不会重跑。必须监听 route.params

<script setup>
watch(() => route.params.slug, (slug) => fetchNote(slug), { immediate: true })
</script>

误区 2:用 window.location.href = '/x' 跳转

这会触发整页刷新,SPA 的所有状态丢失。router.push('/x')

误区 3:路由参数全是字符串

const route = useRoute()
const id = route.params.id          // 类型是 string,不是 number
fetch(`/api/users/${Number(id)}`)   // 需要手动转

URL 里的一切都是字符串。数字类参数自己 Number() / parseInt()

误区 4:在守卫里搞死循环

router.beforeEach((to) => {
  if (!isLoggedIn) return '/login'  // 结果 /login 本身也进这个守卫,又被重定向,死循环
})

修复:排除 /login 本身:

router.beforeEach((to) => {
  if (to.path === '/login') return true
  if (!isLoggedIn) return '/login'
})

速查表

// 定义路由
{ path, name, component, meta, redirect, beforeEnter }

// 动态参数
'/user/:id'            // 单段
'/notes/:slug(.*)*'    // 多段(含斜杠)
'/search/:q?'          // 可选

// 跳转
router.push('/x')           // 入栈
router.push({ name, params, query })
router.replace('/x')        // 替换
router.back()               // 后退

// 读取
useRoute().params / query / path / name / meta

// 守卫
router.beforeEach((to, from) => ...)      // 全局前置
route.beforeEnter                          // 路由级
onBeforeRouteLeave                         // 组件内离开

小练习

  1. src/router/index.tsbeforeEach 里加 document.title = (to.meta.title as string) ?? 'in4vue',并给两个路由都加 meta: { title: '...' },观察浏览器标签标题变化
  2. 在首页随便一张笔记卡片上,把 <RouterLink> 改成 <a :href="...">,对比两种跳转的体验(后者会整页刷新)
  3. 进入某篇笔记详情,点浏览器"后退",再"前进",观察组件渲染——是否被重建?(不会,组件实例会被复用)

延伸阅读