第4章:路由与状态管理
掌握 Vue Router 和 Pinia 的核心用法,构建复杂的单页应用。本章将深入讲解路由配置、导航守卫、状态管理的最佳实践,为构建大型应用打下基础。
📋 本章内容
🎯 学习目标
- 掌握 Vue Router 的核心概念和配置
- 理解路由守卫和数据预取机制
- 熟练使用 Pinia 进行状态管理
- 学会设计可维护的状态架构
- 掌握状态持久化和 SSR 集成
🛣 Vue Router 核心概念
基础概念
Vue Router 是 Vue.js 的官方路由管理器,它提供了:
- 声明式路由:通过组件配置路由
- 编程式导航:通过 JavaScript 控制路由
- 路由守卫:控制路由访问权限
- 数据预取:在路由切换前获取数据
安装与配置
# 安装 Vue Router
pnpm add vue-router@4
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/about',
name: 'About',
component: () => import('@/views/About.vue')
},
{
path: '/user/:id',
name: 'User',
component: () => import('@/views/User.vue'),
props: true
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
export default router
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
🧭 路由配置与导航
路由配置详解
// 路由配置选项
interface RouteRecordRaw {
path: string // 路由路径
name?: string // 路由名称
component?: Component // 路由组件
components?: Record<string, Component> // 命名视图
redirect?: string | RouteLocationRaw // 重定向
alias?: string | string[] // 别名
children?: RouteRecordRaw[] // 嵌套路由
beforeEnter?: NavigationGuard // 路由独享守卫
meta?: Record<string, any> // 路由元信息
props?: boolean | object | function // 路由参数传递
sensitive?: boolean // 路径大小写敏感
strict?: boolean // 严格模式
}
嵌套路由
// 嵌套路由配置
const routes: RouteRecordRaw[] = [
{
path: '/dashboard',
component: () => import('@/layouts/Dashboard.vue'),
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/Dashboard/Index.vue')
},
{
path: 'profile',
name: 'Profile',
component: () => import('@/views/Dashboard/Profile.vue')
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/Dashboard/Settings.vue')
}
]
}
]
<!-- layouts/Dashboard.vue -->
<template>
<div class="dashboard">
<nav class="dashboard-nav">
<router-link to="/dashboard">概览</router-link>
<router-link to="/dashboard/profile">个人资料</router-link>
<router-link to="/dashboard/settings">设置</router-link>
</nav>
<main class="dashboard-content">
<router-view />
</main>
</div>
</template>
动态路由
// 动态路由配置
const routes: RouteRecordRaw[] = [
{
path: '/user/:id',
name: 'User',
component: () => import('@/views/User.vue'),
props: true // 将路由参数作为 props 传递
},
{
path: '/user/:id/post/:postId',
name: 'UserPost',
component: () => import('@/views/UserPost.vue'),
props: route => ({
userId: route.params.id,
postId: route.params.postId
})
}
]
<!-- views/User.vue -->
<template>
<div class="user">
<h1>用户信息</h1>
<p>用户ID: {{ id }}</p>
</div>
</template>
<script setup lang="ts">
interface Props {
id: string
}
const props = defineProps<Props>()
</script>
编程式导航
// 在组合式 API 中使用路由
import { useRouter, useRoute } from 'vue-router'
export default {
setup() {
const router = useRouter()
const route = useRoute()
// 编程式导航
const navigateToUser = (userId: string) => {
router.push({
name: 'User',
params: { id: userId }
})
}
// 替换当前路由
const replaceRoute = () => {
router.replace('/login')
}
// 前进/后退
const goBack = () => {
router.go(-1)
}
// 获取当前路由信息
const currentRoute = computed(() => ({
path: route.path,
name: route.name,
params: route.params,
query: route.query
}))
return {
navigateToUser,
replaceRoute,
goBack,
currentRoute
}
}
}
路由参数与查询
// 路由参数传递
const routes: RouteRecordRaw[] = [
{
path: '/search',
name: 'Search',
component: () => import('@/views/Search.vue')
}
]
// 导航时传递参数
router.push({
name: 'Search',
query: {
q: 'vue',
page: 1,
category: 'tutorial'
}
})
// 在组件中获取参数
export default {
setup() {
const route = useRoute()
const searchQuery = computed(() => route.query.q as string)
const currentPage = computed(() => Number(route.query.page) || 1)
const category = computed(() => route.query.category as string)
return {
searchQuery,
currentPage,
category
}
}
}
🛡 导航守卫与数据预取
全局守卫
// 全局前置守卫
router.beforeEach((to, from, next) => {
// 检查用户是否已登录
const isAuthenticated = checkAuth()
if (to.meta.requiresAuth && !isAuthenticated) {
next({
name: 'Login',
query: { redirect: to.fullPath }
})
} else {
next()
}
})
// 全局解析守卫
router.beforeResolve(async (to, from, next) => {
// 在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后调用
if (to.meta.requiresData) {
try {
await fetchData(to.params.id)
next()
} catch (error) {
next({ name: 'Error' })
}
} else {
next()
}
})
// 全局后置钩子
router.afterEach((to, from) => {
// 设置页面标题
document.title = to.meta.title || 'Vue App'
// 发送页面访问统计
analytics.track('page_view', {
page: to.path,
title: to.meta.title
})
})
路由独享守卫
const routes: RouteRecordRaw[] = [
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/Admin.vue'),
beforeEnter: (to, from, next) => {
// 检查管理员权限
if (hasAdminRole()) {
next()
} else {
next({ name: 'Forbidden' })
}
}
}
]
组件内守卫
<template>
<div class="user-profile">
<!-- 用户资料内容 -->
</div>
</template>
<script setup lang="ts">
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
// 路由更新守卫
onBeforeRouteUpdate(async (to, from) => {
// 当路由参数变化时,重新获取数据
if (to.params.id !== from.params.id) {
await fetchUserData(to.params.id as string)
}
})
// 路由离开守卫
onBeforeRouteLeave((to, from) => {
// 检查是否有未保存的更改
if (hasUnsavedChanges.value) {
const answer = window.confirm('您有未保存的更改,确定要离开吗?')
if (!answer) {
return false
}
}
})
</script>
数据预取
// 使用 async setup 进行数据预取
export default defineComponent({
async setup() {
const route = useRoute()
// 在组件创建前获取数据
const userData = await fetchUser(route.params.id as string)
const posts = await fetchUserPosts(route.params.id as string)
return {
userData,
posts
}
}
})
// 使用 Suspense 处理异步组件
<template>
<Suspense>
<template #default>
<UserProfile />
</template>
<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</template>
🗃 Pinia 状态管理
基础概念
Pinia 是 Vue 的官方状态管理库,它提供了:
- 类型安全:完整的 TypeScript 支持
- 模块化:每个 store 都是独立的模块
- 组合式 API:与 Vue 3 的组合式 API 完美集成
- 开发工具:Vue DevTools 支持
安装与配置
# 安装 Pinia
pnpm add pinia
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
Store 定义
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const isLoggedIn = computed(() => !!user.value)
const userName = computed(() => user.value?.name || 'Guest')
const userRole = computed(() => user.value?.role || 'user')
// Actions
const login = async (credentials: LoginCredentials) => {
loading.value = true
error.value = null
try {
const response = await api.login(credentials)
user.value = response.user
localStorage.setItem('token', response.token)
} catch (err) {
error.value = err instanceof Error ? err.message : '登录失败'
throw err
} finally {
loading.value = false
}
}
const logout = () => {
user.value = null
localStorage.removeItem('token')
}
const updateProfile = async (updates: Partial<User>) => {
if (!user.value) return
loading.value = true
try {
const updatedUser = await api.updateUser(user.value.id, updates)
user.value = updatedUser
} catch (err) {
error.value = err instanceof Error ? err.message : '更新失败'
throw err
} finally {
loading.value = false
}
}
return {
// State
user,
loading,
error,
// Getters
isLoggedIn,
userName,
userRole,
// Actions
login,
logout,
updateProfile
}
})
在组件中使用 Store
<template>
<div class="user-profile">
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="user" class="profile">
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
<button @click="handleLogout">退出登录</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// 使用 storeToRefs 保持响应式
const { user, loading, error, isLoggedIn } = storeToRefs(userStore)
// 直接解构 actions
const { login, logout, updateProfile } = userStore
const handleLogout = () => {
logout()
// 导航到登录页
router.push('/login')
}
</script>
模块化 Store
// stores/index.ts
export { useUserStore } from './user'
export { useProductStore } from './product'
export { useCartStore } from './cart'
// stores/product.ts
export const useProductStore = defineStore('product', () => {
const products = ref<Product[]>([])
const categories = ref<Category[]>([])
const loading = ref(false)
const getProductsByCategory = computed(() => {
return (categoryId: string) =>
products.value.filter(p => p.categoryId === categoryId)
})
const fetchProducts = async () => {
loading.value = true
try {
products.value = await api.getProducts()
} finally {
loading.value = false
}
}
return {
products,
categories,
loading,
getProductsByCategory,
fetchProducts
}
})
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const addItem = (product: Product, quantity = 1) => {
const existingItem = items.value.find(item => item.id === product.id)
if (existingItem) {
existingItem.quantity += quantity
} else {
items.value.push({
id: product.id,
name: product.name,
price: product.price,
quantity
})
}
}
const removeItem = (productId: string) => {
const index = items.value.findIndex(item => item.id === productId)
if (index > -1) {
items.value.splice(index, 1)
}
}
const clearCart = () => {
items.value = []
}
return {
items,
totalItems,
totalPrice,
addItem,
removeItem,
clearCart
}
})
💾 状态持久化与 SSR
状态持久化
# 安装持久化插件
pnpm add pinia-plugin-persistedstate
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const preferences = ref<UserPreferences>({
theme: 'light',
language: 'zh-CN'
})
return {
user,
preferences
}
}, {
persist: {
key: 'user-store',
storage: localStorage,
paths: ['user', 'preferences'] // 只持久化指定字段
}
})
// 自定义持久化配置
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
return { items }
}, {
persist: {
key: 'cart-store',
storage: sessionStorage, // 使用 sessionStorage
beforeRestore: (context) => {
console.log('恢复状态前:', context.store.$id)
},
afterRestore: (context) => {
console.log('恢复状态后:', context.store.$id)
}
}
})
SSR 集成
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const fetchUser = async () => {
if (process.server) {
// 服务端渲染时从 cookie 获取用户信息
const token = useCookie('token')
if (token.value) {
user.value = await api.getUserByToken(token.value)
}
} else {
// 客户端从 localStorage 获取
const token = localStorage.getItem('token')
if (token) {
user.value = await api.getUserByToken(token)
}
}
}
return {
user,
fetchUser
}
}, {
persist: {
storage: persistedState.localStorage
}
})
🧪 实战练习
练习1:实现路由权限控制
// router/guards.ts
export function setupRouterGuards(router: Router) {
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 检查是否需要认证
if (to.meta.requiresAuth) {
if (!userStore.isLoggedIn) {
next({
name: 'Login',
query: { redirect: to.fullPath }
})
return
}
// 检查角色权限
if (to.meta.roles && !to.meta.roles.includes(userStore.userRole)) {
next({ name: 'Forbidden' })
return
}
}
// 检查是否需要数据预取
if (to.meta.requiresData) {
try {
await userStore.fetchUserData()
} catch (error) {
next({ name: 'Error' })
return
}
}
next()
})
}
// 路由配置
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: {
requiresAuth: true,
requiresData: true
}
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/Admin.vue'),
meta: {
requiresAuth: true,
roles: ['admin']
}
}
]
练习2:实现购物车状态管理
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const loading = ref(false)
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const addItem = async (product: Product, quantity = 1) => {
loading.value = true
try {
const existingItem = items.value.find(item => item.id === product.id)
if (existingItem) {
existingItem.quantity += quantity
} else {
items.value.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
quantity
})
}
// 同步到服务器
await api.updateCart(items.value)
} catch (error) {
console.error('添加商品失败:', error)
} finally {
loading.value = false
}
}
const removeItem = async (productId: string) => {
loading.value = true
try {
const index = items.value.findIndex(item => item.id === productId)
if (index > -1) {
items.value.splice(index, 1)
await api.updateCart(items.value)
}
} catch (error) {
console.error('移除商品失败:', error)
} finally {
loading.value = false
}
}
const updateQuantity = async (productId: string, quantity: number) => {
if (quantity <= 0) {
await removeItem(productId)
return
}
loading.value = true
try {
const item = items.value.find(item => item.id === productId)
if (item) {
item.quantity = quantity
await api.updateCart(items.value)
}
} catch (error) {
console.error('更新数量失败:', error)
} finally {
loading.value = false
}
}
const clearCart = async () => {
loading.value = true
try {
items.value = []
await api.clearCart()
} catch (error) {
console.error('清空购物车失败:', error)
} finally {
loading.value = false
}
}
const checkout = async () => {
loading.value = true
try {
const order = await api.createOrder(items.value)
items.value = []
return order
} catch (error) {
console.error('结算失败:', error)
throw error
} finally {
loading.value = false
}
}
return {
items,
loading,
totalItems,
totalPrice,
addItem,
removeItem,
updateQuantity,
clearCart,
checkout
}
}, {
persist: {
key: 'cart-store',
storage: localStorage
}
})
练习3:实现数据预取和缓存
// composables/useDataFetch.ts
export function useDataFetch<T>(
key: string,
fetcher: () => Promise<T>,
options: {
cache?: boolean
staleTime?: number
refetchOnMount?: boolean
} = {}
) {
const {
cache = true,
staleTime = 5 * 60 * 1000, // 5分钟
refetchOnMount = true
} = options
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const lastFetch = ref<number>(0)
const isStale = computed(() => {
return Date.now() - lastFetch.value > staleTime
})
const fetch = async () => {
if (loading.value) return
loading.value = true
error.value = null
try {
const result = await fetcher()
data.value = result
lastFetch.value = Date.now()
if (cache) {
localStorage.setItem(`cache_${key}`, JSON.stringify({
data: result,
timestamp: lastFetch.value
}))
}
} catch (err) {
error.value = err as Error
} finally {
loading.value = false
}
}
const loadFromCache = () => {
if (!cache) return false
try {
const cached = localStorage.getItem(`cache_${key}`)
if (cached) {
const { data: cachedData, timestamp } = JSON.parse(cached)
data.value = cachedData
lastFetch.value = timestamp
return true
}
} catch (err) {
console.warn('缓存读取失败:', err)
}
return false
}
onMounted(() => {
if (refetchOnMount) {
if (!loadFromCache() || isStale.value) {
fetch()
}
}
})
return {
data: readonly(data),
loading: readonly(loading),
error: readonly(error),
fetch,
isStale
}
}
// 使用示例
export function useUserPosts(userId: string) {
return useDataFetch(
`user-posts-${userId}`,
() => api.getUserPosts(userId),
{
cache: true,
staleTime: 2 * 60 * 1000, // 2分钟
refetchOnMount: true
}
)
}
📚 最佳实践
1. 路由设计
// ✅ 好的路由结构
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/products',
name: 'Products',
component: () => import('@/views/Products.vue'),
children: [
{
path: '',
name: 'ProductList',
component: () => import('@/views/Products/List.vue')
},
{
path: ':id',
name: 'ProductDetail',
component: () => import('@/views/Products/Detail.vue'),
props: true
}
]
}
]
// ❌ 避免:过于复杂的嵌套
const routes: RouteRecordRaw[] = [
{
path: '/a/b/c/d/e',
component: () => import('@/views/DeepNested.vue')
}
]
2. 状态管理设计
// ✅ 好的状态设计:单一职责
export const useUserStore = defineStore('user', () => {
// 只管理用户相关状态
const user = ref<User | null>(null)
const preferences = ref<UserPreferences>({})
return { user, preferences }
})
export const useProductStore = defineStore('product', () => {
// 只管理产品相关状态
const products = ref<Product[]>([])
const categories = ref<Category[]>([])
return { products, categories }
})
// ❌ 避免:单一 store 管理所有状态
export const useAppStore = defineStore('app', () => {
// 管理所有应用状态
const user = ref<User | null>(null)
const products = ref<Product[]>([])
const cart = ref<CartItem[]>([])
const orders = ref<Order[]>([])
// ... 更多状态
})
3. 性能优化
// ✅ 使用 computed 缓存计算结果
export const useProductStore = defineStore('product', () => {
const products = ref<Product[]>([])
const expensiveCalculation = computed(() => {
return products.value
.filter(p => p.active)
.sort((a, b) => b.rating - a.rating)
.slice(0, 10)
})
return { products, expensiveCalculation }
})
// ✅ 使用 shallowRef 优化大对象
export const useDataStore = defineStore('data', () => {
const largeDataSet = shallowRef<LargeData[]>([])
return { largeDataSet }
})
🚨 常见问题
1. 路由参数类型问题
问题:路由参数类型不明确
// ❌ 错误
const userId = route.params.id // 类型是 string | string[]
// ✅ 正确
const userId = route.params.id as string
// 或者使用类型守卫
const userId = Array.isArray(route.params.id)
? route.params.id[0]
: route.params.id
2. Store 响应式丢失
问题:解构 store 导致响应式丢失
// ❌ 错误
const { user, loading } = useUserStore()
// ✅ 正确
const userStore = useUserStore()
const { user, loading } = storeToRefs(userStore)
3. 路由守卫死循环
问题:路由守卫中的无限重定向
// ❌ 错误
router.beforeEach((to, from, next) => {
if (!isAuthenticated) {
next('/login') // 可能导致死循环
}
})
// ✅ 正确
router.beforeEach((to, from, next) => {
if (!isAuthenticated && to.name !== 'Login') {
next({ name: 'Login' })
} else {
next()
}
})
📖 延伸阅读
下一章预告:我们将学习工程化与性能优化,包括 Vite 构建优化、SSR/Nuxt 配置、可访问性、安全性等,为构建生产级应用打下基础。