Vue Router 4 从入门到实战:路由、导航传参、嵌套与守卫完整指南
- 前言
- 1. 路由、SPA 与组件分类
-
- 1.1 什么是路由
- 1.2 单页应用 SPA 与多页应用 MPA
- 1.3 页面组件与复用组件
- 2. Vue Router 基本使用与模块封装
-
- 2.1 先理解“4+2”完整流程
- 2.2 核心 API、参数与返回值
- 2.3 创建页面、配置规则并提供出口
- 2.4 路由的执行机制与空白页排查
- 2.5 抽离并封装路由模块
- 3. 声明式导航与路由传参
-
- 3.1 RouterLink 与激活样式
- 3.2 查询参数:适合筛选条件和多个可选值
- 3.3 动态路由参数:让资源标识成为路径的一部分
- 3.4 查询参数与动态参数对比
- 4. 重定向、404 与历史模式
-
- 4.1 使用 redirect 避免默认路径空白
- 4.2 配置 Vue Router 4 的 404 兜底页
- 4.3 Hash 模式与 HTML5 History 模式
- 5. 编程式导航与传参
-
- 5.1 useRouter、useRoute 与 router.push
- 5.2 编程式导航携带查询参数与动态参数
- 6. 嵌套路由:在页面内部继续切换页面
-
- 6.1 嵌套路由的结构与渲染流程
- 6.2 children、相对 path 与默认子页面
- 7. 路由守卫与访问权限
-
- 7.1 beforeEach 的执行时机、参数与返回值
- 7.2 从布尔判断到可维护的登录拦截
- 8. 完整项目整合与运行复盘
-
- 8.1 项目目标与文件结构
- 8.2 整合路由表、嵌套关系与守卫
- 8.3 整合入口、导航和页面组件
- 8.4 从启动到导航完成的执行链
- 总结
前言
在传统多页面网站中,用户点击链接后,浏览器通常会向服务器请求一个新的 HTML 页面;而在 Vue 单页应用中,页面的外壳通常始终是同一个 HTML,真正变化的是页面内部被渲染的组件。于是,一个关键问题出现了:浏览器地址变化后,应用应该显示哪个组件?
Vue Router 正是用来解决这个问题的。它把 URL 与 Vue 页面组件建立映射,并提供导航、参数传递、重定向、嵌套路由、历史记录模式和权限守卫等能力。掌握 Vue Router 不能只停留在“会复制配置”,还要理解浏览器地址、路由表、当前路由对象和 <router-view> 之间如何协作。
本文使用 Vue 3 + Vue Router 4 + <script setup> 语法,从基础概念一路讲到完整项目整合。阅读完成后,不但可以独立搭建路由模块,还能判断查询参数与动态参数的使用边界,处理刷新 404、二级页面空白、命名路由传参和登录拦截等常见问题。
说明: 由于 CSDN 对 Vue 代码块的语法高亮支持存在一定限制,本文中的 Vue 模板代码统一使用 html 类型进行代码块标注,仅用于优化代码高亮显示效果,不影响代码内容及实际运行结果。
1. 路由、SPA 与组件分类
1.1 什么是路由
生活中的路由器维护着某种“去往哪里”的映射关系:数据到达路由器后,设备会依据网络地址决定下一步把数据转发到哪里。前端路由也采用了相似思想,只不过映射两端从“网络地址与设备”变成了“URL 路径与页面组件”。
前端路由是一组 URL 与页面内容之间的映射规则。URL 发生变化时,路由器查找匹配规则,并把对应组件渲染到指定出口。
例如,应用可以维护下面这组关系:
| /find | Find.vue | 发现音乐 |
| /my | My.vue | 我的音乐 |
| /friend | Friend.vue | 朋友 |
Vue 本身负责组件和响应式视图,Vue Router 是 Vue 官方提供的客户端路由解决方案。它作为单独的模块安装,却能够通过 Vue 插件机制注册到应用中。注册成功后,应用就可以使用 <RouterLink>、<RouterView>、useRoute()、useRouter() 等能力。
一次典型的路由切换包含四个角色:
- 浏览器 URL:描述用户当前要访问的位置。
- 路由表 routes:保存 path 与 component 的映射规则。
- 路由器实例 router:监听导航、解析 URL、执行守卫并计算匹配结果。
- 路由出口 <RouterView>:接收匹配结果并渲染对应组件。
1.2 单页应用 SPA 与多页应用 MPA
SPA 是 Single Page Application 的缩写,中文叫单页应用。这里的“单页”不是说应用只有一个功能页面,而是说多个业务页面通常共享同一个 HTML 入口。用户切换功能时,JavaScript 更新当前页面中的组件,不必每次都加载一份全新的 HTML。
MPA 是 Multi Page Application 的缩写。它通常由多个 HTML 页面构成,访问不同功能时会请求不同的页面文档。传统资讯站、服务端模板站点经常采用这种方式。
两种模式并没有绝对的高下之分,关键在于业务需求:
| HTML 组织 | 通常只有一个 HTML 入口 | 通常有多个 HTML 页面 |
| 页面更新 | 按需替换组件或局部内容 | 常见做法是加载新的完整页面 |
| 页面切换体验 | 切换流畅,容易保留应用状态 | 会发生文档级导航,状态通常需要重新恢复 |
| 前后端协作 | 前后端职责分离清晰,前端工程化程度高 | 可以由服务端直接组织页面,链路直观 |
| 首屏加载 | 初始资源较多时可能偏慢,可通过拆包和懒加载优化 | 单个页面可按请求输出,首屏通常更直接 |
| SEO | 纯客户端渲染不占优势,可借助 SSR 或 SSG 改善 | 服务端直接输出完整内容,通常更利于抓取 |
| 适合场景 | 后台系统、交互密集型产品、Web 应用 | 内容站、页面相对独立的网站、服务端模板项目 |
SPA 的本质是“一个 HTML 入口承载多个客户端视图”,Vue Router 则负责让 URL 与这些视图保持同步。
1.3 页面组件与复用组件
Vue 并不强制把 .vue 文件分成两类,但在工程实践中,人们通常按职责划分目录,以降低维护成本:
| 页面组件 | src/views | 表示一个相对完整的业务页面 | 是 | 通常较低 |
| 复用组件 | src/components | 组装页面,例如导航栏、列表项、弹窗 | 通常不是 | 通常较高 |
这种分类只是工程约定,两者本质上都是 Vue 组件。Find.vue 放入 views,是因为它会直接与 /find 这样的地址建立映射;SongCard.vue 放入 components,是因为它可能同时出现在发现页、歌单页和搜索页中。
初始目录可以这样组织:
src/
├── components/
│ └── SongCard.vue
├── views/
│ ├── Find.vue
│ ├── Friend.vue
│ └── My.vue
├── App.vue
└── main.js
2. Vue Router 基本使用与模块封装
2.1 先理解“4+2”完整流程
第一次接触 Vue Router,可以先用“4 个固定步骤 + 2 个核心步骤”建立全局认识。
四个固定步骤是:
两个核心步骤是:
阶段复盘如下:
| 安装 | 把 Vue Router 加入项目依赖 | 无法导入路由 API |
| 导入 | 引入 createRouter 和历史模式函数 | 无法创建路由器 |
| 创建 | 提供 history 与 routes | 没有可运行的路由规则 |
| 注册 | 执行 app.use(router) | 路由组件和组合式 API 无法正常工作 |
| 配规则 | 建立 path -> component 映射 | URL 无法匹配页面 |
| 给出口 | 放置 <RouterView> | 虽然能匹配路由,但页面没有显示位置 |
安装 Vue Router 4:
npm install vue-router@4
如果项目使用 Yarn 或 pnpm,对应命令是 yarn add vue-router@4 或 pnpm add vue-router@4。
2.2 核心 API、参数与返回值
在写完整代码前,先把核心 API 的职责分清:
| createRouter(options) | 创建路由器实例 | options.history、options.routes 等 | Router 实例 | 忘记提供 history,或把 Vue Router 3 的 new VueRouter() 写法混进来 |
| createWebHashHistory(base?) | 创建 Hash 历史模式 | 可选基础路径 base | RouterHistory | 误以为 Hash URL 不带 # |
| createWebHistory(base?) | 创建 HTML5 History 模式 | 可选基础路径 base | RouterHistory | 线上服务器没有配置回退到 index.html |
| routes | 保存路由记录 | 由路由记录对象组成的数组 | 被路由器解析为匹配器 | 把它误写成单个对象,或写错 component 引用 |
| app.use(router) | 把路由插件注册到 Vue 应用 | 路由器实例 | 返回 Vue 应用实例,便于链式调用 | 在 app.mount() 之后才注册 |
| <RouterView> | 渲染当前层级匹配到的组件 | 常规基础用法不需要参数 | 输出匹配组件的视图 | 忘记添加,导致地址变化但页面空白 |
createRouter() 的核心配置可以写成下面这样:
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(),
routes: []
})
这里的 history 决定 URL 如何与浏览器历史记录协作,routes 决定哪些 URL 可以匹配哪些组件。createRouter() 会返回一个路由器实例,后续的导航、守卫和注册都围绕这个实例进行。
2.3 创建页面、配置规则并提供出口
先创建三个最小页面组件。它们结构相同,但代表三个独立页面:
<!– src/views/Find.vue –>
<template>
<section class="find">
<h2>发现音乐</h2>
<p>这里展示推荐内容。</p>
</section>
</template>
<!– src/views/My.vue –>
<template>
<section class="my">
<h2>我的音乐</h2>
<p>这里展示用户收藏的音乐。</p>
</section>
</template>
<!– src/views/Friend.vue –>
<template>
<section class="friend">
<h2>朋友</h2>
<p>这里展示朋友动态。</p>
</section>
</template>
为了先看清完整链路,可以暂时在 main.js 中创建路由:
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import Find from '@/views/Find.vue'
import Friend from '@/views/Friend.vue'
import My from '@/views/My.vue'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/find', component: Find },
{ path: '/my', component: My },
{ path: '/friend', component: Friend }
]
})
const app = createApp(App)
app.use(router)
app.mount('#app')
然后在根组件中提供一级路由出口:
<!– src/App.vue –>
<template>
<main>
<RouterView />
</main>
</template>
启动项目后访问 /#/find,路由器会把 Find.vue 渲染到 App.vue 的 <RouterView> 位置。使用 createWebHashHistory() 时,地址中的 # 是预期结果,它表示当前应用采用 Hash 模式。
2.4 路由的执行机制与空白页排查
当用户访问 /#/friend 时,Vue Router 的执行过程可以概括为:
URL 决定“要去哪里”,路由表决定“那里对应什么组件”,<RouterView> 决定“组件显示在哪里”。
页面空白通常不是 Vue Router “没有反应”,而是链路中的某一环断开了:
| 地址变化但内容不显示 | 缺少 <RouterView> | 检查当前布局组件是否提供出口 |
| 访问某路径后空白 | routes 中没有匹配项 | 对照地址和 path,检查大小写与斜杠 |
| 控制台提示组件未定义 | 页面组件没有正确导入 | 检查导入路径和 component 变量 |
| <RouterLink> 不识别 | 未执行 app.use(router) | 确保注册发生在 app.mount() 前 |
| 根路径 / 打开后空白 | 没有为 / 配置组件或重定向 | 添加首页路由或 redirect |
2.5 抽离并封装路由模块
把全部路由代码长期堆在 main.js 中,会让应用入口同时承担创建应用、配置路由、权限控制等多种职责。更常见的做法是新建 src/router/index.js,让入口文件只负责组装应用。
// src/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router'
import Find from '@/views/Find.vue'
import Friend from '@/views/Friend.vue'
import My from '@/views/My.vue'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/find', component: Find },
{ path: '/my', component: My },
{ path: '/friend', component: Friend }
]
})
export default router
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App)
.use(router)
.mount('#app')
脚手架项目中,@ 通常被配置为 src 目录的别名,因此 @/views/Find.vue 等价于从 src/views/Find.vue 导入。需要注意,@ 并不是 JavaScript 原生语法,它来自 Vite 或构建工具的别名配置;如果自建工程没有配置别名,就应使用相对路径,或先在构建配置中声明别名。
3. 声明式导航与路由传参
3.1 RouterLink 与激活样式
让用户手动修改地址栏显然不现实。Vue Router 提供全局组件 <RouterLink> 实现声明式导航:开发者在模板中声明目标地址,组件负责生成链接并触发客户端路由切换。
<template>
<nav>
<RouterLink to="/find">发现音乐</RouterLink>
<RouterLink to="/my">我的音乐</RouterLink>
<RouterLink to="/friend">朋友</RouterLink>
</nav>
<RouterView />
</template>
RouterLink 默认会渲染为可访问的 <a> 元素,但点击后由 Vue Router 接管导航,通常不会重新加载整份 HTML。它最重要的属性是 to:
| 静态字符串 | string | 目标地址固定 | to="/find" |
| 动态字符串 | JavaScript 表达式 | 地址由变量拼接 | :to="'/friend/' + id" |
| 路由位置对象 | object | 使用 name、query、params 等结构化配置 | :to="{ name: 'Friend' }" |
导航成功后,匹配链接会自动获得两个类名:
| router-link-active | 当前链接处于激活状态 | 当前地址匹配子路由时,祖先路由链接也可能激活 |
| router-link-exact-active | 当前链接被精确激活 | 只标记最精确匹配的链接,不包含祖先路由 |
初学时常把二者理解为“模糊匹配”和“精确匹配”。更准确地说,Vue Router 会比较路由记录和路径参数;当子路由激活时,它的祖先路由记录也属于匹配链,因此父级链接拥有 router-link-active,但通常没有 router-link-exact-active。
<style>
nav a {
color: #333;
text-decoration: none;
margin-right: 24px;
}
nav a.router-link-active {
color: #fff;
background: #e5484d;
}
nav a.router-link-exact-active {
font-weight: 700;
}
</style>
如果只需要普通导航高亮,给其中一个类设置样式即可。也可以通过 active-class、exact-active-class 修改单个链接的类名,或在 createRouter() 中使用 linkActiveClass、linkExactActiveClass 进行全局配置。
3.2 查询参数:适合筛选条件和多个可选值
查询参数位于 URL 的 ? 后面,例如 /friend?id=10086&tab=post。它适合搜索关键字、分页、排序、筛选条件等信息,因为参数名清晰、数量灵活,而且不需要提前在 path 中占位。
字符串传参:
<RouterLink to="/friend?id=10086&tab=post">
查看朋友动态
</RouterLink>
对象传参:
<RouterLink
:to="{
path: '/friend',
query: {
id: 10086,
tab: 'post'
}
}"
>
查看朋友动态
</RouterLink>
对象形式不需要手动拼接 ? 和 &,参数较多时可读性更好。目标组件通过 useRoute() 获取当前路由,再从 route.query 中读取参数:
<!– src/views/Friend.vue –>
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<template>
<section>
<h2>朋友动态</h2>
<p>用户 ID:{{ route.query.id }}</p>
<p>当前标签:{{ route.query.tab }}</p>
</section>
</template>
useRoute() 不接收参数,返回当前路由位置的响应式对象。常用字段如下:
| route.path | 不带查询串的路径 | /friend |
| route.fullPath | 包含查询串和 Hash 的完整路径 | /friend?id=10086#comment |
| route.query | 查询参数对象 | { id: '10086' } |
| route.params | 动态路径参数对象 | { fid: '10086' } |
| route.name | 当前命名路由名称 | Friend |
| route.meta | 路由记录合并后的元信息 | { requiresAuth: true } |
需要特别注意:URL 中的查询值通常是字符串或字符串数组。即使传入 id: 10086,读取时也可能得到 '10086'。业务需要数值时,应显式执行 Number(route.query.id),并处理参数缺失或转换失败的情况。
3.3 动态路由参数:让资源标识成为路径的一部分
如果多个页面具有相同结构,只是资源 ID 不同,例如朋友 10010 与朋友 10086 的详情页,就没有必要为每个人配置一条路由。可以在路径中使用以 : 开头的动态字段:
const routes = [
{
path: '/friend/:fid',
name: 'FriendDetail',
component: Friend
}
]
这里的 :fid 是参数占位符,参数名应有业务语义。/friend/10010 和 /friend/10086 都能命中同一条路由,实际值分别进入 route.params.fid。
字符串传参:
<RouterLink to="/friend/10010">
查看朋友 10010
</RouterLink>
对象传参:
<RouterLink
:to="{
name: 'FriendDetail',
params: {
fid: 10010
}
}"
>
查看朋友 10010
</RouterLink>
目标页面接收参数:
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<template>
<p>当前朋友 ID:{{ route.params.fid }}</p>
</template>
对象写法中,params 应与命名路由 name 配合。不要写成 { path: '/friend', params: { fid: 10010 } } 并期待 Vue Router 自动拼接路径,因为指定 path 时 params 会被忽略。要么使用 { name, params },要么直接把具体值写进字符串路径。
当 /friend/10010 切换到 /friend/10086 时,Vue Router 可能复用同一个 Friend 组件实例。route 本身具有响应性,模板会更新;如果还要根据新 ID 重新请求数据,可以监听 () => route.params.fid,或使用 onBeforeRouteUpdate() 处理参数变化。
3.4 查询参数与动态参数对比
两种传参方式都可以携带一个或多个值,区别主要在于参数的业务含义和 URL 结构:
| URL 示例 | /friend?id=10086&tab=post | /friend/10086 |
| 是否提前配置占位符 | 不需要 | 需要配置 /friend/:fid |
| 声明式对象写法 | { path, query } 或 { name, query } | 推荐 { name, params } |
| 接收方式 | route.query.id | route.params.fid |
| 适合内容 | 搜索、分页、排序、多个可选筛选项 | 用户 ID、文章 ID 等资源身份 |
| 参数缺失 | 通常仍能访问原路径 | 必填参数缺失时通常无法匹配对应路由 |
| 多参数能力 | 支持,表达较直观 | 也支持,如 /user/:uid/post/:pid |
选择参数方式时,不要只看“参数有几个”。决定因素应是:这个值是在描述资源身份,还是在补充筛选与展示条件。
4. 重定向、404 与历史模式
4.1 使用 redirect 避免默认路径空白
应用第一次打开时,浏览器常处于根路径 /。如果路由表只配置了 /find、/my 和 /friend,根路径就没有匹配组件。最简单的处理方式是添加重定向:
const routes = [
{ path: '/', redirect: '/find' },
{ path: '/find', component: Find }
]
redirect 表示“命中当前路由后,创建一次到目标位置的新导航”。它既可以是字符串,也可以是路由位置对象或返回路由位置的函数:
| 字符串 | redirect: '/find' | 固定目标路径 |
| 对象 | redirect: { name: 'Find' } | 使用命名路由,降低路径耦合 |
| 函数 | redirect: to => ({ path: '/search', query: { q: to.params.text } }) | 目标地址依赖原路由参数 |
重定向与别名不同:重定向会让当前地址进入新的目标路由;别名则允许多个 URL 命中同一个路由记录。处理默认首页时通常应该使用 redirect。
4.2 配置 Vue Router 4 的 404 兜底页
当用户访问不存在的地址时,应该显示明确的 404 页面,而不是空白。先创建页面:
<!– src/views/NotFound.vue –>
<template>
<section>
<h2>404</h2>
<p>你访问的页面不存在。</p>
<RouterLink to="/">返回首页</RouterLink>
</section>
</template>
再把捕获所有地址的路由放入路由表:
import NotFound from '@/views/NotFound.vue'
const routes = [
// 其他业务路由……
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound
}
]
/:pathMatch(.*)* 是 Vue Router 4 的捕获所有路径写法:pathMatch 是参数名,(.*) 是自定义匹配表达式,最后的 * 表示参数可重复。Vue Router 3 中常见的 { path: '*' } 不应直接照搬到 Vue Router 4 项目。
虽然路由匹配器能够按规则判断优先级,但把 404 记录放在业务路由之后更符合阅读习惯,也能清楚表达“前面全部未匹配时才进入兜底页”的意图。
4.3 Hash 模式与 HTML5 History 模式
Vue Router 4 常用的浏览器历史模式有两种:
import {
createRouter,
createWebHashHistory,
createWebHistory
} from 'vue-router'
const hashRouter = createRouter({
history: createWebHashHistory(),
routes: []
})
const historyRouter = createRouter({
history: createWebHistory(),
routes: []
})
| 创建函数 | createWebHashHistory() | createWebHistory() |
| URL 示例 | https://example.com/#/find | https://example.com/find |
| 是否带 # | 是 | 否 |
| 服务器是否收到客户端路径 | # 后内容不会作为请求路径发给服务器 | 会直接请求 /find |
| 部署要求 | 通常无需为前端路由做特殊回退 | 服务器必须把未知前端路径回退到 index.html |
| SEO 与 URL 观感 | URL 不够自然,SEO 通常较弱 | URL 自然,更适合常规 Web 部署 |
History 模式最常见的坑是:站内点击一切正常,刷新二级地址却返回服务器 404。原因是站内导航由 Vue Router 处理,而刷新时浏览器会直接向服务器请求 /find。服务器如果把它当成真实文件路径,自然找不到资源。
解决原则是:先让服务器查找静态文件和后端接口;如果请求不属于它们,就返回 SPA 的 index.html,再由 Vue Router 在浏览器中解析路径。Vite 开发服务器通常已经处理了回退,所以开发环境正常并不能证明生产环境配置正确。
5. 编程式导航与传参
5.1 useRouter、useRoute 与 router.push
<RouterLink> 适合用户点击链接的场景,但登录成功自动回首页、提交表单后进入详情、倒计时结束后跳转等行为,需要 JavaScript 主动发起导航,这就是编程式导航。
在 <script setup> 中,通过 useRouter() 获取路由器实例:
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
function goToFriend() {
router.push('/friend')
}
</script>
<template>
<button @click="goToFriend">去朋友页</button>
</template>
三个容易混淆的对象需要明确区分:
| useRouter() | 整个路由器 | push、replace、back、注册守卫 | Router 实例 |
| useRoute() | 当前已经激活的路由位置 | 读取 path、query、params、meta | 响应式路由对象 |
| router.push(to) | 发起一次导航,并增加历史记录 | 传字符串或路由位置对象 | Promise,可等待导航完成 |
router 是“导航控制器”,route 是“当前地址快照的响应式表示”。要跳转用 router,要读取当前参数用 route。
router.push() 的目标既可以是字符串,也可以是对象:
router.push('/friend')
router.push({ path: '/friend' })
router.push({ name: 'FriendQuery' })
因为 router.push() 返回 Promise,在关闭弹窗、显示成功提示等操作必须等待导航完成时,可以使用 await router.push(…)。如果不想在浏览器历史中新增记录,可改用 router.replace(…);用户按后退键时不会回到被替换的位置。
5.2 编程式导航携带查询参数与动态参数
编程式导航与声明式导航使用相同的路由位置结构。查询参数可以用字符串或对象传递:
router.push('/friend?id=110&tab=post')
router.push({
path: '/friend',
query: {
id: 101,
tab: 'post'
}
})
目标页依然通过 route.query.id 和 route.query.tab 接收。动态参数同样有两种形式:
router.push('/friend/110')
router.push({
name: 'FriendDetail',
params: {
fid: 101
}
})
目标页通过 route.params.fid 接收。无论导航是由 <RouterLink> 还是 router.push() 发起,接参方式完全一致,因为参数最终都会进入同一个当前路由对象。
下面用登录成功跳转展示一个真实场景:
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
async function handleLogin() {
const loginSucceeded = true
if (loginSucceeded) {
await router.push({ path: '/find', query: { welcome: '1' } })
}
}
</script>
<template>
<button @click="handleLogin">登录</button>
</template>
常见错误可以集中记忆:
| 在 setup 中写 this.$router | <script setup> 中没有组件 this | 使用 useRouter() |
| 写 { path: '/friend', params: { fid: 1 } } | path 与 params 的组合不会自动插值 | 使用 { name, params } 或字符串完整路径 |
| 读取 router.query | 查询参数属于当前路由对象 | 使用 route.query |
| 参数变化但数据没有重新请求 | 同一路由组件可能被复用 | 监听具体参数或使用 onBeforeRouteUpdate() |
| 重复点击当前地址后仍执行后续逻辑 | 没有等待导航结果或判断导航失败 | 按业务需要 await router.push() 并处理结果 |
6. 嵌套路由:在页面内部继续切换页面
6.1 嵌套路由的结构与渲染流程
许多应用都存在“页面中还有子页面”的结构。例如,一级导航进入“发现音乐”后,页面内部还要在“推荐、排行榜、歌单”之间切换。对应 URL 可以设计为:
/find
├── /find/recommend
├── /find/ranking
└── /find/songlist
此时会存在两层出口:
App.vue 的 <RouterView>
└── 渲染 Find.vue
└── Find.vue 的 <RouterView>
├── 渲染 Recommend.vue
├── 渲染 Ranking.vue
└── 渲染 SongList.vue
每一层路由记录对应一层 <RouterView>。父路由组件先进入外层出口,子路由组件再进入父组件内部的出口。
实现嵌套路由需要三步:
6.2 children、相对 path 与默认子页面
路由配置如下:
import Find from '@/views/Find.vue'
import Recommend from '@/views/Recommend.vue'
import Ranking from '@/views/Ranking.vue'
import SongList from '@/views/SongList.vue'
const routes = [
{
path: '/find',
component: Find,
redirect: '/find/recommend',
children: [
{
path: 'recommend',
component: Recommend
},
{
path: 'ranking',
component: Ranking
},
{
path: 'songlist',
component: SongList
}
]
}
]
children 接收一个路由记录数组,结构与顶层 routes 类似。子路由的 path 推荐写成不带 / 的相对路径:父路径 /find 与子路径 recommend 会组合成 /find/recommend。
如果子路径写成 /recommend,它会被当作根路径,不再自动拼接父路径。这个能力有特殊用途,但不适合当前“URL 与组件层级一致”的场景。
redirect: '/find/recommend' 用于处理 /find:父组件虽然能够显示,但没有子路由命中时,父组件内部的 <RouterView> 会是空的。重定向到默认子页面即可避免这类“二级区域空白”。另一种做法是配置 { path: '', component: Recommend } 作为空路径子路由,两种方案的地址表现不同:
| 父路由 redirect | 变为 /find/recommend | 推荐页 | 希望 URL 明确体现默认栏目 |
| 空路径子路由 | 保持 /find | 推荐页 | 希望父路径本身就是默认子页地址 |
父页面中的导航需要指向完整目标地址,并提供子路由出口:
<!– src/views/Find.vue –>
<template>
<section class="find">
<h2>发现音乐</h2>
<nav>
<RouterLink to="/find/recommend">推荐</RouterLink>
<RouterLink to="/find/ranking">排行榜</RouterLink>
<RouterLink to="/find/songlist">歌单</RouterLink>
</nav>
<RouterView />
</section>
</template>
嵌套路由最常见的三个问题正好对应三条规则:
| 子页面变成根级地址 | 子 path 错写成 /ranking | 常规嵌套场景下子 path 不加 / |
| 点击后没有进入预期子页 | 导航目标漏写父路径 | 使用 /find/ranking 等完整路径,或配置命名子路由 |
| 访问父路径时内层空白 | 没有默认子路由 | 添加父级重定向或空路径子路由 |
7. 路由守卫与访问权限
7.1 beforeEach 的执行时机、参数与返回值
“我的音乐”通常只有登录用户可以访问。权限判断不能只写在页面按钮上,因为用户还可以直接输入 URL。Vue Router 提供导航守卫,在路由真正确认前统一执行检查。
全局前置守卫使用 router.beforeEach() 注册:
router.beforeEach((to, from) => {
// 根据条件返回不同结果
})
这个 API 接收一个守卫函数。每次导航触发时,守卫按注册顺序执行;异步守卫完成前,导航会保持等待。beforeEach() 的返回值是一个移除该守卫的函数,常规应用通常不需要手动移除。
守卫参数与回调返回值如下:
| to | 即将进入的标准化目标路由 | 判断目标名称、路径、参数和 meta |
| from | 当前正要离开的标准化路由 | 记录来源、判断返回逻辑 |
| return false | 取消当前导航 | 权限不足且希望停留原页 |
| return true 或 return undefined | 放行本次导航 | 校验通过;也可以直接不写 return |
| return '/login' | 重定向到字符串路径 | 简单登录跳转 |
| return { name: 'Login' } | 重定向到路由位置对象 | 推荐用于命名路由及附加查询参数 |
路由守卫不是普通事件监听器。它必须明确“取消、放行或重定向”,否则错误的分支结构可能造成导航悬空或无限重定向。
7.2 从布尔判断到可维护的登录拦截
先看一个最小示例。isLogin 用于模拟登录状态;未登录且准备访问 /my 时取消导航:
const isLogin = true
router.beforeEach((to) => {
if (!isLogin && to.path === '/my') {
window.alert('请先登录')
return false
}
return true
})
这个示例能解释返回值,却不适合页面较多的项目。更可维护的做法是用路由元信息 meta 标记是否需要登录,再统一重定向到登录页:
const routes = [
{
path: '/my',
name: 'My',
component: My,
meta: {
requiresAuth: true
}
},
{
path: '/login',
name: 'Login',
component: Login
}
]
router.beforeEach((to) => {
const isLogin = localStorage.getItem('isLogin') === '1'
if (to.meta.requiresAuth && !isLogin) {
return {
name: 'Login',
query: {
redirect: to.fullPath
}
}
}
})
这里把原目标写入 redirect 查询参数,登录成功后即可返回用户原本想去的页面。守卫没有显式返回时就是 undefined,表示正常放行。
务必避免下面这种无限重定向逻辑:未登录时访问任何页面都跳登录页,而登录页本身再次被拦截。可通过 meta.requiresAuth 只保护必要页面,或额外判断 to.name !== 'Login'。
前端守卫改善的是页面访问流程,不能替代后端鉴权。敏感数据接口仍必须在服务端验证令牌或会话,不能因为前端隐藏了页面就直接返回私密数据。
8. 完整项目整合与运行复盘
8.1 项目目标与文件结构
现在把前面的知识整合成一个最小音乐站路由项目。完整流程是:应用启动后注册路由;根路径重定向到推荐页;一级导航通过 <RouterLink> 切换;朋友页同时演示查询参数和动态参数;发现页包含三条子路由;我的音乐由全局前置守卫保护;未知路径进入 404 页面。
src/
├── router/
│ └── index.js
├── views/
│ ├── Find.vue
│ ├── Friend.vue
│ ├── Login.vue
│ ├── My.vue
│ ├── NotFound.vue
│ ├── Ranking.vue
│ ├── Recommend.vue
│ └── SongList.vue
├── App.vue
└── main.js
选择 History 模式是为了获得自然 URL;若部署环境暂时无法配置服务器回退,把 createWebHistory() 换成 createWebHashHistory() 即可。
8.2 整合路由表、嵌套关系与守卫
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Find from '@/views/Find.vue'
import Friend from '@/views/Friend.vue'
import Login from '@/views/Login.vue'
import My from '@/views/My.vue'
import NotFound from '@/views/NotFound.vue'
import Ranking from '@/views/Ranking.vue'
import Recommend from '@/views/Recommend.vue'
import SongList from '@/views/SongList.vue'
const routes = [
{
path: '/',
redirect: '/find/recommend'
},
{
path: '/find',
name: 'Find',
component: Find,
redirect: '/find/recommend',
children: [
{
path: 'recommend',
name: 'Recommend',
component: Recommend
},
{
path: 'ranking',
name: 'Ranking',
component: Ranking
},
{
path: 'songlist',
name: 'SongList',
component: SongList
}
]
},
{
path: '/friend',
name: 'FriendQuery',
component: Friend
},
{
path: '/friend/:fid',
name: 'FriendDetail',
component: Friend
},
{
path: '/my',
name: 'My',
component: My,
meta: {
requiresAuth: true
}
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to) => {
const isLogin = localStorage.getItem('isLogin') === '1'
if (to.meta.requiresAuth && !isLogin) {
return {
name: 'Login',
query: {
redirect: to.fullPath
}
}
}
})
export default router
这段配置的关键点可以用一张表复盘:
| 根路径 redirect | 首次访问 / 没有页面 | 自动进入 /find/recommend |
| children | 发现页内部继续切换栏目 | 子组件进入 Find.vue 的出口 |
| FriendQuery | 查询参数演示 | /friend?tab=post |
| FriendDetail | 动态参数演示 | /friend/10086 |
| meta.requiresAuth | 标记受保护页面 | /my 会被守卫检查 |
| beforeEach | 统一执行登录校验 | 未登录跳到 /login |
| /:pathMatch(.*)* | 捕获未知地址 | 渲染 404 页面 |
8.3 整合入口、导航和页面组件
应用入口只负责安装路由并挂载应用:
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App)
.use(router)
.mount('#app')
根组件提供一级导航和一级出口:
<!– src/App.vue –>
<template>
<header>
<h1>简易音乐站</h1>
<nav>
<RouterLink to="/find">发现音乐</RouterLink>
<RouterLink
:to="{
name: 'FriendQuery',
query: { tab: 'post', page: 1 }
}"
>
朋友
</RouterLink>
<RouterLink to="/my">我的音乐</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
<style>
nav {
display: flex;
gap: 16px;
}
.router-link-active {
color: #e5484d;
font-weight: 700;
}
</style>
发现页负责二级导航和二级出口:
<!– src/views/Find.vue –>
<template>
<section>
<h2>发现音乐</h2>
<nav>
<RouterLink to="/find/recommend">推荐</RouterLink>
<RouterLink to="/find/ranking">排行榜</RouterLink>
<RouterLink to="/find/songlist">歌单</RouterLink>
</nav>
<RouterView />
</section>
</template>
三个子页面保持简单,让重点落在路由结构上:
<!– src/views/Recommend.vue –>
<template>
<article>
<h3>推荐</h3>
<p>展示个性化推荐歌曲。</p>
</article>
</template>
<!– src/views/Ranking.vue –>
<template>
<article>
<h3>排行榜</h3>
<p>展示热门歌曲排行榜。</p>
</article>
</template>
<!– src/views/SongList.vue –>
<template>
<article>
<h3>歌单</h3>
<p>展示精选歌单。</p>
</article>
</template>
朋友页同时兼容查询参数路由和动态参数路由:
<!– src/views/Friend.vue –>
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
function openFriendDetail() {
router.push({
name: 'FriendDetail',
params: {
fid: 10086
}
})
}
</script>
<template>
<section>
<h2>朋友</h2>
<p>查询标签:{{ route.query.tab ?? '未提供' }}</p>
<p>朋友 ID:{{ route.params.fid ?? '未提供' }}</p>
<button @click="openFriendDetail">查看朋友 10086</button>
</section>
</template>
受保护页面只展示业务内容,权限逻辑统一留在守卫中:
<!– src/views/My.vue –>
<template>
<section>
<h2>我的音乐</h2>
<p>只有登录用户能够访问这里。</p>
</section>
</template>
登录页读取守卫保存的原目标,登录成功后返回原页面:
<!– src/views/Login.vue –>
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
async function login() {
localStorage.setItem('isLogin', '1')
const redirect = typeof route.query.redirect === 'string'
? route.query.redirect
: '/find/recommend'
await router.push(redirect)
}
</script>
<template>
<section>
<h2>登录</h2>
<button @click="login">模拟登录成功</button>
</section>
</template>
最后补上 404 页面:
<!– src/views/NotFound.vue –>
<template>
<section>
<h2>404</h2>
<p>页面不存在或已经被移动。</p>
<RouterLink to="/">返回首页</RouterLink>
</section>
</template>
8.4 从启动到导航完成的执行链
完整项目运行后,一次访问 /my 的过程如下:
最后可以按下面的清单验证功能:
| 首次访问 / | 自动进入 /find/recommend | 根路径重定向 |
| 点击排行榜 | 进入 /find/ranking,父页面结构保留 | 嵌套路由与二级出口 |
| 点击朋友 | 地址包含 tab、page | 声明式查询参数 |
| 点击“查看朋友 10086” | 进入 /friend/10086 | 编程式动态参数 |
| 未登录访问 /my | 进入登录页并记录原地址 | 全局前置守卫 |
| 完成模拟登录 | 返回 /my | useRouter() 与 router.push() |
| 访问任意未知路径 | 显示 404 页面 | 捕获所有路由 |
| 直接刷新二级路径 | 生产环境仍返回应用 | History 模式服务器回退 |
总结
Vue Router 的核心并不复杂:它维护 URL 与组件之间的映射,在地址变化时解析路由表、执行导航守卫,并把匹配组件放入对应层级的 <RouterView>。基础使用可以记成“安装、导入、创建、注册,加上配置规则和提供出口”的 4+2 流程;项目变大后,应把路由抽离到独立模块,并通过页面组件与复用组件的目录约定保持结构清晰。
导航层面,<RouterLink> 负责模板中的声明式导航,useRouter() 与 router.push() 负责 JavaScript 主动导航,useRoute() 负责读取当前地址信息。查询参数适合筛选与多个可选条件,动态参数适合表达资源身份。重定向解决默认入口,捕获路由提供 404 兜底,嵌套路由用 children 和多层 <RouterView> 表达页面层级,全局前置守卫则把访问权限集中在导航确认之前。真正理解这些角色的边界后,路由配置就不再是零散语法,而是一条可以被定位、验证和维护的完整导航链路。

