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)。具体配置:
- Nginx:
try_files $uri $uri/ /index.html; - Cloudflare Pages:自动处理
- Vercel:
vercel.json里{"rewrites": [{"source": "/(.*)", "destination": "/"}]}
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>:
<a href="/about">:会触发浏览器真正的页面跳转(整页刷新)<RouterLink to="/about">:被拦截,走前端路由(不刷新)
规则:站内跳转全用 <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:
push:在历史栈里新增一条。用户点浏览器后退能回来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>
千万别搞混:
useRouter():拿到路由器实例(操作路由,比如push / replace)useRoute():拿到当前路由信息(读取 params / query)
一个是动词,一个是名词。
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 新写法):
true或undefined:放行false:取消导航- 字符串或对象(
{ path: '/login' }):重定向
旧写法(用 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
日常基本只用 redirect。alias 留给"老 URL 兼容"这种特殊场景。
10. in4vue 的路由现状
看 src/router/index.ts:
- 只有两条路由:
/首页、/notes/:slug(.*)*详情 - 都用懒加载(
() => import()) - 全局前置守卫是空壳(
return true),留给后续接入登录
后续会加的路由:
/ai:AI 问答页/playground:代码 Playground/category/:category:分类筛选页(也可能用 query 参数/?category=xxx)/search:独立搜索页(如果首页搜索不够用)
可以放心先不加的:
/login//register:当前产品定位"零门槛无需登录",不需要- 权限控制:没有用户系统时无意义
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 // 组件内离开
小练习
- 在
src/router/index.ts的beforeEach里加document.title = (to.meta.title as string) ?? 'in4vue',并给两个路由都加meta: { title: '...' },观察浏览器标签标题变化 - 在首页随便一张笔记卡片上,把
<RouterLink>改成<a :href="...">,对比两种跳转的体验(后者会整页刷新) - 进入某篇笔记详情,点浏览器"后退",再"前进",观察组件渲染——是否被重建?(不会,组件实例会被复用)