L10 · Vue Router:从单页到多页
🎯 本节目标:集成 Vue Router 4,实现多视图 SPA + 路由守卫
📦 本节产出:支持 任务看板 / 任务详情 / 统计 / 设置 四个页面的 SPA
🔗 前置钩子:L09 拆分后的组件架构
🔗 后续钩子:L11 将引入 Pinia 管理跨路由的共享状态1. SPA 与路由的概念
2. 安装和基础配置
npm install vue-router@42.1 创建路由配置
先准备页面文件,避免路由导入不存在的组件:
- 将 L09 的 App.vue 内容复制为
src/views/HomeView.vue,把其中./components/、./composables/导入改成@/components/、@/composables/。保留样式与 Todo 功能。 - 在
src/views/创建下面四个文件。详情页的id来自 URL,是字符串,需要转成 Todo 使用的数字 ID。
<!-- src/views/TaskDetailView.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useTodos } from '@/composables/useTodos'
const props = defineProps<{ id: string }>()
const { todos } = useTodos()
const todo = computed(() => todos.value.find(item => item.id === Number(props.id)))
</script>
<template>
<section v-if="todo">
<h1>{{ todo.text }}</h1>
<p>{{ todo.done ? '已完成' : '进行中' }} · {{ todo.priority }}</p>
<p>创建于 {{ todo.createdAt }}</p>
</section>
<p v-else>任务不存在</p>
</template><!-- src/views/StatsView.vue -->
<script setup lang="ts">
import TodoStats from '@/components/todo/TodoStats.vue'
import { useTodos } from '@/composables/useTodos'
const { stats } = useTodos()
</script>
<template><section><h1>任务统计</h1><TodoStats v-bind="stats" /></section></template><!-- src/views/SettingsView.vue -->
<template><section><h1>设置</h1><p>主题设置将在 L16 加入。</p></section></template><!-- src/views/NotFoundView.vue -->
<template><section><h1>页面不存在</h1><RouterLink to="/">返回任务列表</RouterLink></section></template>本节各页面调用 useTodos() 会分别读取本地存储并创建状态;跨页面实时共享会在 L11 改为 Pinia。然后创建路由配置:
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'), // 路由懒加载
},
{
path: '/tasks/:id',
name: 'task-detail',
component: () => import('@/views/TaskDetailView.vue'),
props: true, // 将路由参数作为 Props 传入组件
},
{
path: '/stats',
name: 'stats',
component: () => import('@/views/StatsView.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
{
// 404 页面
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
},
],
})
export default router2.2 注册路由
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'
createApp(App)
.use(router) // 安装路由插件
.mount('#app')2.3 使用 RouterView 和 RouterLink
<!-- src/App.vue -->
<script setup lang="ts">
import { RouterView, RouterLink } from 'vue-router'
</script>
<template>
<div class="app">
<nav class="app-nav">
<RouterLink to="/" class="nav-link">📋 任务</RouterLink>
<RouterLink to="/stats" class="nav-link">📊 统计</RouterLink>
<RouterLink to="/settings" class="nav-link">⚙️ 设置</RouterLink>
</nav>
<!-- 路由出口:匹配的组件渲染在这里 -->
<main class="app-main">
<RouterView />
</main>
</div>
</template>3. 路由懒加载
// 静态导入:页面代码进入初始依赖图,是否拆包取决于构建配置
import HomeView from '@/views/HomeView.vue'
import StatsView from '@/views/StatsView.vue'
// 动态导入:形成异步加载边界;共享依赖仍可能合并为公共 chunk
component: () => import('@/views/HomeView.vue')4. 动态路由与参数
4.1 路由参数
// 路由配置
{ path: '/tasks/:id', component: TaskDetailView, props: true }<!-- TaskDetailView.vue -->
<script setup lang="ts">
// 方式 1:通过 Props 接收(推荐,需要路由配置 props: true)
const props = defineProps<{ id: string }>()
// 方式 2:通过 useRoute 获取
import { useRoute } from 'vue-router'
const route = useRoute()
console.log(route.params.id) // 等同于 props.id
</script>路由从 /tasks/1 切到 /tasks/2 时会复用同一组件,props.id 会更新,setup 不会重跑。上面完整详情页使用 computed 查找任务,因此能响应参数变化;如果需要请求接口,则 watch 对应参数。
在 HomeView 的 TodoItem 附近加入 <RouterLink :to="{ name: 'task-detail', params: { id: todo.id } }">详情</RouterLink> 时,要把链接和 TodoItem 放在同一个 v-for 容器中。也可以先通过地址栏访问 /tasks/1 验证详情页。
4.2 编程式导航
import { useRouter } from 'vue-router'
const router = useRouter()
// 跳转到指定路由
router.push('/stats')
router.push({ name: 'task-detail', params: { id: '42' } })
// 替换当前记录(不产生历史记录)
router.replace('/settings')
// 后退
router.back()5. 路由守卫
5.1 全局前置守卫
下面是可选的导航守卫练习,使用本地标记模拟登录,不构成真实认证。先创建登录页并注册路由,否则跳转到不存在的 login 名称会失败:
<!-- src/views/LoginView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const error = ref('')
function login() {
try {
localStorage.setItem('user-token', 'demo-only')
void router.replace('/settings')
} catch {
error.value = '浏览器存储不可用,无法设置演示登录标记'
}
}
</script>
<template><section><h1>演示登录</h1><button @click="login">进入演示</button><p>{{ error }}</p></section></template>// src/router/index.ts,放在 createRouter 之后
router.addRoute({
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
})
function isLoggedIn() {
try {
return localStorage.getItem('user-token') !== null
} catch {
return false
}
}
router.beforeEach((to) => {
if (to.name === 'settings' && !isLoggedIn()) {
return { name: 'login' }
}
})守卫只决定前端导航是否放行。真正的数据访问权限必须由后端验证,不能依赖 localStorage 中是否存在一个字符串。
5.2 路由元信息
// 在路由配置中添加 meta
{
path: '/settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
meta: {
requiresAuth: true, // 需要登录
title: '设置', // 页面标题
},
}
// 用这个守卫替换 §5.1 的 beforeEach,复用已经定义的 isLoggedIn
router.beforeEach((to) => {
// 检查是否需要登录
if (to.meta.requiresAuth && to.name !== 'login' && !isLoggedIn()) {
return { name: 'login' }
}
})
router.afterEach((to, _from, failure) => {
if (!failure) {
document.title = typeof to.meta.title === 'string' ? to.meta.title : 'Vue Todo'
}
})6. 嵌套路由
下面展示另一种路由组织方式,练习时替换原有 /tasks/:id 路由,避免重复命名;先创建 TasksLayout.vue,并把 HomeView 复制为 TaskListView 作为列表页。
{
path: '/tasks',
component: () => import('@/views/TasksLayout.vue'),
children: [
{
path: '', // /tasks
name: 'task-list',
component: () => import('@/views/TaskListView.vue'),
},
{
path: ':id', // /tasks/42
name: 'task-detail',
component: () => import('@/views/TaskDetailView.vue'),
props: true,
},
],
}<!-- TasksLayout.vue -->
<template>
<div class="tasks-layout">
<aside class="sidebar">
<!-- 侧边栏导航 -->
</aside>
<div class="content">
<RouterView /> <!-- 子路由渲染在这里 -->
</div>
</div>
</template>7. RouterLink 的激活状态
<template>
<!-- RouterLink 自动给匹配的链接添加 class -->
<RouterLink to="/" class="nav-link">
<!-- 精确匹配:router-link-exact-active -->
<!-- 当前匹配记录及匹配参数相同的祖先记录:router-link-active -->
首页
</RouterLink>
</template>
<style>
/* 自定义激活样式 */
.nav-link.router-link-exact-active {
color: #42b883;
font-weight: bold;
}
</style>激活状态按路由记录与 params 判断,不是简单的路径字符串前缀;query 不参与此判断。History 模式部署到服务器时,还需把前端路由回退到 index.html,仅配置 base 不能解决直接刷新详情页的 404,见 History 模式。参数复用与守卫规则见 动态路由、导航守卫。
8. 本节总结
检查清单
- [ ] 能安装和配置 Vue Router 4
- [ ] 能使用
<RouterView>和<RouterLink> - [ ] 能实现路由懒加载
- [ ] 能使用动态路由参数
:id - [ ] 能用
useRouter()实现编程式导航 - [ ] 能配置路由守卫
beforeEach - [ ] 能使用嵌套路由和路由元信息
🐞 防坑指南
| 坑 | 说明 | 正确做法 |
|---|---|---|
忘记 createWebHistory 的 base | 部署到子路径时 404 | createWebHistory(import.meta.env.BASE_URL) |
| 守卫里死循环 | beforeEach 中 next('/login') 无限跳转 | 加条件判断 if (to.path !== '/login') |
| 路由懒加载用错语法 | import() 接收模块路径,不是已导入的组件对象;动态路径也受构建器限制 | () => import('./views/Foo.vue') |
| 动态路由参数变了组件不刷新 | /user/1 → /user/2 复用同一组件 | 用 watch(() => route.params.id) 或 :key="route.params.id" |
📐 最佳实践
- 页面放
views/,组件放components/:路由挂载的顶层组件放 views 目录 - 路由懒加载:除首页外的路由全部用
() => import()按需加载 - 路由命名:用
name属性而非硬编码路径({ name: 'task-detail', params: { id } }) - 滚动行为:配置
scrollBehavior返回{ top: 0 },切页自动回顶
Git 提交
git add .
git commit -m "L10: Vue Router 多页面 + 路由守卫"🔗 钩子连接
→ 下一节:L11 · Pinia 全局状态管理
多页面后出现新问题:不同路由页面需要共享同一份 todos 数据。 目前的 composable 在每个组件实例中创建独立副本,L11 将引入 Pinia 实现真正的全局状态共享。