【前端路由篇】掌控全局:Vue Router 实现页面导航、动态路由与权限控制
前言
在单页应用 (SPA) 中,路由系统是灵魂。它负责根据用户在浏览器地址栏输入的 URL,或者用户在页面上的点击操作,来决定渲染哪个组件,从而实现页面内容的切换,而无需重新加载整个 HTML 页面。
Vue Router 是 Vue.js 官方的路由管理器,它与 Vue.js 核心深度集成,使得构建 SPA 变得非常简单。
在上一篇中,我们已经实现了:
- 将
Layout.vue
作为大部分页面的父级路由组件。 - 通过
children
属性定义嵌套路由。 - 使用
<RouterView />
作为子路由的渲染出口。 - 通过
<el-menu router>
属性让 Element Plus 菜单与路由联动。
本篇将在此基础上,探讨以下进阶内容:
- 动态路由匹配: 如何处理像
/project/detail/1
这样带有动态参数的 URL。 - 编程式导航: 如何在 JavaScript 代码中(例如点击按钮后)进行页面跳转。
- 导航守卫: 实现路由级别的权限控制,例如用户未登录时自动跳转到登录页。
- 路由元信息: 如何给路由添加自定义数据,如页面标题、权限要求等。
第一步:动态路由匹配 - 实现项目详情页
在我们的测试平台中,用户经常需要查看某个特定项目的详情。例如,点击项目列表中的某个项目,应该跳转到类似 /project/detail/1
(1 代表项目 ID) 的页面。
创建项目详情页组件:
在src/views/project/
目录下创建一个新文件ProjectDetailView.vue
。
内容暂时简单些:
<!-- test-platform/frontend/src/views/project/ProjectDetailView.vue --> <template> <div class="project-detail"> <h1>项目详情</h1> <p v-if="projectId">当前项目 ID: {{ projectId }}</p> <p v-else>正在加载项目信息...</p> <!-- 后续将展示项目的详细信息,如模块列表、用例统计等 --> <el-button @click="goBack">返回项目列表</el-button> </div> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' const route = useRoute() // 获取当前路由信息对象 const router = useRouter() // 获取路由实例 const projectId = ref<string | null>(null) onMounted(() => { // 从路由参数中获取 projectId if (route.params.id) { projectId.value = route.params.id as string // 在这里可以根据 projectId 发起 API 请求获取项目详情数据 console.log('获取项目ID为', projectId.value, '的详情数据') } }) const goBack = () => { router.push('/project/list') } </script> <style scoped> .project-detail { padding: 20px; } </style>
代码解释:
useRoute()
: 这是 Vue Router 4 (Vue3)提供的 Composition API,用于在组件内部访问当前激活的路由对象。它包含了路径、参数、查询等信息。useRouter()
: 用于获取路由器的实例,可以用来进行编程式导航。route.params.id
: 当我们配置动态路由段:id
时,匹配到的值会作为参数存储在route.params
对象中,属性名就是动态段的名称。onMounted
: 我们在组件挂载后尝试获取路由参数id
。goBack
: 演示了编程式导航router.push()
。
在路由配置中添加动态路由:
打开src/router/index.ts
,在Layout
组件的children
数组中添加项目详情页的路由配置。// test-platform/frontend/src/router/index.ts import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' import Layout from '@/layout/index.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', component: Layout, redirect: '/dashboard', children: [ // ... (dashboard, projectList, projectCreate 等路由保持不变) ... { path: 'dashboard', name: 'dashboard', component: HomeView, meta: { title: '仪表盘' } }, { path: '/project/list', // 之前是 'project/list',如果是父路由component是Layout,子path开头带'/'会从根路径开始匹配 name: 'projectList', component: () => import('../views/project/ProjectListView.vue'), meta: { title: '项目列表' } }, { path: '/project/create', name: 'projectCreate', component: () => import('../views/project/ProjectCreateView.vue'), meta: { title: '新建项目' } }, // 新增动态路由 { path: '/project/detail/:id', // :id 就是动态路径参数 name: 'projectDetail', component: () => import('../views/project/ProjectDetailView.vue'), meta: { title: '项目详情' }, props: true // 可选:将路由参数作为 props 传递给组件 }, { path: '/testcases', name: 'testcases', component: () => import('../views/project/TestCaseListView.vue'), meta: { title: '用例管理' } }, { path: '/reports', name: 'reports', component: () => import('../views/project/ReportListView.vue'), meta: { title: '测试报告' } } ] }, { path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { title: '登录' } }, // ... (NotFound 路由) ] }) // ... (router.beforeEach 保持不变或暂时注释掉) ... export default router
代码解释:
path: '/project/detail/:id'
:- 路径中的
:id
部分被称为动态段 (dynamic segment)。它会匹配该位置的任何非空字符串,并将匹配到的值作为参数。 - 例如,
/project/detail/1
和/project/detail/my-project
都会匹配这个路由。
- 路径中的
name: 'projectDetail'
: 给路由命名,方便编程式导航。props: true
: 这是一个非常有用的选项。当设置为true
时,路由参数route.params
会被自动作为 props 传递给ProjectDetailView.vue
组件。这意味着在组件中你可以直接通过defineProps<{ id: string }>()
来接收id
,而无需使用useRoute().params.id
。
在项目列表页添加跳转链接 (演示):
为了能方便地跳转到详情页,我们修改ProjectListView.vue
,添加一个模拟的项目列表和跳转链接。<!-- test-platform/frontend/src/views/project/ProjectListView.vue --> <template> <div class="project-list"> <h2>项目列表</h2> <el-table :data="mockProjects" style="width: 100%"> <el-table-column prop="id" label="ID" width="180" /> <el-table-column prop="name" label="项目名称" width="180" /> <el-table-column label="操作"> <template #default="scope"> <!-- 使用 router-link 进行声明式导航 --> <router-link :to="`/project/detail/${scope.row.id}`"> <el-button size="small" type="primary">查看详情</el-button> </router-link> <!-- 或者使用编程式导航 --> <!-- <el-button size="small" type="primary" @click="goToDetail(scope.row.id)">查看详情 (编程式)</el-button> --> </template> </el-table-column> </el-table> </div> </template> <script setup lang="ts"> import { ref } from 'vue' // import { useRouter } from 'vue-router' // 如果使用编程式导航 // const router = useRouter() // 如果使用编程式导航 const mockProjects = ref([ { id: 1, name: '电商平台测试项目' }, { id: 2, name: '内部管理系统升级' }, { id: 3, name: 'APP V3.0 自动化' }, ]) // const goToDetail = (id: number) => { // 如果使用编程式导航 // router.push(`/project/detail/${id}`) // // 或者使用命名的路由 // // router.push({ name: 'projectDetail', params: { id: id.toString() } }) // } </script> <style scoped> .project-list { padding: 20px; } </style>
代码解释:
- 我们用 Element Plus 的
el-table
来展示一个模拟的项目列表。 <router-link :to="
/project/detail/${scope.row.id}">
:router-link
是 Vue Router提供的用于声明式导航的组件。它会被渲染成一个<a>
标签。:to
属性绑定了一个动态的路径,根据当前行项目的id
生成。
- 编程式导航示例 (注释部分):
router.push(
/project/detail/${id})
:直接跳转到指定路径。router.push({ name: 'projectDetail', params: { id: id.toString() } })
:通过路由名称和参数对象进行跳转,这种方式更健壮,即使路径改变了,只要名称不变,代码就无需修改。params
对象的键必须与动态段名称一致。
- 我们用 Element Plus 的
测试动态路由:
- 启动前端开发服务器
npm run dev
。 - 访问项目列表页 (
http://localhost:5173/project/list
)。 - 点击任意项目的“查看详情”按钮。
- 页面应该跳转到对应的详情页,URL 类似
/project/detail/1
,并且页面上会显示正确的项目 ID。 - 在详情页点击“返回项目列表”按钮,应该能回到列表页。
- 启动前端开发服务器
第二步:导航守卫 - 实现简单的登录权限控制
导航守卫允许你在路由跳转发生前、发生时或发生后执行一些逻辑。最常见的用途就是权限控制:如果用户未登录,访问需要授权的页面时,自动重定向到登录页。
Vue Router 提供了多种导航守卫:
- 全局前置守卫 (Global Before Guards):
router.beforeEach
,在任何路由跳转前都会被调用。 - 全局解析守卫 (Global Resolve Guards):
router.resolve
,在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后,解析守卫就被正确调用。 - 全局后置钩子 (Global After Hooks):
router.afterEach
,在路由跳转完成后被调用,它不像守卫那样有next
函数,不能改变导航本身。 - 路由独享守卫 (Per-Route Guards): 直接在路由配置对象上定义
beforeEnter
。 - 组件内守卫 (In-Component Guards): 在组件选项中定义
beforeRouteEnter
,beforeRouteUpdate
,beforeRouteLeave
。
我们将使用全局前置守卫 router.beforeEach
来实现一个简单的登录检查。
模拟登录状态:
为了演示,我们暂时使用localStorage
来模拟用户的登录状态。在实际项目中,这通常由后端返回的 token 和状态管理库 (如 Pinia) 来管理。我们可以在
LoginView.vue
中添加一个模拟登录的逻辑:<!-- test-platform/frontend/src/views/LoginView.vue --> <template> <div class="login-container"> <h1>用户登录</h1> <el-form ref="loginFormRef" :model="loginForm" label-width="80px" class="login-form"> <el-form-item label="用户名" prop="username"> <el-input v-model="loginForm.username" placeholder="任意用户名"></el-input> </el-form-item> <el-form-item label="密码" prop="password"> <el-input v-model="loginForm.password" type="password" placeholder="任意密码"></el-input> </el-form-item> <el-form-item> <el-button type="primary" @click="handleLogin">登录</el-button> </el-form-item> </el-form> </div> </template> <script setup lang="ts"> import { reactive, ref } from 'vue' import { useRouter, useRoute } from 'vue-router' // 确保导入了 useRoute import type { FormInstance } from 'element-plus' import { ElMessage } from 'element-plus' // <--- 在这里显式导入 ElMessage const router = useRouter() const route = useRoute() // 获取当前路由信息,用于 redirect const loginFormRef = ref<FormInstance>() const loginForm = reactive({ username: 'admin', password: 'password' }) const handleLogin = async () => { // 实际项目中这里会调用后端 API 进行验证 console.log('模拟登录:', loginForm.username, loginForm.password) // 模拟登录成功 localStorage.setItem('user-token', 'fake-token-value') // 存储一个假的 token ElMessage.success('登录成功!') const redirectPath = route.query.redirect as string | undefined router.push(redirectPath || '/') // 跳转到仪表盘或之前尝试访问的页面 } </script> <style scoped> .login-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; background-color: #f0f2f5; } .login-form { width: 350px; padding: 30px; background-color: #fff; border-radius: 6px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); } h1 { margin-bottom: 20px; } </style>
同时,在
/frontend/src/layout/index.vue
的退出登录处添加清除 token 的逻辑:<!-- test-platform/frontend/src/layout/index.vue --> <template> <el-container class="app-layout"> <el-header class="app-header"> <div class="logo-title"> <img src="@/assets/logo.svg" alt="Logo" class="logo" /> <span class="title">测试平台</span> </div> <div class="user-info"> <!-- 用户信息和退出登录等 --> <el-dropdown @command="handleCommand"> <!-- 添加 @command 事件 --> <span class="el-dropdown-link"> 欢迎, Admin <el-icon class="el-icon--right"><arrow-down /></el-icon> </span> <template #dropdown> <el-dropdown-menu> <el-dropdown-item>个人中心</el-dropdown-item> <el-dropdown-item>修改密码</el-dropdown-item> <el-dropdown-item divided>退出登录</el-dropdown-item> </el-dropdown-menu> </template> </el-dropdown> </div> </el-header> <el-container class="app-body"> <el-aside width="200px" class="app-aside"> <el-menu default-active="1" class="el-menu-vertical-demo" router > <el-menu-item index="/"> <el-icon><HomeFilled /></el-icon> <span>首页</span> </el-menu-item> <el-sub-menu index="/project"> <template #title> <el-icon><Folder /></el-icon> <span>项目管理</span> </template> <el-menu-item index="/project/list">项目列表</el-menu-item> <el-menu-item index="/project/create">新建项目</el-menu-item> </el-sub-menu> <el-menu-item index="/testcases"> <el-icon><List /></el-icon> <span>用例管理</span> </el-menu-item> <el-menu-item index="/reports"> <el-icon><DataAnalysis /></el-icon> <span>测试报告</span> </el-menu-item> </el-menu> </el-aside> <el-main class="app-main"> <RouterView /> <!-- 子路由的出口 --> </el-main> </el-container> </el-container> </template> <script setup lang="ts"> import { RouterView } from 'vue-router' // 导入需要的 Element Plus 图标 import { ArrowDown, HomeFilled, Folder, List, DataAnalysis } from '@element-plus/icons-vue' import { ElMessage, ElMessageBox } from 'element-plus' // 导入 ElMessage import { useRouter } from 'vue-router' // 导入 useRouter const router = useRouter() // 获取路由实例 const handleCommand = (command: string | number | object) => { if (command === 'logout') { ElMessageBox.confirm('确定要退出登录吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning', }) .then(() => { // 清除登录状态 (例如 token) localStorage.removeItem('user-token') ElMessage({ type: 'success', message: '退出成功', }) // 跳转到登录页 router.push('/login') }) .catch(() => { // 用户取消操作 }) } else if (command === 'profile') { // router.push('/profile') // 跳转到个人中心 } else if (command === 'changePassword') { // router.push('/change-password') // 跳转到修改密码 } } </script> <style scoped lang="scss"> // 使用 SCSS 方便样式编写 .app-layout { height: 100vh; // 整个布局占满视口高度 background-color: #f0f2f5; } .app-header { background-color: #fff; color: #333; display: flex; justify-content: space-between; align-items: center; padding: 0 20px; border-bottom: 1px solid #e6e6e6; .logo-title { display: flex; align-items: center; .logo { height: 40px; // 根据你的logo调整 margin-right: 10px; } .title { font-size: 20px; font-weight: bold; } } .user-info { .el-dropdown-link { cursor: pointer; display: flex; align-items: center; } } } .app-body { height: calc(100vh - 60px); // 减去 Header 的高度 (假设 Header 高度为 60px) } .app-aside { background-color: #fff; border-right: 1px solid #e6e6e6; .el-menu { border-right: none; // 移除 el-menu 默认的右边框,因为 el-aside 已经有边框了 height: 100%; // 菜单占满侧边栏高度 } } .app-main { padding: 20px; background-color: #fff; // 主内容区背景 margin: 15px; // 添加一些外边距,使其看起来不那么拥挤 border-radius: 4px; // 轻微圆角 box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); // 轻微阴影 } </style>
配置全局前置守卫
router.beforeEach
:
打开src/router/index.ts
,在createRouter
之后,export default router
之前添加守卫逻辑。// test-platform/frontend/src/router/index.ts // ... (imports 和 router 创建代码) ... const router = createRouter({ /* ... routes ... */ }) // 全局前置守卫 router.beforeEach((to, from, next) => { const isAuthenticated = !!localStorage.getItem('user-token') // 检查是否存在 token const pageTitle = to.meta.title ? `${to.meta.title} - 测试平台` : '测试平台' document.title = pageTitle; if (to.name !== 'login' && !isAuthenticated) { // 如果用户未认证,且目标路由不是登录页,则重定向到登录页 console.log('用户未登录,跳转到登录页') next({ name: 'login', query: { redirect: to.fullPath } }) // 携带当前尝试访问的路径,以便登录后跳回 } else if (to.name === 'login' && isAuthenticated) { // 如果用户已认证,且尝试访问登录页,则重定向到仪表盘 console.log('用户已登录,访问登录页,跳转到首页') next({ name: 'dashboard' }) } else { // 其他情况,正常放行 next() } }) export default router
代码解释:
router.beforeEach((to, from, next) => { ... })
: 定义了一个全局前置守卫。const isAuthenticated = !!localStorage.getItem('user-token')
: 简单地检查localStorage
中是否存在user-token
来判断用户是否已“登录”。!!
将值转换为布尔类型。document.title = ...
: 我们顺便在这里根据路由的meta.title
更新了页面标题。if (to.name !== 'login' && !isAuthenticated)
:- 如果目标路由的名称不是
login
(即用户想访问非登录页面),并且用户未认证 (!isAuthenticated
)。 - 则调用
next({ name: 'login', query: { redirect: to.fullPath } })
。name: 'login'
:跳转到名为login
的路由。query: { redirect: to.fullPath }
:在 URL 查询参数中添加一个redirect
参数,值为用户原本想访问的完整路径 (to.fullPath
)。这样,在登录成功后,我们可以读取这个redirect
参数,将用户导航回他们最初想去的页面。
- 如果目标路由的名称不是
else if (to.name === 'login' && isAuthenticated)
:- 如果用户已经认证了,但又尝试访问登录页。
- 则调用
next({ name: 'dashboard' })
,直接跳转到仪表盘页面,避免重复登录。
else { next() }
: 其他所有情况 (例如,用户已认证且访问非登录页,或者用户未认证但访问的是登录页),都调用next()
正常放行,允许导航继续。- 重要:
next()
函数在守卫中必须被调用一次,否则导航会挂起。
第三步:优化路由配置和使用路由元信息
路由元信息 (meta
) 允许你为路由附加任意数据,这些数据可以在导航守卫或组件内部访问。
我们已经在路由配置中使用了 meta: { title: '页面标题' }
。还可以添加其他信息,例如:
requiresAuth: true
: 标记该路由是否需要认证。roles: ['admin', 'editor']
: 标记访问该路由需要的用户角色 (用于更细粒度的权限控制)。icon: 'el-icon-xxx'
: 用于在动态生成菜单时指定图标。
示例:使用 requiresAuth
改进导航守卫
我们可以给需要登录才能访问的路由添加 meta: { requiresAuth: true }
标记。
// test-platform/frontend/src/router/index.ts
// ...
children: [
{
path: 'dashboard',
name: 'dashboard',
component: HomeView,
meta: { title: '仪表盘', requiresAuth: true } // 添加 requiresAuth
},
{
path: '/project/list',
name: 'projectList',
component: () => import('../views/project/ProjectListView.vue'),
meta: { title: '项目列表', requiresAuth: true }
},
// ... 其他需要认证的路由也添加 requiresAuth: true
{
path: '/project/detail/:id',
name: 'projectDetail',
component: () => import('../views/project/ProjectDetailView.vue'),
meta: { title: '项目详情', requiresAuth: true },
props: true
},
]
// ...
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
meta: { title: '登录' } // 登录页不需要 requiresAuth
},
// ...
然后修改导航守卫的逻辑:
// test-platform/frontend/src/router/index.ts -> beforeEach
router.beforeEach((to, from, next) => {
const isAuthenticated = !!localStorage.getItem('user-token')
const pageTitle = to.meta.title ? `${to.meta.title} - 测试平台` : '测试平台'
document.title = pageTitle;
if (to.meta.requiresAuth && !isAuthenticated) { // 检查 meta.requiresAuth
console.log('路由需要认证,但用户未登录,跳转到登录页')
next({ name: 'login', query: { redirect: to.fullPath } })
} else if (to.name === 'login' && isAuthenticated) {
console.log('用户已登录,访问登录页,跳转到首页')
next({ name: 'dashboard' })
}
else {
next()
}
})
这种方式使得权限逻辑更清晰:只有标记了 requiresAuth: true
的路由才会进行登录检查。
总结
在这篇文章中,我们深入学习了 Vue Router 的一些核心和高级功能:
- ✅ 动态路由匹配: 使用动态段 (如
:id
) 创建了项目详情页的路由,并通过useRoute().params
或props: true
在组件中获取了路由参数。 - ✅ 编程式导航: 使用
useRouter().push()
和<router-link>
实现了页面间的跳转。 - ✅ 导航守卫 (全局前置守卫
beforeEach
): 实现了一个基于localStorage
中 token 的简单登录权限控制,当用户未登录时访问受保护页面会自动重定向到登录页,并在登录成功后可以跳回原页面。 - ✅ 路由元信息 (
meta
): 学习了如何使用meta
字段为路由附加自定义信息 (如title
,requiresAuth
),并在导航守卫中利用这些信息来增强逻辑。 - ✅ 页面标题动态更新: 在导航守卫中根据路由元信息动态设置了浏览器标签页的标题。
通过这些知识,你现在可以构建出导航逻辑更复杂、用户体验更好的单页应用了。Vue Router 还有更多高级特性,如滚动行为、过渡效果、数据获取等,可以在官方文档中进一步探索。
在下一篇文章中,我们将引入状态管理库 Pinia,学习如何更有效地管理我们应用中的全局状态,例如用户信息、加载状态等,这对于构建大型复杂应用至关重要。