Skip to content

L10 · Vue Router:从单页到多页 ​

🎯 本节目标:集成 Vue Router 4,实现多视图 SPA + 路由守卫
📦 本节产出:支持 任务看板 / 任务详情 / 统计 / 设置 四个页面的 SPA
🔗 前置钩子:L09 拆分后的组件架构
🔗 后续钩子:L11 将引入 Pinia 管理跨路由的共享状态

1. SPA 与路由的概念 ​


2. 安装和基础配置 ​

bash
npm install vue-router@4

2.1 创建路由配置 ​

先准备页面文件,避免路由导入不存在的组件:

  • 将 L09 的 App.vue 内容复制为 src/views/HomeView.vue,把其中 ./components/、./composables/ 导入改成 @/components/、@/composables/。保留样式与 Todo 功能。
  • 在 src/views/ 创建下面四个文件。详情页的 id 来自 URL,是字符串,需要转成 Todo 使用的数字 ID。
vue
<!-- 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>
vue
<!-- 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>
vue
<!-- src/views/SettingsView.vue -->
<template><section><h1>设置</h1><p>主题设置将在 L16 加入。</p></section></template>
vue
<!-- src/views/NotFoundView.vue -->
<template><section><h1>页面不存在</h1><RouterLink to="/">返回任务列表</RouterLink></section></template>

本节各页面调用 useTodos() 会分别读取本地存储并创建状态;跨页面实时共享会在 L11 改为 Pinia。然后创建路由配置:

typescript
// 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 router

2.2 注册路由 ​

typescript
// 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')
vue
<!-- 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. 路由懒加载 ​

typescript
// 静态导入:页面代码进入初始依赖图,是否拆包取决于构建配置
import HomeView from '@/views/HomeView.vue'
import StatsView from '@/views/StatsView.vue'

// 动态导入:形成异步加载边界;共享依赖仍可能合并为公共 chunk
component: () => import('@/views/HomeView.vue')

4. 动态路由与参数 ​

4.1 路由参数 ​

typescript
// 路由配置
{ path: '/tasks/:id', component: TaskDetailView, props: true }
vue
<!-- 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 编程式导航 ​

typescript
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 名称会失败:

vue
<!-- 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>
typescript
// 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 路由元信息 ​

typescript
// 在路由配置中添加 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 作为列表页。

typescript
{
  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,
    },
  ],
}
vue
<!-- TasksLayout.vue -->
<template>
  <div class="tasks-layout">
    <aside class="sidebar">
      <!-- 侧边栏导航 -->
    </aside>
    <div class="content">
      <RouterView />  <!-- 子路由渲染在这里 -->
    </div>
  </div>
</template>

vue
<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部署到子路径时 404createWebHistory(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"

📐 最佳实践 ​

  1. 页面放 views/,组件放 components/:路由挂载的顶层组件放 views 目录
  2. 路由懒加载:除首页外的路由全部用 () => import() 按需加载
  3. 路由命名:用 name 属性而非硬编码路径({ name: 'task-detail', params: { id } })
  4. 滚动行为:配置 scrollBehavior 返回 { top: 0 },切页自动回顶

Git 提交 ​

bash
git add .
git commit -m "L10: Vue Router 多页面 + 路由守卫"

🔗 钩子连接 ​

→ 下一节:L11 · Pinia 全局状态管理 ​

多页面后出现新问题:不同路由页面需要共享同一份 todos 数据。 目前的 composable 在每个组件实例中创建独立副本,L11 将引入 Pinia 实现真正的全局状态共享。