Vue.js从零到精通系列(四):前端路由与Vue Router——打造多页单页应用
摘要:从本篇开始,我们的待办应用将摆脱“单页面”的束缚,正式进化为拥有多个视图、可前进后退的现代单页应用(SPA)。前端路由正是实现这一切的核心机制。本文将先为你揭开路由的神秘面纱,从 Hash 和 History 两种模式的工作原理讲起,然后逐步带你安装 Vue Router、定义路由表、使用<router-link>和<router-view>完成页面跳转。接着,我们将深入动态路由、嵌套路由、命名视图、编程式导航,以及至关重要的路由守卫和元信息。配合 TypeScript 的类型增强,你会学会如何构建安全、可维护的应用导航体系。最终,我们会将之前的 Todo 应用拆分为列表页、详情页和设置页,用路由串联它们,真正感受 SPA 的流畅体验。
一、前端路由是什么?为什么要用路由?
1.1 从“多页”到“单页”的进化
还记得你初学 HTML 时所做的网页吗?点击一个链接,浏览器向服务器请求新的 HTML 文件,整个页面重新加载——这就是多页应用(MPA)。这种模式在用户体验上存在明显割裂感:每次跳转都有白屏,状态也难以保持。
后来,Ajax 技术让局部更新成为可能,但 URL 并不会变化,用户无法通过浏览器的“前进/后退”回到之前的状态,也无法把当前视图加入书签。前端路由应运而生:它让 URL 变化时不向服务器发送请求,而是由 JavaScript 接管,动态替换页面的部分内容。这便是单页应用(SPA)的基础。
1.2 两种路由模式:Hash vs History
Vue Router 支持两种模式,但它们的原理你最好心知肚明。
Hash 模式URL 中带有一个
#号,例如http://example.com/#/list。#及其后面的部分被称为哈希(hash)。浏览器不会把#后的内容发送给服务器,它的变化不会触发页面刷新,但会被记录在浏览历史中。Vue Router 默认使用 hash 模式,因为它兼容所有浏览器,部署也最省心。History 模式利用 HTML5 的
history.pushState和history.replaceStateAPI,可以实现完全不带#的漂亮 URL,如http://example.com/list。这种模式对 SEO 更友好,用户也看着更自然。但它需要后端配合:因为刷新页面时,浏览器会向服务器请求这个路径,服务器必须始终返回index.html,否则会出现 404。
Vue Router 3.x 默认使用 hash,4.x(对应 Vue 3)也支持通过createWebHistory切换到 history 模式。对于你的项目,初期可直接使用 hash 模式,后续上线再配置 nginx 等后端支持。
二、Vue Router 初体验
2.1 创建 Vite + Vue3 项目
pnpm create vite my-vue-router-app --template vue-ts cd my-vue-router-app pnpm install2.2 安装与创建路由
在项目中安装 vue-router(注意,Vue 3 必须使用 4.x 版本):
pnpm add vue-router@4接着创建路由文件,一般放在src/router/index.ts:
import { createRouter, createWebHashHistory } from 'vue-router' const routes: RouteRecordRaw[] = [ { path: '/', name: 'home', component: () => import('../views/HomePage.vue'), }, { path: '/about', name: 'about', component: () => import('../views/AboutPage.vue'), }, { path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('../views/NotFound.vue'), } ] const router = createRouter({ history: createWebHashHistory(), routes, }) export default router这里使用了动态导入() => import(...),Vite 会将其自动拆分为独立的 chunk,实现路由级别的懒加载,首屏更快。
2.3 在 Vue 应用中安装路由
修改src/main.ts,通过app.use(router)注册:
import { createApp } from 'vue' import './style.css' import App from './App.vue' import router from './router' // 引入路由 const app = createApp(App) app.use(router) // 注册路由 app.mount('#app')app.use()你之前可能用过,它是 Vue 插件系统的标准入口。Vue Router 作为插件被安装后,整个应用都可以访问路由实例。
2.4 放置路由出口:<router-view>
现在修改App.vue,删除之前的内容,换成<router-view>:
<template> <div id="app"> <!-- 导航栏 --> <nav class="nav"> <router-link to="/">首页</router-link> <router-link to="/about">关于</router-link> </nav> <!-- 路由页面渲染在这里 --> <div class="container"> <router-view /> </div> </div> </template> <style scoped> .nav { padding: 16px; background: #f5f5f5; margin-bottom: 20px; } .nav a { margin-right: 16px; text-decoration: none; color: #333; } .nav a:hover { color: #0066cc; } .container { padding: 0 16px; } </style><router-view>是一个动态组件,会根据当前 URL 渲染匹配的页面组件。<router-link>则会被渲染为一个<a>标签,点击时不会刷新页面,只触发前端路由切换。
2.4 编写简单页面
在src/views/下创建三个文件:
src/views/HomePage.vue(首页)
<template> <div> <h1>🏠 首页</h1> <p>欢迎使用 Vue 3 + Vue Router 4</p> </div> </template>src/views/AboutPage.vue(关于页)
<template> <div> <h1>ℹ️ 关于页面</h1> <p>这是一个单页应用(SPA)</p> </div> </template>src/views/NotFound.vue(404 页面)
<template> <div> <h1>❌ 404 页面不存在</h1> <p><router-link to="/">返回首页</router-link></p> </div> </template>运行pnpm dev,点击导航链接,页面内容无刷新切换,URL 也会自动变成/#/和/#/about。你的第一个 SPA 已经诞生!
三、动态路由:路径中的变量
真实的业务中,路由往往携带变量。比如商品详情页需要商品 ID,/product/123。Vue Router 的动态路由用:定义参数。
3.1 定义动态路由
在src/router/index.ts文件中添加:
{ path: '/todo/:id', name: 'todo-detail', component: () => import('../views/TodoDetail.vue') }:id表示动态部分,可以匹配任意字符串(不包括/)。如果希望参数可选,可以在后面加?:/:id?。
3.2 获取并监听路由参数
创建【待办详情页】src/views/TodoDetail.vue,在组件中,通过useRoute获取参数:
<script setup lang="ts"> import { useRoute } from 'vue-router' import { watch } from 'vue' // 1. 获取当前路由 const route = useRoute() // 2. 拿到动态参数 id const todoId = route.params.id // 3. 监听参数变化(从 /todo/1 → /todo/2 会触发) watch( () => route.params.id, (newId) => { console.log('ID 变了:', newId) // 这里可以重新请求接口拿数据 } ) </script> <template> <div> <h1>待办详情</h1> <h3>当前路由参数:{{ todoId }}</h3> <p>你正在查看第 {{ todoId }} 号任务</p> <br /> <router-link to="/">← 返回首页</router-link> </div> </template>如果你想让 TypeScript 知道id一定是字符串(因为路径是:id),可以利用路由的类型声明进行增强。Vue Router 提供了类型扩展接口,但最简单的方式就是使用非空断言或类型转换。
3.3 使用useRoute和useRouter的区别
useRoute()返回当前路由的响应式对象,包含params、query、hash、meta等,用于读取信息。useRouter()返回路由器实例,用于编程式导航(如push、replace)。
import { useRouter, useRoute } from 'vue-router' const router = useRouter() const route = useRoute() function goToDetail(id: number) { router.push({ name: 'todo-detail', params: { id } }) }3.4 修改主页文件
src/views/HomePage.vue
<script setup lang="ts"> import { useRouter } from 'vue-router' // 获取路由实例(用来跳转) const router = useRouter() // 编程式导航(点击按钮跳转) function goDetail(id: number) { router.push({ name: 'todo-detail', params: { id: id } }) } </script> <template> <div> <h1>首页 → 待办列表</h1> <!-- 方式1:router-link 跳转 --> <div> <p>方式1:标签跳转</p> <router-link to="/todo/1">查看 1 号任务</router-link> <br /> <router-link to="/todo/2">查看 2 号任务</router-link> </div> <br /> <!-- 方式2:点击事件跳转 --> <div> <p>方式2:按钮编程式跳转</p> <button @click="goDetail(100)">查看 100 号任务</button> <button @click="goDetail(200)">查看 200 号任务</button> </div> </div> </template>3.5 App.vue 加导航
修改src/App.vue
<template> <div> <nav> <router-link to="/">首页</router-link> | <router-link to="/about">关于</router-link> | <router-link to="/todo/1">任务1</router-link> | <router-link to="/todo/66">任务66</router-link> </nav> <hr /> <router-view /> </div> </template>四、嵌套路由与命名视图
4.1 嵌套路由
当页面有公共布局(如侧边栏、顶栏)时,我们希望子页面嵌套在布局内部切换。Vue Router 通过children配置实现:
const routes: RouteRecordRaw[] = [ { path: '/user', component: () => import('../views/UserLayout.vue'), children: [ { path: '', // 默认子路由 component: () => import('../views/UserProfile.vue') }, { path: 'settings', component: () => import('../views/UserSettings.vue') } ] } ]4.2 命名视图
有时候,一个页面需要同时展示多个同级组件(比如侧边栏和主内容同时变化)。命名视图可以使用components替代component,并给<router-view>指定name:
{ path: '/dashboard', components: { default: () => import('../views/DashboardMain.vue'), sidebar: () => import('../views/DashboardSidebar.vue') } }整体修改src/router/index.ts如下:
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router' const routes: RouteRecordRaw[] = [ // 基础路由 { path: '/', name: 'home', component: () => import('../views/HomePage.vue') }, { path: '/about', name: 'about', component: () => import('../views/AboutPage.vue') }, // 动态路由(承接上一节) { path: '/todo/:id', name: 'todo-detail', component: () => import('../views/TodoDetail.vue') }, // ========== 4.1 嵌套路由 ========== { path: '/user', name: 'user', component: () => import('../views/UserLayout.vue'), // 子路由数组 children: [ // 默认子路由:访问 /user 时渲染 { path: '', name: 'user-profile', component: () => import('../views/UserProfile.vue') }, // 子路由:访问 /user/settings { path: 'settings', name: 'user-settings', component: () => import('../views/UserSettings.vue') } ] }, // ========== 4.2 命名视图 ========== { path: '/dashboard', name: 'dashboard', // 命名视图使用 components (复数) components: { default: () => import('../views/DashboardMain.vue'), sidebar: () => import('../views/DashboardSidebar.vue') } }, // 404 兜底路由 { path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('../views/NotFound.vue') } ] const router = createRouter({ history: createWebHashHistory(), routes }) export default router4.3 公共布局src/views/UserLayout.vue
<template> <div class="user-layout"> <!-- 侧边导航 --> <aside class="aside"> <h3>用户中心</h3> <div class="nav-item"> <router-link to="/user">个人资料</router-link> </div> <div class="nav-item"> <router-link to="/user/settings">账户设置</router-link> </div> </aside> <!-- 子页面渲染出口 --> <main class="main"> <router-view /> </main> </div> </template> <style scoped> .user-layout { display: flex; gap: 20px; margin-top: 20px; } .aside { width: 180px; padding: 16px; background: #f5f5f5; } .nav-item { margin: 10px 0; } a { text-decoration: none; color: #333; } a.router-link-active { color: #42b983; font-weight: bold; } .main { flex: 1; padding: 16px; border: 1px solid #eee; } </style>4.4 默认子页面src/views/UserProfile.vue
<template> <div> <h2>个人资料</h2> <p>这是 /user 路由对应的默认子页面</p> </div> </template>4.5 设置页面src/views/UserSettings.vue
<template> <div> <h2>账户设置</h2> <p>这是 /user/settings 子页面</p> </div> </template>4.6 侧边栏视图src/views/DashboardSidebar.vue
<template> <div class="sidebar"> <h3>控制台侧边栏</h3> <p>菜单1</p> <p>菜单2</p> <p>菜单3</p> </div> </template> <style scoped> .sidebar { width: 160px; padding: 16px; background: #eef2f7; } </style>4.7 主内容视图src/views/DashboardMain.vue
<template> <div class="main-content"> <h2>控制台主内容</h2> <p>命名视图 - 默认视图区域</p> </div> </template> <style scoped> .main-content { flex: 1; padding: 16px; border: 1px solid #ccc; } </style>4.8 修改根组件App.vue
<template> <div class="app"> <!-- 全局导航 --> <nav class="global-nav"> <router-link to="/">首页</router-link> <router-link to="/about">关于</router-link> <router-link to="/todo/888">动态路由</router-link> <router-link to="/user">用户中心(嵌套路由)</router-link> <router-link to="/dashboard">控制台(命名视图)</router-link> </nav> <hr> <!-- 命名视图容器:两个带 name 的 router-view --> <div class="dashboard-box"> <router-view name="sidebar" /> <router-view /> <!-- 不写name,对应 default 视图 --> </div> </div> </template> <style scoped> .global-nav { margin: 10px 0; } .global-nav a { margin-right: 15px; text-decoration: none; } .global-nav a.router-link-active { color: #42b983; font-weight: bold; } .dashboard-box { display: flex; gap: 20px; margin-top: 20px; } </style>五、导航方式与路由传参
5.1 声明式导航:<router-link>
它比硬编码的<a>更智能,会自动添加激活时的 CSS 类router-link-active和router-link-exact-active,方便高亮当前导航。
<router-link to="/" exact-active-class="exact-active">首页</router-link>5.2 编程式导航
在 JavaScript 中跳转:
import { useRouter } from 'vue-router' const router = useRouter() // 字符串 router.push('/about') // 命名路由 + params router.push({ name: 'todo-detail', params: { id: 123 } }) // 带 query router.push({ path: '/search', query: { q: 'vue' } })push会添加一条历史记录,replace则会替换当前记录(不会留下记录)。还有router.go(n)可以前进或后退 n 步。
5.3 路由传参的方式与选择
除了params,还可以使用query。它们的区别:
params属于路径的一部分,在动态路由中定义(:id),刷新页面不会丢失。query是?后面的键值对,不需要预先在路由声明中配置,通常用于可选筛选条件。
TypeScript 提示:当使用命名路由跳转时,Vue Router 4 提供了有限的类型推断。可以借助unplugin-vue-router等增强类型,但对于本篇学习,强类型断言已经足够。
六、路由守卫:把控跳转的每一关
导航守卫让你能在路由切换的各个阶段拦截或重定向。它就像一道安检门,你可以定义“谁可以进、谁需要先登录”。
6.1 全局守卫
在router/index.ts中定义,作用于所有路由:
const router = createRouter({ ... }) // 全局前置守卫 router.beforeEach((to, from, next) => { // 根据条件放行或跳转 if (to.meta.requiresAuth && !isLoggedIn()) { next({ name: 'login', query: { redirect: to.fullPath } }) } else { next() } }) // 全局解析守卫(导航被确认之前,所有组件内守卫和异步路由组件被解析之后) router.beforeResolve((to, from, next) => { // 可用于加载数据 next() }) // 全局后置钩子(不会改变导航本身) router.afterEach((to, from) => { document.title = to.meta.title as string || '我的应用' })Vue Router 4 的守卫支持返回一个值或 Promise 来取代next回调(但仍支持 next):
router.beforeEach(async (to, from) => { if (to.meta.requiresAuth && !(await checkAuth())) { return { name: 'login' } } })6.2 路由独享守卫
可以直接在路由记录上定义beforeEnter:
const routes = [ { path: '/admin', component: AdminPage, beforeEnter: (to, from) => { // 只在进入此路由时触发 if (!isAdmin()) return { name: 'forbidden' } } } ]6.3 组件内守卫
在组件内部,你可以通过onBeforeRouteUpdate和onBeforeRouteLeave钩子实现组件级守卫:
<script setup lang="ts"> import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router' onBeforeRouteUpdate((to, from) => { // 当前路由改变但组件复用时调用(如 /todo/1 → /todo/2) if (hasUnsavedChanges.value) { const answer = window.confirm('有未保存更改,确定离开吗?') if (!answer) return false } }) onBeforeRouteLeave((to, from) => { // 导航离开时调用 window.clearInterval(timer) // 清理 }) </script>6.4 完整的导航解析流程
了解生命周期有助于调试:
编辑
七、路由元信息与类型扩展
7.1 使用 meta 存储附加信息
每个路由都可以携带meta对象,用于存储权限、图标、标题等自定义数据:
const routes: RouteRecordRaw[] = [ { path: '/admin', component: AdminPage, meta: { requiresAuth: true, role: 'admin', title: '管理后台' } } ]在守卫中读取:
router.beforeEach((to) => { if (to.meta.requiresAuth) { ... } })7.2 为 meta 扩展 TypeScript 类型
默认情况下route.meta是any类型。为了获得智能提示,可以扩展RouteMeta接口:
// src/types/router.d.ts import 'vue-router' declare module 'vue-router' { interface RouteMeta { requiresAuth?: boolean title?: string role?: string } }现在在守卫或组件中访问to.meta.title,TypeScript 会知道你声明的所有字段,极大提升开发体验。
八、综合案例:Todo 应用的多页面改造
让我们把第二篇和第三篇的 Todo 应用正式进化为 SPA,拥有三个页面:列表页、详情页、设置页。
src/ ├── router │ └── index.ts # 路由配置 ├── types.ts # TS类型定义 ├── App.vue # 根布局+导航+路由出口 ├── views │ ├── TodoListPage.vue # 待办列表页 │ ├── TodoDetailPage.vue# 单条详情页 │ └── SettingsPage.vue # 设置页面 └── components ├── TodoHeader.vue ├── TodoInput.vue ├── TodoList.vue ├── TodoItem.vue └── TodoFooter.vue8.1 路由表设计
src/router/index.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router' const routes: RouteRecordRaw[] = [ { path: '/', name: 'todo-list', component: () => import('../views/TodoListPage.vue'), meta: { title: '待办列表' } }, { path: '/todo/:id', name: 'todo-detail', component: () => import('../views/TodoDetailPage.vue'), meta: { title: '待办详情' } }, { path: '/settings', name: 'settings', component: () => import('../views/SettingsPage.vue'), meta: { title: '系统设置' } } ] const router = createRouter({ history: createWebHashHistory(), routes }) // 全局后置钩子修改浏览器标题 router.afterEach((to) => { document.title = (to.meta.title as string) || 'Vue3 Todo SPA应用' }) export default router8.2 列表页:TodoListPage.vue
我们将原先的App.vue中的主要逻辑搬迁到列表页,保留TodoHeader、TodoInput、TodoList和TodoFooter组件。同时增加点击项目跳转到详情页的功能。
<script setup lang="ts"> import { ref, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' import TodoHeader from '../components/TodoHeader.vue' import TodoInput from '../components/TodoInput.vue' import TodoList from '../components/TodoList.vue' import TodoItem from '../components/TodoItem.vue' import TodoFooter from '../components/TodoFooter.vue' import type { Todo } from '../types' const router = useRouter() const todos = ref<Todo[]>([]) let nextId = 1 const activeCount = computed(() => todos.value.filter(t => !t.done).length) const allDone = computed({ get: () => todos.value.length > 0 && activeCount.value === 0, set: (val: boolean) => todos.value.forEach(t => t.done = val) }) function addTodo(text: string) { todos.value.push({ id: nextId++, text, done: false }) } function toggleTodo(id: number) { const todo = todos.value.find(t => t.id === id) if (todo) todo.done = !todo.done } function removeTodo(id: number) { todos.value = todos.value.filter(t => t.id !== id) } function clearCompleted() { todos.value = todos.value.filter(t => !t.done) } function goToDetail(id: number) { router.push({ name: 'todo-detail', params: { id } }) } onMounted(() => { const saved = localStorage.getItem('vue3-todos') if (saved) { try { const data = JSON.parse(saved) as Todo[] todos.value = data nextId = data.reduce((max, t) => Math.max(max, t.id), 0) + 1 } catch {} } }) </script> <template> <div class="todo-page"> <TodoHeader :active-count="activeCount" :total="todos.length" /> <TodoInput @add="addTodo" /> <TodoList :todos="todos" @toggle="toggleTodo" @remove="removeTodo"> <template #item="{ todo }"> <TodoItem :todo="todo" @toggle="toggleTodo" @remove="removeTodo" @click="goToDetail(todo.id)" /> </template> </TodoList> <TodoFooter v-if="todos.length > 0" v-model="allDone" @clear-completed="clearCompleted" /> </div> </template>注意我们给TodoItem添加了点击事件,跳转到详情页。
8.3 详情页:TodoDetailPage.vue
从路由获取 ID,并读取数据展示。真实项目中会从 API 或状态管理获取,这里简单从localStorage查找。
<script setup lang="ts"> import { ref, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import type { Todo } from '../types' const route = useRoute() const router = useRouter() const todo = ref<Todo | null>(null) onMounted(() => { const id = Number(route.params.id) const saved = localStorage.getItem('vue3-todos') if (saved) { const todos = JSON.parse(saved) as Todo[] todo.value = todos.find(t => t.id === id) || null } }) function goBack() { router.push({ name: 'todo-list' }) } </script> <template> <div> <button @click="goBack">← 返回列表</button> <div v-if="todo"> <h1>{{ todo.text }}</h1> <p>状态:{{ todo.done ? '已完成' : '未完成' }}</p> <p>ID:{{ todo.id }}</p> </div> <div v-else> <p>待办不存在</p> </div> </div> </template>8.4 导航栏 App.vue
App.vue现在只需提供公共导航和路由出口:
<template> <div id="app"> <nav class="app-nav"> <router-link to="/">列表</router-link> <router-link to="/settings">设置</router-link> </nav> <main class="app-main"> <router-view /> </main> </div> </template> <style scoped> .app-nav { padding: 12px; background: #f0f0f0; display: flex; gap: 16px; } .router-link-active { color: #42b883; font-weight: bold; } </style>现在运行应用,你可以在列表页、详情页和设置页之间无缝切换,浏览器前进后退按钮完美工作,URL 也始终同步。
[此处插入图片:多页面Todo应用截图拼图,展示列表页、详情页和设置页]
九、本篇总结
本文我们系统学习了 Vue Router,从前端路由的原理到环境配置,再到动态路由、嵌套路由、编程导航、路由守卫和元信息。通过重构 Todo 应用,你将组件化与路由结合起来,实现了真正意义上的单页应用。
关键收获:
前端路由让 SPA 的 URL 变化可控,主要有 Hash 和 History 两种模式。
createRouter+createWebHashHistory初始化路由,<router-view>是出口,<router-link>声明导航。动态路由用
:param定义,useRoute().params获取参数,复用组件时需watch参数变化。嵌套路由通过
children实现布局复用,命名视图可渲染多个同级组件。router.push/replace/go进行编程式导航,params和query各有适用场景。路由守卫体系(全局、独享、组件内)让你能够拦截导航,实现权限控制、数据预载等逻辑。
利用
meta和 TypeScript 扩展增强代码安全性和可维护性。
下篇预告: 有了路由,我们还缺一个能全局共享状态、跨组件通信的利器——状态管理。下一篇将走进 Pinia,Vue 3 的官方状态管理库。你将会学到如何定义 Store、使用 State/Getters/Actions,在 Todo 应用中实现数据持久化与多页面共享,以及与 Vue Devtools 的结合,让复杂应用的状态管理变得清晰而优雅。
总结路由是 SPA 的骨架,它让应用从单一的页面变成了有机的整体。本文从原理到实战,覆盖了 Vue Router 在实际开发中最常用的知识和技巧。请务必跟着教程亲手重构 Todo 应用,当你点击“详情”看到 URL 变化、内容切换,却没有任何刷新时,就能真正理解前端路由的魅力。掌握路由,你就拥有了构建大型前端应用的导航能力。
[此处插入图片:Vue Router 知识体系思维导图,涵盖路由配置、导航守卫、组合式 API 等]
