HiHuo
首页
博客
手册
工具
首页
博客
手册
工具
  • Vue & CSS 进阶

    • 第0章:工具链与开发体验
    • 第1章:TypeScript 核心
    • 第2章:CSS 现代能力
    • 第3章:Vue3 核心与原理
    • 第4章:路由与状态管理
    • 第5章:工程化与性能
    • 第6章:测试与质量
    • 第7章:CSS 进阶专题
    • 第8章:项目实战A - SaaS仪表盘
    • 第9章:项目实战B - 内容社区
    • 第10章:Vue 内核深入
    • 第11章:微前端与部署
    • CSS 变量体系与主题系统深度指南
    • 前端安全与防护专题
    • CSS 专题:动效优化与微交互
    • CSS 专题:图片与图形处理
    • 实时通信与WebSocket专题
    • 开发工具与调试技巧专题
    • 新兴技术与未来趋势专题
    • 移动端开发与PWA专题
    • 附录A:代码片段库
    • 附录B:练习题集

第4章:路由与状态管理

掌握 Vue Router 和 Pinia 的核心用法,构建复杂的单页应用。本章将深入讲解路由配置、导航守卫、状态管理的最佳实践,为构建大型应用打下基础。

📋 本章内容

  • Vue Router 核心概念
  • 路由配置与导航
  • 导航守卫与数据预取
  • Pinia 状态管理
  • 状态持久化与 SSR
  • 实战练习

🎯 学习目标

  • 掌握 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()
  }
})

📖 延伸阅读

  • Vue Router 官方文档
  • Pinia 官方文档
  • Vue Router 源码
  • Pinia 源码

下一章预告:我们将学习工程化与性能优化,包括 Vite 构建优化、SSR/Nuxt 配置、可访问性、安全性等,为构建生产级应用打下基础。

Prev
第3章:Vue3 核心与原理
Next
第5章:工程化与性能